DDD spełnia OOP: jak zaimplementować repozytorium obiektowe?

12

Typowa implementacja repozytorium DDD nie wygląda zbyt dobrze, na przykład save()metoda:

package com.example.domain;

public class Product {  /* public attributes for brevity */
    public String name;
    public Double price;
}

public interface ProductRepo {
    void save(Product product);
} 

Część infrastruktury:

package com.example.infrastructure;
// imports...

public class JdbcProductRepo implements ProductRepo {
    private JdbcTemplate = ...

    public void save(Product product) {
        JdbcTemplate.update("INSERT INTO product (name, price) VALUES (?, ?)", 
            product.name, product.price);
    }
} 

Taki interfejs oczekuje, że Productbędzie to model anemiczny, przynajmniej z getterami.

Z drugiej strony OOP mówi, że Productobiekt powinien wiedzieć, jak się uratować.

package com.example.domain;

public class Product {
    private String name;
    private Double price;

    void save() {
        // save the product
        // ???
    }
}

Chodzi o to, że gdy Productumie się zapisać, oznacza to, że kod infrastruktury nie jest oddzielony od kodu domeny.

Może możemy przekazać zapisywanie innemu obiektowi:

package com.example.domain;

public class Product {
    private String name;
    private Double price;

    void save(Storage storage) {
        storage
            .with("name", this.name)
            .with("price", this.price)
            .save();
    }
}

public interface Storage {
    Storage with(String name, Object value);
    void save();
}

Część infrastruktury:

package com.example.infrastructure;
// imports...

public class JdbcProductRepo implements ProductRepo {        
    public void save(Product product) {
        product.save(new JdbcStorage());
    }
}

class JdbcStorage implements Storage {
    private final JdbcTemplate = ...
    private final Map<String, Object> attrs = new HashMap<>();

    private final String tableName;

    public JdbcStorage(String tableName) {
        this.tableName = tableName;
    }

    public Storage with(String name, Object value) {
        attrs.put(name, value);
    }
    public void save() {
        JdbcTemplate.update("INSERT INTO " + tableName + " (name, price) VALUES (?, ?)", 
            attrs.get("name"), attrs.get("price"));
    }
}

Jakie jest najlepsze podejście do tego? Czy można zaimplementować repozytorium obiektowe?

ttulka
źródło
6
OOP mówi, że obiekt produktu powinien wiedzieć, jak się uratować - nie jestem pewien, czy to jest naprawdę poprawne ... OOP sam w sobie tak naprawdę nie dyktuje, to raczej problem z projektem / wzorcem (gdzie DDD / cokolwiek-ty -use wchodzi)
jleach
1
Pamiętaj, że w kontekście OOP chodzi o przedmioty. Tylko obiekty, a nie trwałość danych. Twoje stwierdzenie wskazuje, że stanem obiektu nie należy zarządzać poza sobą, z czym się zgadzam. Repozytorium jest odpowiedzialne za ładowanie / zapisywanie z jakiejś warstwy trwałości (która jest poza sferą OOP). Właściwości i metody klasy powinny zachować swoją integralność, tak, ale to nie znaczy, że inny obiekt nie może być odpowiedzialny za utrzymanie stanu. I pobierające i ustawiające mają zapewnić integralność przychodzących / wychodzących danych obiektu.
jleach
1
„nie oznacza to, że inny obiekt nie może być odpowiedzialny za utrzymanie stanu”. - Nie powiedziałem tego. Ważnym stwierdzeniem jest to, że obiekt powinien być aktywny . Oznacza to, że obiekt (i nikt inny) nie może przekazać tej operacji innemu obiektowi, ale nie na odwrót: żaden obiekt nie powinien po prostu zbierać informacji z obiektu pasywnego w celu przetworzenia własnej egoistycznej operacji (tak jak repozytorium zrobiłoby to z getterami) . Próbowałem zaimplementować to podejście we fragmentach powyżej.
ttulka
1
@jleach Masz rację, nasze niezrozumienie OOP jest inne, dla mnie getters + setters w ogóle nie są OOP, w przeciwnym razie moje pytanie nie miałoby sensu. Mimo wszystko dziekuję! :-)
ttulka
1
Oto artykuł o moim punkcie: martinfowler.com/bliki/AnemicDomainModel.html Nie jestem pewien , czy model anemiczny jest we wszystkich przypadkach, na przykład jest to dobra strategia programowania funkcjonalnego. Po prostu nie OOP.
ttulka

Odpowiedzi:

7

Napisałeś

Z drugiej strony OOP mówi, że obiekt produktu powinien wiedzieć, jak się zapisać

i w komentarzu.

... powinien być odpowiedzialny za wszystkie operacje z nim wykonane

Jest to powszechne nieporozumienie. Productjest obiektem domeny, więc powinien być odpowiedzialny za operacje domeny , które dotyczą jednego obiektu produktu, nie mniej, nie więcej - więc zdecydowanie nie za wszystkie operacje. Zwykle trwałość nie jest postrzegana jako operacja domeny. Wręcz przeciwnie, w aplikacjach korporacyjnych nierzadko próbuje się osiągnąć ignorancję w modelu domenowym (przynajmniej w pewnym stopniu), a utrzymywanie mechaniki trwałości w oddzielnej klasie repozytorium jest popularnym rozwiązaniem. „DDD” to technika, która ma na celu tego rodzaju aplikacje.

Więc co może być rozsądną operacją domenową dla Product? Zależy to w rzeczywistości od kontekstu domeny systemu aplikacji. Jeśli system jest mały i obsługuje wyłącznie operacje CRUD, wówczas rzeczywiście Productmoże pozostać dość „anemiczny”, jak w twoim przykładzie. W przypadku takich aplikacji może być dyskusyjne, czy umieszczenie operacji bazy danych w osobnej klasie repo lub użycie DDD w ogóle jest kłopotliwe.

Jednak gdy tylko aplikacja obsługuje rzeczywiste operacje biznesowe, takie jak kupowanie lub sprzedawanie produktów, utrzymywanie ich w magazynie i zarządzanie nimi, lub obliczanie podatków dla nich, dość często zaczynasz odkrywać operacje, które można rozsądnie umieścić w Productklasie. Na przykład może istnieć operacja, CalcTotalPrice(int noOfItems)która oblicza cenę za pozycje n określonego produktu, biorąc pod uwagę rabaty ilościowe.

Krótko mówiąc, projektując klasy, musisz pomyśleć o swoim kontekście, w którym jesteś z pięciu światów Joela Spolsky'ego , a jeśli system zawiera wystarczającą logikę domeny, więc DDD będzie korzystne. Jeśli odpowiedź brzmi „tak”, jest mało prawdopodobne, że skończysz z modelem anemicznym tylko dlatego, że trzymasz mechanizmy trwałości poza klasami domen.

Doktor Brown
źródło
Twój punkt wydaje mi się bardzo rozsądny. Tak więc produkt staje się anemiczną strukturą danych po przekroczeniu granicy kontekstu anemicznych struktur danych (bazy danych), a repozytorium jest bramą. Ale to wciąż oznacza, że ​​muszę zapewnić dostęp do wewnętrznej struktury obiektu za pomocą gettera i setterów, które następnie stają się częścią jego interfejsu API i mogą być łatwo nadużywane przez inny kod, który nie ma nic wspólnego z trwałością. Czy istnieje dobra praktyka, jak tego uniknąć? Dziękuję Ci!
ttulka
„Ale to wciąż oznacza, że ​​muszę zapewnić dostęp do wewnętrznej struktury obiektu za pomocą gettera i seterów” - mało prawdopodobne. Wewnętrzny stan obiektu domeny nieświadomego przetrwania jest zwykle podawany wyłącznie przez zestaw atrybutów związanych z domeną. W przypadku tych atrybutów muszą istnieć moduły pobierające i ustawiające (lub inicjujące konstruktora), w przeciwnym razie nie byłoby możliwe „interesujące” działanie domeny. W kilku ramach dostępne są również funkcje utrwalania, które pozwalają zachować atrybuty prywatne przez odbicie, więc enkapsulacja jest zepsuta tylko dla tego mechanizmu, a nie dla „innego kodu”.
Doc Brown
1
Zgadzam się, że trwałość zwykle nie jest częścią operacji domeny, jednak powinna być częścią „prawdziwych” operacji domeny wewnątrz obiektu, który tego potrzebuje. Na przykład Account.transfer(amount)powinien zachować transfer. Jak to robi, to odpowiedzialność obiektu, a nie jakiegoś zewnętrznego bytu. Z drugiej strony wyświetlanie obiektu jest zwykle operacją domeny! Wymagania zwykle opisują bardzo szczegółowo, jak powinny wyglądać rzeczy. Jest to część języka wśród członków projektu, biznesu lub nie.
Robert Bräutigam
@ RobertBräutigam: klasyczny Account.transferdla zwykle obejmuje dwa obiekty konta i obiekt jednostki pracy. Operacja polegająca na utrwalaniu transakcji mogłaby wówczas stanowić część tego ostatniego (razem z wywołaniami powiązanych repozytoriów), więc nie wchodzi w grę metoda „transferu”. W ten sposób Accountmoże pozostać ignorantem. Nie twierdzę, że jest to z pewnością lepsze niż twoje domniemane rozwiązanie, ale twoje jest również tylko jednym z kilku możliwych podejść.
Doc Brown
1
@ RobertBräutigam Jestem pewien, że za dużo myślisz o relacji między obiektem a stołem. Pomyśl o tym, że obiekt ma stan dla siebie, wszystko w pamięci. Po wykonaniu przelewów w obiektach konta pozostaną obiekty z nowym stanem. Właśnie to chciałbyś utrwalić i na szczęście obiekty kont zapewniają sposób, aby poinformować Cię o ich stanie. Nie oznacza to, że ich stan musi być równy tabelom w bazie danych - tzn. Przekazana kwota może być obiektem pieniężnym zawierającym surową kwotę i walutę.
Steve Chamaillard
5

Ćwicz teorię atutów.

Doświadczenie uczy nas, że Product.Save () prowadzi do wielu problemów. Aby obejść te problemy, opracowaliśmy wzorzec repozytorium.

Na pewno łamie zasadę OOP dotyczącą ukrywania danych produktu. Ale działa dobrze.

Znacznie trudniej jest stworzyć zestaw spójnych reguł obejmujących wszystko, niż stworzyć ogólne dobre reguły z wyjątkami.

Ewan
źródło
3

DDD spełnia OOP

Pomaga pamiętać, że między tymi dwiema ideami nie ma napięcia - obiekty wartości, agregaty, repozytoria to tablica używanych wzorców, co niektórzy uważają za wykonane poprawnie.

Z drugiej strony OOP mówi, że obiekt produktu powinien wiedzieć, jak się zapisać.

Skąd. Obiekty hermetyzują własne struktury danych. Twoja reprezentacja produktu w pamięci jest odpowiedzialna za prezentowanie zachowań produktu (czymkolwiek są); ale trwały magazyn jest tam (za repozytorium) i ma swoją własną pracę.

Musi istnieć sposób na skopiowanie danych między reprezentacją bazy danych w pamięci a jej utrwaloną pamiątką. Na granicy rzeczy stają się dość prymitywne.

Zasadniczo bazy danych tylko do zapisu nie są szczególnie przydatne, a ich odpowiedniki w pamięci nie są bardziej użyteczne niż sortowanie „utrwalone”. Nie ma sensu umieszczać informacji w Productobiekcie, jeśli nigdy nie zamierzasz ich wyciągać. Niekoniecznie będziesz używać „pobierających” - nie próbujesz udostępniać struktury danych produktu, a na pewno nie powinieneś udostępniać zmiennego dostępu do wewnętrznej reprezentacji Produktu.

Może możemy przekazać zapisywanie innemu obiektowi:

To z pewnością działa - twoje trwałe miejsce pamięci skutecznie staje się oddzwanianiem. Prawdopodobnie uprościłbym interfejs:

interface ProductStorage {
    onProduct(String name, double price);
}

Będzie istniało sprzężenie między reprezentacją w pamięci a mechanizmem przechowywania, ponieważ informacje muszą być przekazywane z miejsca na miejsce (iz powrotem). Zmiana informacji, które zostaną udostępnione, wpłynie na oba końce rozmowy. Równie dobrze moglibyśmy wyrazić to wyraźnie, gdy tylko możemy.

Takie podejście - przekazywanie danych za pośrednictwem wywołań zwrotnych, odegrało ważną rolę w rozwoju próbnych prób w TDD .

Pamiętaj, że przekazywanie informacji do oddzwaniania ma te same ograniczenia, co zwracanie informacji z zapytania - nie powinieneś przekazywać zmiennych kopii swoich struktur danych.

To podejście jest trochę sprzeczne z tym, co Evans opisał w Niebieskiej Księdze, w której zwracanie danych za pomocą zapytania było normalnym sposobem postępowania, a obiekty domeny zostały zaprojektowane specjalnie w celu uniknięcia mieszania się w „obawach związanych z trwałością”.

Rozumiem DDD jako technikę OOP i dlatego chcę w pełni zrozumieć tę pozorną sprzeczność.

Należy pamiętać o jednej rzeczy - Niebieska Księga została napisana piętnaście lat temu, gdy Java 1.4 wędrowała po ziemi. W szczególności książka poprzedza generyczne Java - mamy teraz o wiele więcej dostępnych technik niż wtedy, gdy Evans rozwijał swoje pomysły.

VoiceOfUnreason
źródło
2
Warto również wspomnieć: „zapisywanie się” zawsze wymagałoby interakcji z innymi obiektami (obiektem systemu plików, bazą danych lub zdalną usługą internetową, niektóre z nich mogą dodatkowo wymagać ustanowienia sesji w celu kontroli dostępu). Zatem taki przedmiot nie byłby samodzielny i niezależny. OOP nie może zatem tego wymagać, ponieważ jego celem jest obudowanie obiektu i ograniczenie sprzężenia.
Christophe
Dziękuję za świetną odpowiedź. Najpierw zaprojektowałem Storageinterfejs w taki sam sposób, jak Ty, a potem wziąłem pod uwagę wysokie sprzężenie i zmieniłem go. Ale masz rację, i tak istnieje nieuniknione sprzężenie, więc dlaczego nie uczynić tego bardziej wyraźnym.
ttulka
1
„To podejście jest trochę sprzeczne z tym, co Evans opisał w Niebieskiej Księdze” - więc w końcu jest pewne napięcie :-) To był właśnie cel mojego pytania, rozumiem DDD jako technikę OOP i dlatego chcę w pełni rozumiem tę pozorną sprzeczność.
ttulka
1
Z mojego doświadczenia wynika, że ​​każda z tych rzeczy (ogólnie OOP, DDD, TDD, pick-your-acronym) brzmi sama w sobie ładnie i dobrze, ale ilekroć chodzi o implementację w „prawdziwym świecie”, zawsze występuje pewien kompromis lub mniej niż idealizm, który musi być, aby działał.
jleach
Nie zgadzam się z opinią, że upór (i prezentacja) są w jakiś sposób „wyjątkowe”. Oni nie są. Powinny być częścią modelowania w celu rozszerzenia wymagań. Tam nie musi być sztuczny (dane oparte) graniczne wewnątrz aplikacji, chyba że istnieją rzeczywiste wymagania inaczej.
Robert Bräutigam
1

Bardzo dobre obserwacje, całkowicie się z nimi zgadzam. Oto moje omówienie (poprawka: tylko slajdy) na dokładnie ten temat: Object-Oriented Domain-Driven Design .

Krótka odpowiedź: nie. W twojej aplikacji nie powinno być obiektu czysto technicznego i niezwiązanego z domeną. To tak, jak zaimplementowanie struktury rejestrowania w aplikacji księgowej.

Twój Storageprzykład interfejsu jest doskonały, zakładając, że Storagejest on wtedy uważany za jakiś szkielet zewnętrzny, nawet jeśli go napiszesz.

Ponadto save()w obiekcie należy zezwolić tylko wtedy, gdy jest on częścią domeny („język”). Na przykład nie powinienem być zobowiązany do „zapisania” Accountpo zadzwonieniu transfer(amount). Słusznie powinienem oczekiwać, że funkcja biznesowa transfer()utrzyma mój transfer.

Podsumowując, myślę, że pomysły DDD są dobre. Używanie wszechobecnego języka, ćwiczenie domeny rozmową, ograniczonymi kontekstami itp. Bloki konstrukcyjne wymagają jednak poważnego remontu, jeśli są zgodne z orientacją obiektową. Szczegółowe informacje można znaleźć w połączonej talii.

Robert Bräutigam
źródło
Czy gdzieś rozmawiasz? (Widzę, że tylko slajdy pod linkiem). Dzięki!
ttulka
Mam tylko niemieckie nagranie rozmowy, tutaj: javadevguy.wordpress.com/2018/11/26/...
Robert Bräutigam
Świetna rozmowa! (Na szczęście mówię po niemiecku). Myślę, że warto przeczytać cały blog ... Dziękuję za pracę!
ttulka
Bardzo wnikliwy suwak Robert. Uważam, że jest to bardzo ilustrujące, ale mam wrażenie, że na końcu wiele rozwiązań skierowanych do nie przerywania enkapsulacji i LoD opiera się na nadawaniu dużej odpowiedzialności obiektowi domeny: drukowanie, serializacja, formatowanie interfejsu użytkownika itp. • które zwiększają sprzężenie między domeną a kwestiami technicznymi (szczegóły implementacji)? Na przykład AccountNumber w połączeniu z Apache Wicket API. Lub Konto z dowolnym obiektem Json? Czy uważasz, że warto mieć połączenie?
Laiv
@Laiv Gramatyka twojego pytania sugeruje, że jest coś złego w korzystaniu z technologii do wdrażania funkcji biznesowych? Ujmijmy to w ten sposób: problem nie polega na połączeniu domeny i technologii, lecz na sprzężeniu różnych poziomów abstrakcji. Na przykład AccountNumber powinien wiedzieć, że może być reprezentowany jako TextField. Jeśli inni (jak na „View”), to wiedziałby, że jest sprzężenie, które nie powinny istnieć, ponieważ składnik ten musiałby wiedzieć, co AccountNumberskłada, czyli wewnętrzne.
Robert Bräutigam,
1

Może możemy przekazać zapisywanie innemu obiektowi

Unikaj niepotrzebnego rozpowszechniania wiedzy o polach. Im więcej rzeczy wie o danym polu, tym trudniej jest go dodać lub usunąć:

public class Product {
    private String name;
    private Double price;

    void save(Storage storage) {
        storage.save( toString() );
    }
}

W tym przypadku produkt nie ma pojęcia, czy zapisujesz plik dziennika, bazę danych czy jedno i drugie. Tutaj metoda zapisu nie ma pojęcia, czy masz 4 lub 40 pól. To luźno powiązane. To dobra rzecz.

Oczywiście jest to tylko jeden przykład tego, jak możesz osiągnąć ten cel. Jeśli nie lubisz budować i analizować łańcucha znaków jako DTO, możesz także użyć kolekcji. LinkedHashMapjest moim ulubionym, ponieważ zachowuje porządek, a toString () wygląda dobrze w pliku dziennika.

Jakkolwiek to robisz, nie rozpowszechniaj wiedzy na temat różnych dziedzin. Jest to forma łączenia, którą ludzie często ignorują, dopóki nie jest za późno. Chcę, aby jak najmniej rzeczy statycznie wiedziało, ile pól ma mój obiekt, jak to możliwe. W ten sposób dodanie pola nie wymaga wielu edycji w wielu miejscach.

candied_orange
źródło
To jest faktycznie kod, który opublikowałem w swoim pytaniu, prawda? Użyłem a Map, proponujesz a Stringlub a List. Ale, jak wspomniał @VoiceOfUnreason w swojej odpowiedzi, połączenie wciąż istnieje, po prostu nie jest jednoznaczne. Nadal niepotrzebna jest znajomość struktury danych produktu, aby zapisać go zarówno w bazie danych, jak i pliku dziennika, przynajmniej w przypadku odczytu jako obiektu.
ttulka
Zmieniłem metodę zapisywania, ale poza tym tak, jest prawie tak samo. Różnica polega na tym, że sprzężenie nie jest już statyczne, co pozwala na dodawanie nowych pól bez wymuszania zmiany kodu w systemie pamięci masowej. To sprawia, że ​​system przechowywania może być wykorzystywany do wielu różnych produktów. To po prostu zmusza cię do robienia rzeczy, które wydają się trochę nienaturalne, takich jak zamiana podwójnej w strunę i z powrotem w podwójną. Ale można to obejść, jeśli to naprawdę problem.
candied_orange
Zobacz hetrogeniczną kolekcję
candied_orange
Ale, jak powiedziałem, widzę, że sprzężenie wciąż tam jest (przez analizowanie), ale tylko dlatego, że nie jest statyczne (wyraźne), ma tę wadę, że nie może być sprawdzone przez kompilator, a więc bardziej podatne na błędy. StorageJest częścią domeny (jak również repozytorium interfejs) i sprawia, że taka trwałości API. Po zmianie lepiej poinformować klientów w czasie kompilacji, ponieważ i tak muszą zareagować, aby nie ulec awarii w czasie wykonywania.
ttulka
To nieporozumienie. Kompilator nie może sprawdzić pliku dziennika ani bazy danych. Sprawdza tylko, czy jeden plik kodu jest zgodny z innym plikiem kodu, co również nie gwarantuje zgodności z plikiem dziennika lub bazą danych.
candied_orange
0

Istnieje alternatywa dla wspomnianych już wzorów. Wzorzec Memento doskonale nadaje się do enkapsulacji stanu wewnętrznego obiektu domeny. Obiekt memento reprezentuje migawkę stanu publicznego obiektu domeny. Obiekt domeny wie, jak utworzyć ten stan publiczny ze stanu wewnętrznego i odwrotnie. Repozytorium działa wtedy tylko z publiczną reprezentacją państwa. Dzięki temu wewnętrzne wdrożenie jest oddzielone od jakichkolwiek szczegółów dotyczących trwałości i musi jedynie utrzymać zamówienie publiczne. Również twój obiekt domeny nie musi ujawniać żadnych programów pobierających, które rzeczywiście sprawiłyby, że byłby trochę anemiczny.

Aby uzyskać więcej informacji na ten temat, polecam świetną książkę: „Wzorce, zasady i praktyki projektowania opartego na domenie” Scott Millett i Nick Tune

Roman Weis
źródło