Persistierende DDD-Aggregate

1. Übersicht

In diesem Tutorial werden wir die Möglichkeiten untersuchen, DDD-Aggregate mithilfe verschiedener Technologien zu persistieren.

2. Einführung in Aggregate

Ein Aggregat ist eine Gruppe von Geschäftsobjekten, die immer konsistent sein müssen . Daher speichern und aktualisieren wir Aggregate als Ganzes innerhalb einer Transaktion.

Aggregat ist ein wichtiges taktisches Muster in DDD, das dazu beiträgt, die Konsistenz unserer Geschäftsobjekte aufrechtzuerhalten. Die Idee des Aggregats ist jedoch auch außerhalb des DDD-Kontexts nützlich.

Es gibt zahlreiche Geschäftsfälle, in denen dieses Muster nützlich sein kann. Als Faustregel sollten wir die Verwendung von Aggregaten in Betracht ziehen, wenn mehrere Objekte im Rahmen derselben Transaktion geändert werden .

Lassen Sie uns einen Blick darauf werfen, wie wir dies bei der Modellierung eines Bestellkaufs anwenden können.

2.1. Bestellbeispiel

Nehmen wir also an, wir möchten eine Bestellung modellieren:

class Order { private Collection orderLines; private Money totalCost; // ... }
class OrderLine { private Product product; private int quantity; // ... }
class Product { private Money price; // ... }

Diese Klassen bilden ein einfaches Aggregat . Sowohl die Felder orderLines als auch totalCost der Order müssen immer konsistent sein, dh totalCost sollte immer den Wert haben, der der Summe aller orderLines entspricht .

Jetzt könnten wir alle versucht sein, all dies in vollwertige Java-Bohnen zu verwandeln. Beachten Sie jedoch, dass die Einführung einfacher Getter und Setter in der Reihenfolge die Kapselung unseres Modells leicht aufheben und geschäftliche Einschränkungen verletzen kann.

Mal sehen, was schief gehen könnte.

2.2. Naives Aggregatdesign

Stellen wir uns vor, was passieren könnte, wenn wir uns entschließen, allen Eigenschaften der Order- Klasse, einschließlich setOrderTotal , naiv Getter und Setter hinzuzufügen .

Nichts hindert uns daran, den folgenden Code auszuführen:

Order order = new Order(); order.setOrderLines(Arrays.asList(orderLine0, orderLine1)); order.setTotalCost(Money.zero(CurrencyUnit.USD)); // this doesn't look good...

In diesem Code setzen wir die Eigenschaft totalCost manuell auf Null, was gegen eine wichtige Geschäftsregel verstößt. Auf jeden Fall sollten die Gesamtkosten nicht null Dollar betragen!

Wir brauchen einen Weg, um unsere Geschäftsregeln zu schützen. Schauen wir uns an, wie Aggregate Roots helfen kann.

2.3. Gesamtwurzel

Eine Aggregatwurzel ist eine Klasse, die als Einstiegspunkt für unser Aggregat fungiert. Alle Geschäftsvorgänge sollten über die Wurzel gehen. Auf diese Weise kann die Aggregatwurzel dafür sorgen, dass das Aggregat in einem konsistenten Zustand bleibt.

Die Wurzel kümmert sich um alle unsere Geschäftsinvarianten .

In unserem Beispiel ist die Order- Klasse der richtige Kandidat für die aggregierte Wurzel. Wir müssen nur einige Änderungen vornehmen, um sicherzustellen, dass das Aggregat immer konsistent ist:

class Order { private final List orderLines; private Money totalCost; Order(List orderLines) { checkNotNull(orderLines); if (orderLines.isEmpty()) { throw new IllegalArgumentException("Order must have at least one order line item"); } this.orderLines = new ArrayList(orderLines); totalCost = calculateTotalCost(); } void addLineItem(OrderLine orderLine) { checkNotNull(orderLine); orderLines.add(orderLine); totalCost = totalCost.plus(orderLine.cost()); } void removeLineItem(int line) { OrderLine removedLine = orderLines.remove(line); totalCost = totalCost.minus(removedLine.cost()); } Money totalCost() { return totalCost; } // ... }

Durch die Verwendung eines aggregierten Stamms können wir Product und OrderLine jetzt einfacher in unveränderliche Objekte verwandeln , bei denen alle Eigenschaften endgültig sind.

Wie wir sehen können, ist dies ein ziemlich einfaches Aggregat.

Und wir hätten einfach jedes Mal die Gesamtkosten berechnen können, ohne ein Feld zu verwenden.

Im Moment sprechen wir jedoch nur von aggregierter Persistenz, nicht von aggregiertem Design. Bleiben Sie auf dem Laufenden, da diese spezielle Domain in Kürze nützlich sein wird.

Wie gut spielt dies mit Persistenztechnologien? Lass uns einen Blick darauf werfen. Dies wird uns letztendlich helfen, das richtige Persistenz-Tool für unser nächstes Projekt auszuwählen .

3. JPA und Ruhezustand

In diesem Abschnitt versuchen wir, unser Auftragsaggregat mithilfe von JPA und Hibernate beizubehalten. Wir werden Spring Boot und JPA Starter verwenden:

 org.springframework.boot spring-boot-starter-data-jpa 

Für die meisten von uns scheint dies die natürlichste Wahl zu sein. Schließlich haben wir jahrelang mit relationalen Systemen gearbeitet und kennen alle gängige ORM-Frameworks.

Das wahrscheinlich größte Problem bei der Arbeit mit ORM-Frameworks ist die Vereinfachung unseres Modelldesigns . Es wird manchmal auch als objektrelationale Impedanzfehlanpassung bezeichnet. Lassen Sie uns darüber nachdenken, was passieren würde, wenn wir unser Auftragsaggregat beibehalten möchten :

@DisplayName("given order with two line items, when persist, then order is saved") @Test public void test() throws Exception { // given JpaOrder order = prepareTestOrderWithTwoLineItems(); // when JpaOrder savedOrder = repository.save(order); // then JpaOrder foundOrder = repository.findById(savedOrder.getId()) .get(); assertThat(foundOrder.getOrderLines()).hasSize(2); }

Zu diesem Zeitpunkt würde dieser Test eine Ausnahme auslösen : java.lang.IllegalArgumentException: Unbekannte Entität: com.baeldung.ddd.order.Order . Offensichtlich fehlen einige der JPA-Anforderungen:

  1. Fügen Sie Mapping-Anmerkungen hinzu
  2. OrderLine- und Product- Klassen müssen Entitäten oder @ Embeddable- Klassen sein, keine einfachen Wertobjekte
  3. Fügen Sie für jede Entität oder @ Embeddable- Klasse einen leeren Konstruktor hinzu
  4. Ersetzen Sie Money- Eigenschaften durch einfache Typen

Hmm, wir müssen das Design des Auftragsaggregats ändern , um JPA verwenden zu können. Während das Hinzufügen von Anmerkungen keine große Sache ist, können die anderen Anforderungen viele Probleme verursachen.

3.1. Änderungen an den Wertobjekten

The first issue of trying to fit an aggregate into JPA is that we need to break the design of our value objects: Their properties can no longer be final, and we need to break encapsulation.

We need to add artificial ids to the OrderLine and Product, even if these classes were never designed to have identifiers. We wanted them to be simple value objects.

It's possible to use @Embedded and @ElementCollection annotations instead, but this approach can complicate things a lot when using a complex object graph (for example @Embeddable object having another @Embedded property etc.).

Using @Embedded annotation simply adds flat properties to the parent table. Except that, basic properties (e.g. of String type) still require a setter method, which violates the desired value object design.

Empty constructor requirement forces the value object properties to not be final anymore, breaking an important aspect of our original design. Truth be told, Hibernate can use the private no-args constructor, which mitigates the problem a bit, but it's still far from being perfect.

Even when using a private default constructor, we either cannot mark our properties as final or we need to initialize them with default (often null) values inside the default constructor.

However, if we want to be fully JPA-compliant, we must use at least protected visibility for the default constructor, which means other classes in the same package can create value objects without specifying values of their properties.

3.2. Complex Types

Unfortunately, we cannot expect JPA to automatically map third-party complex types into tables. Just see how many changes we had to introduce in the previous section!

For example, when working with our Order aggregate, we'll encounter difficulties persisting Joda Money fields.

In such a case, we might end up with writing custom type @Converter available from JPA 2.1. That might require some additional work, though.

Alternatively, we can also split the Money property into two basic properties. For example String for currency unit and BigDecimal for the actual value.

While we can hide the implementation details and still use Money class through the public methods API, the practice shows most developers cannot justify the extra work and would simply degenerate the model to conform to the JPA specification instead.

3.3. Conclusion

While JPA is one of the most adopted specifications in the world, it might not be the best option for persisting our Order aggregate.

If we want our model to reflect the true business rules, we should design it to not be a simple 1:1 representation of the underlying tables.

Basically, we have three options here:

  1. Create a set of simple data classes and use them to persist and recreate the rich business model. Unfortunately, this might require a lot of extra work.
  2. Accept the limitations of JPA and choose the right compromise.
  3. Consider another technology.

The first option has the biggest potential. In practice, most projects are developed using the second option.

Now, let's consider another technology to persist aggregates.

4. Document Store

A document store is an alternative way of storing data. Instead of using relations and tables, we save whole objects. This makes a document store a potentially perfect candidate for persisting aggregates.

For the needs of this tutorial, we'll focus on JSON-like documents.

Let's take a closer look at how our order persistence problem looks in a document store like MongoDB.

4.1. Persisting Aggregate Using MongoDB

Now, there are quite a few databases which can store JSON data, one of the popular being MongoDB. MongoDB actually stores BSON, or JSON in binary form.

Thanks to MongoDB, we can store the Order example aggregate as-is.

Before we move on, let's add the Spring Boot MongoDB starter:

 org.springframework.boot spring-boot-starter-data-mongodb 

Now we can run a similar test case like in the JPA example, but this time using MongoDB:

@DisplayName("given order with two line items, when persist using mongo repository, then order is saved") @Test void test() throws Exception { // given Order order = prepareTestOrderWithTwoLineItems(); // when repo.save(order); // then List foundOrders = repo.findAll(); assertThat(foundOrders).hasSize(1); List foundOrderLines = foundOrders.iterator() .next() .getOrderLines(); assertThat(foundOrderLines).hasSize(2); assertThat(foundOrderLines).containsOnlyElementsOf(order.getOrderLines()); }

What's important – we didn't change the original Order aggregate classes at all; no need to create default constructors, setters or custom converter for Money class.

And here is what our Order aggregate appears in the store:

{ "_id": ObjectId("5bd8535c81c04529f54acd14"), "orderLines": [ { "product": { "price": { "money": { "currency": { "code": "USD", "numericCode": 840, "decimalPlaces": 2 }, "amount": "10.00" } } }, "quantity": 2 }, { "product": { "price": { "money": { "currency": { "code": "USD", "numericCode": 840, "decimalPlaces": 2 }, "amount": "5.00" } } }, "quantity": 10 } ], "totalCost": { "money": { "currency": { "code": "USD", "numericCode": 840, "decimalPlaces": 2 }, "amount": "70.00" } }, "_class": "com.baeldung.ddd.order.mongo.Order" }

This simple BSON document contains the whole Order aggregate in one piece, matching nicely with our original notion that all this should be jointly consistent.

Note that complex objects in the BSON document are simply serialized as a set of regular JSON properties. Thanks to this, even third-party classes (like Joda Money) can be easily serialized without a need to simplify the model.

4.2. Conclusion

Persisting aggregates using MongoDB is simpler than using JPA.

This absolutely doesn't mean MongoDB is superior to traditional databases. There are plenty of legitimate cases in which we should not even try to model our classes as aggregates and use a SQL database instead.

Wenn wir jedoch eine Gruppe von Objekten identifiziert haben, die gemäß den komplexen Anforderungen immer konsistent sein sollten, kann die Verwendung eines Dokumentenspeichers eine sehr ansprechende Option sein.

5. Schlussfolgerung

In DDD enthalten Aggregate normalerweise die komplexesten Objekte im System. Die Arbeit mit ihnen erfordert einen ganz anderen Ansatz als in den meisten CRUD-Anwendungen.

Die Verwendung gängiger ORM-Lösungen kann zu einem vereinfachten oder überbelichteten Domänenmodell führen, das häufig keine komplexen Geschäftsregeln ausdrücken oder durchsetzen kann.

Dokumentenspeicher können es einfacher machen, Aggregate beizubehalten, ohne die Komplexität des Modells zu beeinträchtigen.

Der vollständige Quellcode aller Beispiele ist auf GitHub verfügbar.