Agrégats DDD persistants

1. Vue d'ensemble

Dans ce didacticiel, nous explorerons les possibilités de persistance d'agrégats DDD à l'aide de différentes technologies.

2. Introduction aux agrégats

Un agrégat est un groupe d'objets métier qui doivent toujours être cohérents . Par conséquent, nous sauvegardons et mettons à jour les agrégats dans leur ensemble dans une transaction.

L'agrégat est un modèle tactique important dans DDD, qui aide à maintenir la cohérence de nos objets métier. Cependant, l'idée d'agrégat est également utile en dehors du contexte DDD.

Il existe de nombreuses analyses de rentabilisation où ce modèle peut être utile. En règle générale, nous devrions envisager d'utiliser des agrégats lorsque plusieurs objets sont modifiés dans le cadre de la même transaction .

Voyons comment nous pourrions appliquer cela lors de la modélisation d'une commande d'achat.

2.1. Exemple de bon de commande

Supposons donc que nous souhaitons modéliser un bon de commande:

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

Ces classes forment un simple agrégat . Les champs orderLines et totalCost de Order doivent toujours être cohérents, c'est-à-dire que totalCost doit toujours avoir la valeur égale à la somme de toutes les orderLines .

Maintenant, nous pourrions tous être tentés de transformer tout cela en Java Beans à part entière. Mais, notez que l'introduction de simples getters et setters dans Order pourrait facilement casser l'encapsulation de notre modèle et violer les contraintes commerciales.

Voyons ce qui pourrait mal tourner.

2.2. Conception d'agrégats naïfs

Imaginons ce qui pourrait arriver si nous décidions d'ajouter naïvement des getters et des setters à toutes les propriétés de la classe Order , y compris setOrderTotal .

Il n'y a rien qui nous empêche d'exécuter le code suivant:

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

Dans ce code, nous définissons manuellement la propriété totalCost sur zéro, violant une règle métier importante. Décidément, le coût total ne devrait pas être nul!

Nous avons besoin d'un moyen de protéger nos règles commerciales. Voyons comment Aggregate Roots peut vous aider.

2.3. Racine agrégée

Une racine agrégée est une classe qui fonctionne comme un point d'entrée vers notre agrégat. Toutes les opérations commerciales doivent passer par la racine. De cette façon, la racine d'agrégat peut prendre soin de maintenir l'agrégat dans un état cohérent.

La racine est ce qui prend soin de tous nos invariants commerciaux .

Et dans notre exemple, la classe Order est le bon candidat pour la racine agrégée. Nous avons juste besoin de faire quelques modifications pour nous assurer que l'agrégat est toujours cohérent:

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; } // ... }

L'utilisation d'une racine agrégée nous permet désormais de transformer plus facilement Product et OrderLine en objets immuables, où toutes les propriétés sont définitives.

Comme nous pouvons le voir, il s'agit d'un agrégat assez simple.

Et nous aurions pu simplement calculer le coût total à chaque fois sans utiliser de champ.

Cependant, à l'heure actuelle, nous ne parlons que de persistance agrégée, pas de conception agrégée. Restez à l'écoute, car ce domaine spécifique vous sera utile dans un instant.

Dans quelle mesure cela joue-t-il bien avec les technologies de persistance? Nous allons jeter un coup d'oeil. En fin de compte, cela nous aidera à choisir le bon outil de persistance pour notre prochain projet .

3. JPA et Hibernate

Dans cette section, essayons de conserver notre agrégat de commandes à l'aide de JPA et Hibernate. Nous utiliserons Spring Boot et JPA starter:

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

Pour la plupart d'entre nous, cela semble être le choix le plus naturel. Après tout, nous avons passé des années à travailler avec des systèmes relationnels, et nous connaissons tous les frameworks ORM populaires.

Le plus gros problème lors de l'utilisation de frameworks ORM est probablement la simplification de la conception de notre modèle . Il est également parfois appelé décalage d'impédance objet-relationnel. Pensons à ce qui se passerait si nous voulions conserver notre agrégat de commandes :

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

À ce stade, ce test lèverait une exception: java.lang.IllegalArgumentException: entité inconnue: com.baeldung.ddd.order.Order . De toute évidence, il nous manque certaines des exigences JPA:

  1. Ajouter des annotations de mappage
  2. Les classes OrderLine et Product doivent être des entités ou des classes @Embeddable , pas de simples objets de valeur
  3. Ajouter un constructeur vide pour chaque entité ou classe @Embeddable
  4. Remplacer les propriétés Money par des types simples

Hmm, nous devons modifier la conception de l' agrégat Order pour pouvoir utiliser JPA. Bien que l'ajout d'annotations ne soit pas un problème, les autres exigences peuvent poser de nombreux problèmes.

3.1. Modifications des objets de valeur

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.

Pourtant, lorsque nous avons identifié un groupe d'objets qui devraient toujours être cohérents en fonction des exigences complexes, l'utilisation d'un magasin de documents peut être une option très attrayante.

5. Conclusion

Dans DDD, les agrégats contiennent généralement les objets les plus complexes du système. Travailler avec eux nécessite une approche très différente de celle de la plupart des applications CRUD.

L'utilisation de solutions ORM populaires peut conduire à un modèle de domaine simpliste ou surexposé, qui est souvent incapable d'exprimer ou d'appliquer des règles métier complexes.

Les magasins de documents peuvent faciliter la conservation des agrégats sans sacrifier la complexité du modèle.

Le code source complet de tous les exemples est disponible à l'adresse over sur GitHub.