DDD meets OOP: Wie implementiert man ein objektorientiertes Repository?

12

Eine typische Implementierung eines DDD-Repository sieht nicht besonders gut aus, zum Beispiel eine save()Methode:

package com.example.domain;

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

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

Infrastrukturteil:

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);
    }
} 

Eine solche Schnittstelle erwartet Product, dass es sich zumindest bei Gettern um ein anämisches Modell handelt.

Andererseits sagt OOP, dass ein ProductObjekt wissen sollte, wie es sich selbst retten kann.

package com.example.domain;

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

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

Die Sache ist, wenn der Productweiß, wie er sich selbst speichert, bedeutet dies, dass der Infrastrukturcode nicht vom Domänencode getrennt ist.

Vielleicht können wir die Speicherung an ein anderes Objekt delegieren:

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();
}

Infrastrukturteil:

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"));
    }
}

Was ist der beste Ansatz, um dies zu erreichen? Ist es möglich, ein objektorientiertes Repository zu implementieren?

ttulka
quelle
6
OOP sagt, ein Produktobjekt sollte wissen, wie man sich selbst speichert - ich bin nicht sicher, ob das wirklich stimmt ... OOP an sich schreibt das nicht wirklich vor, es ist eher ein Design- / Musterproblem (wo DDD / whatever-you ist -use kommt rein)
jleach
1
Denken Sie daran, dass es sich im Kontext von OOP um Objekte handelt. Nur Objekte, keine Datenpersistenz. Ihre Aussage besagt, dass der Status eines Objekts nicht außerhalb von sich selbst verwaltet werden sollte, womit ich einverstanden bin. Ein Repository ist für das Laden / Speichern von einer Persistenzschicht (die sich außerhalb des Bereichs von OOP befindet) verantwortlich. Die Klasseneigenschaften und -methoden sollten zwar ihre eigene Integrität beibehalten, dies bedeutet jedoch nicht, dass ein anderes Objekt nicht für die Beibehaltung des Status verantwortlich sein kann. Und Getter und Setter sollen die Integrität eingehender / ausgehender Daten des Objekts sicherstellen.
Jleach
1
"Dies bedeutet nicht, dass ein anderes Objekt nicht für das Fortbestehen des Staates verantwortlich sein kann." - Das habe ich nicht gesagt. Die wichtige Aussage ist, dass ein Objekt sein sollte aktiv . Dies bedeutet, dass das Objekt (und niemand anderes) diese Operation an ein anderes Objekt delegieren kann, aber nicht umgekehrt: Kein Objekt sollte nur Informationen von einem passiven Objekt sammeln, um seine eigene selbstsüchtige Operation zu verarbeiten (wie es ein Repo mit Gettern tun würde). . Ich habe versucht, diesen Ansatz in den obigen Ausschnitten zu implementieren.
ttulka
1
@jleach Du hast recht, unser Verständnis von OOP ist anders, für mich sind Getter + Setter überhaupt keine OOP, sonst hatte meine Frage keinen Sinn. Trotzdem danke! :-)
ttulka
1
Hier ist ein Artikel über meinen Standpunkt: martinfowler.com/bliki/AnemicDomainModel.html Ich bin nicht in allen Fällen gegen das anämische Modell, zum Beispiel ist es eine gute Strategie für die funktionale Programmierung. Nur nicht OOP.
ttulka

Antworten:

7

Sie schrieben

Andererseits sagt OOP, dass ein Produktobjekt wissen sollte, wie es sich selbst speichert

und in einem Kommentar.

... sollte für alle damit durchgeführten Operationen verantwortlich sein

Dies ist ein weit verbreitetes Missverständnis. ProductIst ein Domain-Objekt, so sollte es für die Domain- Operationen verantwortlich sein, die ein einzelnes Produktobjekt betreffen , nicht weniger, nicht mehr - also definitiv nicht für alle Operationen. In der Regel wird Persistenz nicht als Domänenoperation angesehen. Im Gegenteil, in Unternehmensanwendungen ist es nicht ungewöhnlich, Persistenz-Ignoranz im Domänenmodell zu erreichen (zumindest bis zu einem gewissen Grad), und die Persistenzmechanik in einer separaten Repository-Klasse zu halten, ist eine beliebte Lösung dafür. "DDD" ist eine Technik, die auf diese Art von Anwendungen abzielt.

Was könnte also eine sinnvolle Domainoperation für eine sein Product? Dies hängt tatsächlich vom Domänenkontext des Anwendungssystems ab. Wenn es sich bei dem System um ein kleines System handelt, das nur CRUD-Operationen unterstützt, Productkann es durchaus vorkommen , dass ein System wie in Ihrem Beispiel "anämisch" bleibt. Für solche Anwendungen ist es möglicherweise umstritten, ob sich das Einordnen der Datenbankoperationen in eine separate Repo-Klasse oder die Verwendung von DDD überhaupt lohnt.

Sobald Ihre Anwendung jedoch reale Geschäftsvorgänge unterstützt, z. B. den Kauf oder Verkauf von Produkten, die Vorratshaltung und -verwaltung oder die Berechnung von Steuern für diese Vorgänge, werden häufig Vorgänge erkannt, die sinnvoll in eine ProductKlasse eingeordnet werden können . Beispielsweise könnte es eine Operation geben, CalcTotalPrice(int noOfItems)die den Preis für n Artikel eines bestimmten Produkts unter Berücksichtigung von Mengenrabatten berechnet.

Kurz gesagt, wenn Sie Klassen entwerfen, müssen Sie über Ihren Kontext nachdenken, in welcher der fünf Welten von Joel Spolsky Sie sich befinden, und wenn das System genügend Domänenlogik enthält, ist DDD von Vorteil . Wenn die Antwort Ja lautet, ist es ziemlich unwahrscheinlich, dass Sie ein anämisches Modell erhalten, nur weil Sie die Persistenzmechanik außerhalb der Domänenklassen halten.

Doc Brown
quelle
Ihr Punkt klingt für mich sehr vernünftig. Das Produkt wird zu einer anämischen Datenstruktur, wenn eine Grenze eines Kontexts anämischer Datenstrukturen (Datenbank) überschritten wird und das Repository ein Gateway ist. Das bedeutet aber immer noch, dass ich über Getter und Setter Zugriff auf die interne Struktur des Objekts gewähren muss. Diese werden dann Teil der API und können leicht von anderem Code missbraucht werden, der nichts mit Persistenz zu tun hat. Gibt es eine bewährte Methode, um dies zu vermeiden? Vielen Dank!
ttulka
"Das bedeutet aber immer noch, dass ich über Getter und Setter Zugriff auf die interne Struktur des Objekts gewähren muss" - unwahrscheinlich. Der interne Zustand eines persistenzunabhängigen Domänenobjekts wird normalerweise ausschließlich durch eine Reihe domänenbezogener Attribute angegeben. Für diese Attribute müssen Get- und Setter (oder eine Konstruktorinitialisierung) existieren, da sonst keine "interessante" Domainoperation möglich wäre. In mehreren Frameworks stehen auch Persistenzfunktionen zur Verfügung, mit denen private Attribute durch Reflektion beibehalten werden können, sodass die Kapselung nur für diesen Mechanismus und nicht für "anderen Code" unterbrochen wird.
Doc Brown
1
Ich bin damit einverstanden, dass die Persistenz normalerweise nicht Teil von Domänenoperationen ist, sie sollte jedoch Teil der "echten" Domänenoperationen innerhalb des Objekts sein, das sie benötigt. Zum Beispiel Account.transfer(amount)sollte die Übertragung bestehen bleiben. Wie es das macht, liegt in der Verantwortung des Objekts, nicht in der einer externen Entität. Das Anzeigen des Objekts hingegen ist in der Regel eine Domain-Operation! Die Anforderungen beschreiben in der Regel sehr detailliert, wie das Zeug aussehen soll. Es ist Teil der Sprache unter Projektmitgliedern, Unternehmen oder auf andere Weise.
Robert Bräutigam
@ RobertBräutigam: Der Klassiker Account.transferfür beinhaltet normalerweise zwei Kontenobjekte und eine Arbeitseinheit. Die Transaktions-Persistenz-Operation könnte dann Teil der letzteren sein (zusammen mit Aufrufen verwandter Repos), sodass sie von der "Transfer" -Methode ausgeschlossen bleibt. Auf diese Weise Accountkann Beharrlichkeit ignorant bleiben. Ich sage nicht, dass dies unbedingt besser ist als Ihre vermeintliche Lösung, aber Ihre ist auch nur einer von mehreren möglichen Ansätzen.
Doc Brown
1
@ RobertBräutigam Ziemlich sicher, dass du zu viel über die Beziehung zwischen dem Objekt und der Tabelle nachdenkst. Stellen Sie sich das Objekt als einen Zustand vor, der sich alle im Gedächtnis befindet. Nachdem Sie die Überweisungen in Ihren Kontoobjekten vorgenommen haben, verbleiben Objekte mit neuem Status. Das ist, was Sie beibehalten möchten, und zum Glück bieten die Kontoobjekte eine Möglichkeit, Sie über ihren Status zu informieren. Das bedeutet nicht, dass ihr Status mit den Tabellen in der Datenbank übereinstimmen muss - dh der überwiesene Betrag kann ein Geldobjekt sein, das den Rohbetrag und die Währung enthält.
Steve Chamaillard
5

Praxis übertrumpft Theorie.

Die Erfahrung lehrt uns, dass Product.Save () zu vielen Problemen führt. Um diese Probleme zu umgehen, haben wir das Repository-Muster erfunden.

Sicher, es verstößt gegen die OOP-Regel, die Produktdaten zu verbergen. Aber es funktioniert gut.

Es ist viel schwieriger, einheitliche Regeln zu erstellen, die alles abdecken, als allgemeine Regeln zu erstellen, die Ausnahmen enthalten.

Ewan
quelle
3

DDD trifft OOP

Es ist hilfreich, sich vor Augen zu halten, dass es keine Spannung zwischen diesen beiden Ideen geben soll - Wertobjekte, Aggregate, Repositorys sind eine Reihe von Mustern, die verwendet werden, was einige für richtig halten.

Andererseits sagt OOP, dass ein Produktobjekt wissen sollte, wie es sich selbst speichert.

Nicht so. Objekte kapseln ihre eigenen Datenstrukturen. Ihre In-Memory-Darstellung eines Produkts ist dafür verantwortlich, dass das Produktverhalten angezeigt wird (unabhängig davon, was es ist). aber der persistente Speicher ist dort (hinter dem Repository) und hat seine eigene Arbeit zu erledigen.

Es muss eine Möglichkeit geben, Daten zwischen der In-Memory-Darstellung der Datenbank und ihrem dauerhaften Andenken zu kopieren . An der Grenze tendieren die Dinge dazu, ziemlich primitiv zu werden.

Grundsätzlich sind reine Schreibdatenbanken nicht besonders nützlich, und ihre Entsprechungen im Arbeitsspeicher sind nicht nützlicher als die "dauerhafte" Sortierung. Es hat keinen Sinn, Informationen in ein ProductObjekt einzufügen, wenn Sie diese Informationen niemals löschen wollen. Sie werden nicht unbedingt "Getter" verwenden - Sie versuchen nicht, die Produktdatenstruktur freizugeben, und Sie sollten auf keinen Fall einen veränderlichen Zugriff auf die interne Darstellung des Produkts freigeben.

Vielleicht können wir die Speicherung an ein anderes Objekt delegieren:

Das funktioniert auf jeden Fall - Ihr persistenter Speicher wird effektiv zum Rückruf. Ich würde das Interface wahrscheinlich einfacher machen:

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

Es wird gehen zwischen dem in Speicherdarstellung und der Speichermechanismus zu koppeln, da die Informationen von hier nach dort zu kommen brauchen (und wieder zurück). Das Ändern der Informationen, die geteilt werden sollen, wirkt sich auf beide Enden der Konversation aus. Also können wir das genauso gut explizit machen, wo wir können.

Dieser Ansatz - die Weitergabe von Daten über Rückrufe - spielte eine wichtige Rolle bei der Entwicklung von Mocks in TDD .

Beachten Sie, dass die Weitergabe der Informationen an den Rückruf dieselben Einschränkungen aufweist wie die Rückgabe der Informationen aus einer Abfrage. Sie sollten keine veränderlichen Kopien Ihrer Datenstrukturen weitergeben.

Dieser Ansatz steht ein wenig im Widerspruch zu dem, was Evans im Blue Book beschrieben hat, in dem das Zurückgeben von Daten über eine Abfrage die übliche Vorgehensweise war und Domänenobjekte speziell entwickelt wurden, um das Einmischen von "Persistenzbedenken" zu vermeiden.

Ich verstehe DDD als eine OOP-Technik und möchte diesen scheinbaren Widerspruch vollständig verstehen.

Beachten Sie Folgendes: Das Blaue Buch wurde vor fünfzehn Jahren geschrieben, als Java 1.4 die Erde durchstreifte. Insbesondere ist das Buch älter als Java- Generika - wir haben jetzt viel mehr Techniken zur Verfügung, als Evans seine Ideen entwickelte.

VoiceOfUnreason
quelle
2
Erwähnenswert ist auch: "Sichern" erfordert immer die Interaktion mit anderen Objekten (entweder einem Dateisystemobjekt oder einer Datenbank oder einem Remotewebdienst; für einige dieser Objekte muss möglicherweise zusätzlich eine Sitzung für die Zugriffssteuerung eingerichtet werden). Ein solches Objekt wäre also nicht eigenständig und unabhängig. OOP kann dies daher nicht verlangen, da es beabsichtigt, ein Objekt einzukapseln und die Kopplung zu verringern.
Christophe
Vielen Dank für eine tolle Antwort. Zuerst habe ich die StorageBenutzeroberfläche auf die gleiche Weise wie Sie entworfen, dann habe ich über hohe Kopplung nachgedacht und sie geändert. Aber Sie haben Recht, es gibt ohnehin eine unvermeidliche Kopplung, warum also nicht expliziter.
ttulka
1
"Dieser Ansatz ist ein bisschen im Gegensatz zu dem, was Evans im Blue Book beschrieben hat" - es gibt also doch einige Spannungen :-) Das war eigentlich der Punkt meiner Frage, ich verstehe DDD als eine OOP-Technik und möchte es daher Verstehe diesen scheinbaren Widerspruch voll und ganz.
ttulka
1
Nach meiner Erfahrung klingt jedes dieser Dinge (OOP im Allgemeinen, DDD, TDD, Pick-your-Akronym) an und für sich gut und in Ordnung, aber wenn es um die "echte" Implementierung geht, gibt es immer einen Kompromiss oder Weniger als Idealismus, das muss sein, damit es funktioniert.
Juli,
Ich bin mit der Vorstellung nicht einverstanden, dass die Beharrlichkeit (und Präsentation) irgendwie "speziell" sind. Sie sind nicht. Sie sollten Teil der Modellierung sein, um den Bedarf zu decken. Es muss keine künstliche (datenbasierte) Grenze innerhalb der Anwendung geben, es sei denn, es bestehen tatsächliche gegenteilige Anforderungen.
Robert Bräutigam
1

Sehr gute Beobachtungen, da stimme ich Ihnen voll und ganz zu. Hier ist eine Rede von mir (Korrektur: nur Folien) zu genau diesem Thema: Objektorientiertes domänengetriebenes Design .

Kurze Antwort: nein. In Ihrer Anwendung sollte sich kein Objekt befinden , das rein technisch ist und keine Domain-Relevanz hat. Das entspricht dem Implementieren des Protokollierungsframeworks in einer Buchhaltungsanwendung.

Ihr StorageSchnittstellenbeispiel ist ausgezeichnet, vorausgesetzt, das Storagewird dann als ein externes Framework betrachtet, auch wenn Sie es schreiben.

Außerdem sollte save()in einem Objekt nur erlaubt sein, wenn das Teil der Domain ist (die "Sprache"). Zum Beispiel sollte ich nicht aufgefordert werden, ein Accountnach dem Anruf explizit "zu speichern" transfer(amount). Ich sollte zu Recht damit rechnen, dass die Geschäftsfunktion transfer()meine Übertragung fortsetzt.

Alles in allem finde ich die Ideen von DDD gut. Verwenden der allgegenwärtigen Sprache, Trainieren der Domäne mit Konversation, begrenzten Kontexten usw. Die Bausteine ​​müssen jedoch grundlegend überarbeitet werden, um mit der Objektorientierung kompatibel zu sein. Einzelheiten finden Sie im verknüpften Deck.

Robert Bräutigam
quelle
Ist Ihr Gespräch irgendwo zu sehen? (Ich sehe nur Folien unter dem Link). Vielen Dank!
ttulka
Ich habe nur eine deutsche Aufzeichnung des Vortrags, hier: javadevguy.wordpress.com/2018/11/26/…
Robert Bräutigam
Tolles Gespräch! (Zum Glück spreche ich deutsch). Ich finde dein ganzes Blog lesenswert ... Danke für deine Arbeit!
ttulka
Sehr aufschlussreicher Slider Robert. Ich fand es sehr anschaulich, aber ich hatte das Gefühl, dass am Ende viele der Lösungen, die darauf abzielen, Kapselung und LoD nicht zu beschädigen, darauf beruhen, dem Domänenobjekt viele Verantwortlichkeiten zuzuweisen: Drucken, Serialisieren, Formatieren der Benutzeroberfläche usw. Steigert dies die Kopplung zwischen Domain und Technik (Implementierungsdetails)? Zum Beispiel die AccountNumber in Verbindung mit der Apache Wicket API. Oder Konto bei welchem ​​Json-Objekt auch immer? Denken Sie, dass es sich lohnt, eine Kupplung zu haben?
Laiv,
@Laiv Die Grammatik Ihrer Frage deutet darauf hin, dass die Verwendung von Technologie zur Implementierung von Geschäftsfunktionen nicht in Ordnung ist. Sagen wir es so: Nicht die Kopplung zwischen Domäne und Technologie ist das Problem, sondern die Kopplung zwischen verschiedenen Abstraktionsebenen. Zum Beispiel AccountNumber sollte wissen, dass es als dargestellt werden kann TextField. Wenn andere (wie ein „View“) das wissen würde, dass das ist.Anhängerkupplung nicht existieren sollten, weil diese Komponente wissen müsste , was AccountNumberdie Interna, also besteht.
Robert Bräutigam
1

Vielleicht können wir die Speicherung an ein anderes Objekt delegieren

Vermeiden Sie es, unnötig Wissen über Felder zu verbreiten. Je mehr Informationen über ein einzelnes Feld vorliegen, desto schwieriger wird es, ein Feld hinzuzufügen oder zu entfernen:

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

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

Hier hat das Produkt keine Ahnung, ob Sie in eine Protokolldatei oder eine Datenbank oder beides speichern. Hier hat die Speichermethode keine Ahnung, ob Sie 4 oder 40 Felder haben. Das ist locker gekoppelt. Das ist gut.

Dies ist natürlich nur ein Beispiel dafür, wie Sie dieses Ziel erreichen können. Wenn Sie keine Zeichenfolge erstellen und analysieren möchten, die als DTO verwendet werden soll, können Sie auch eine Auflistung verwenden. LinkedHashMapist ein alter Favorit von mir, da er die Ordnung beibehält und toString () in einer Protokolldatei gut aussieht.

Wie auch immer du es tust, bitte verbreite keine Kenntnisse über Felder in der Umgebung. Dies ist eine Form der Kopplung, die die Leute oft ignorieren, bis es zu spät ist. Ich möchte, dass möglichst wenige Dinge statisch wissen, über wie viele Felder mein Objekt verfügt. Auf diese Weise erfordert das Hinzufügen eines Felds nicht viele Änderungen an vielen Stellen.

kandierte_orange
quelle
Dies ist in der Tat der Code, den ich in meiner Frage gepostet habe, oder? Ich habe a verwendet Map, Sie schlagen a Stringoder a vor List. Aber, wie @VoiceOfUnreason in seiner Antwort erwähnte, ist die Kopplung immer noch da, nur nicht explizit. Es ist immer noch nicht erforderlich, die Datenstruktur des Produkts zu kennen, um es sowohl in einer Datenbank als auch in einer Protokolldatei zu speichern, zumindest wenn es als Objekt zurückgelesen wird.
ttulka
Ich habe die Speichermethode geändert, aber ansonsten ist es ähnlich. Der Unterschied besteht darin, dass die Kopplung nicht mehr statisch ist, sodass neue Felder hinzugefügt werden können, ohne eine Codeänderung im Speichersystem zu erzwingen. Das macht das Speichersystem auf vielen verschiedenen Produkten wiederverwendbar. Es zwingt Sie einfach dazu, Dinge zu tun, die ein wenig unnatürlich wirken, wie aus einem Double einen String und zurück ein Double zu machen. Aber das kann auch umgangen werden, wenn es wirklich ein Problem ist.
candied_orange
Siehe Josh Blochs heterogene Sammlung
candied_orange
Aber wie gesagt, ich sehe die Kopplung immer noch da (durch Parsen), nur weil sie nicht statisch (explizit) ist, bringt das den Nachteil, dass sie nicht von einem Compiler überprüft werden kann und so fehleranfälliger ist. Das Storageist ein Teil der Domain (ebenso wie die Repository-Schnittstelle) und macht eine solche Persistenz-API. Bei Änderungen ist es besser, die Clients in der Kompilierungszeit zu informieren, da sie ohnehin reagieren müssen, um nicht in der Laufzeit kaputt zu gehen.
ttulka
Das ist ein Irrtum. Der Compiler kann keine Protokolldatei oder DB überprüfen. Es wird lediglich geprüft, ob eine Codedatei mit einer anderen Codedatei übereinstimmt, die nicht unbedingt mit der Protokolldatei oder der Datenbank übereinstimmt.
candied_orange
0

Es gibt eine Alternative zu den bereits erwähnten Mustern. Das Memento-Muster eignet sich hervorragend zum Einkapseln des internen Zustands eines Domänenobjekts. Das Erinnerungsobjekt stellt eine Momentaufnahme des öffentlichen Zustands des Domänenobjekts dar. Das Domänenobjekt weiß, wie dieser öffentliche Zustand aus seinem internen Zustand erstellt wird und umgekehrt. Ein Endlager arbeitet dann nur mit der öffentlichen Vertretung des Staates. Damit ist die interne Implementierung von jeglichen Persistenzspezifikationen entkoppelt und muss lediglich den öffentlichen Auftrag aufrechterhalten. Auch Ihr Domain-Objekt muss keine Getter aussetzen, die es tatsächlich ein bisschen anämisch machen würden.

Für mehr zu diesem Thema empfehle ich das großartige Buch: "Patterns, Principles and Practices of Domain-Driven Design" von Scott Millett und Nick Tune

Roman Weis
quelle