Aggregati DDD persistenti

1. Panoramica

In questo tutorial, esploreremo le possibilità di persistere gli aggregati DDD utilizzando diverse tecnologie.

2. Introduzione agli aggregati

Un aggregato è un gruppo di oggetti di business che devono sempre essere coerenti . Pertanto, salviamo e aggiorniamo gli aggregati nel loro insieme all'interno di una transazione.

L'aggregato è un modello tattico importante in DDD, che aiuta a mantenere la coerenza dei nostri oggetti di business. Tuttavia, l'idea di aggregazione è utile anche al di fuori del contesto DDD.

Esistono numerosi casi aziendali in cui questo modello può tornare utile. Come regola generale, dovremmo considerare l'utilizzo di aggregati quando ci sono più oggetti modificati come parte della stessa transazione .

Diamo un'occhiata a come potremmo applicarlo durante la modellazione di un ordine di acquisto.

2.1. Esempio di ordine di acquisto

Quindi, supponiamo di voler modellare un ordine di acquisto:

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

Queste classi formano un semplice aggregato . Entrambi OrderLines e totalcost campi della dell'Ordine devono essere sempre coerente, che è totalcost dovrebbe sempre avere il valore pari alla somma di tutti i OrderLines .

Ora, tutti potremmo essere tentati di trasformare tutti questi in Java Beans a tutti gli effetti. Tuttavia, tieni presente che l'introduzione di semplici getter e setter in Order potrebbe facilmente rompere l'incapsulamento del nostro modello e violare i vincoli aziendali.

Vediamo cosa potrebbe andare storto.

2.2. Design aggregato ingenuo

Immaginiamo cosa potrebbe accadere se decidessimo di aggiungere ingenuamente getter e setter a tutte le proprietà della classe Order , incluso setOrderTotal .

Non c'è niente che ci vieti di eseguire il seguente codice:

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

In questo codice, impostiamo manualmente la proprietà totalCost su zero, violando un'importante regola aziendale. Sicuramente, il costo totale non dovrebbe essere zero dollari!

Abbiamo bisogno di un modo per proteggere le nostre regole aziendali. Diamo un'occhiata a come Aggregate Roots può aiutare.

2.3. Radice aggregata

Una radice aggregata è una classe che funziona come punto di ingresso al nostro aggregato. Tutte le operazioni aziendali dovrebbero passare attraverso la radice. In questo modo, la radice aggregata può occuparsi di mantenere l'aggregato in uno stato coerente.

La radice è ciò che si prende cura di tutte le nostre invarianti aziendali .

E nel nostro esempio, la classe Order è il candidato giusto per la radice aggregata. Dobbiamo solo apportare alcune modifiche per garantire che l'aggregato sia sempre coerente:

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'utilizzo di una radice aggregata ora ci consente di trasformare più facilmente Product e OrderLine in oggetti immutabili, dove tutte le proprietà sono definitive.

Come possiamo vedere, questo è un aggregato piuttosto semplice.

Inoltre, avremmo potuto semplicemente calcolare ogni volta il costo totale senza utilizzare un campo.

Tuttavia, in questo momento stiamo parlando solo di persistenza aggregata, non di progettazione aggregata. Restate sintonizzati, poiché questo specifico dominio tornerà utile in un attimo.

Quanto bene funziona con le tecnologie di persistenza? Diamo un'occhiata. In definitiva, questo ci aiuterà a scegliere il giusto strumento di persistenza per il nostro prossimo progetto .

3. JPA e Hibernate

In questa sezione, proviamo a rendere persistente la nostra aggregazione di ordini utilizzando JPA e Hibernate. Useremo Spring Boot e JPA starter:

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

Per la maggior parte di noi, questa sembra essere la scelta più naturale. Dopotutto, abbiamo trascorso anni a lavorare con sistemi relazionali e conosciamo tutti framework ORM popolari.

Probabilmente il problema più grande quando si lavora con i framework ORM è la semplificazione del design del nostro modello . A volte viene anche definito disadattamento di impedenza relazionale oggetto. Pensiamo a cosa accadrebbe se volessimo persistere il nostro aggregato di ordini :

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

A questo punto, questo test genererebbe un'eccezione: java.lang.IllegalArgumentException: Unknown entity: com.baeldung.ddd.order.Order . Ovviamente, ci mancano alcuni dei requisiti JPA:

  1. Aggiungi annotazioni di mappatura
  2. Le classi OrderLine e Product devono essere entità o classi @Embeddable , non semplici oggetti valore
  3. Aggiungi un costruttore vuoto per ogni entità o classe @Embeddable
  4. Sostituisci le proprietà Money con tipi semplici

Hmm, dobbiamo modificare il design di Order aggregate per poter utilizzare JPA. Sebbene l'aggiunta di annotazioni non sia un grosso problema, gli altri requisiti possono introdurre molti problemi.

3.1. Modifiche agli oggetti valore

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.

Tuttavia, quando abbiamo identificato un gruppo di oggetti che dovrebbero essere sempre coerenti in base ai requisiti complessi, l'utilizzo di un archivio di documenti può essere un'opzione molto interessante.

5. conclusione

In DDD, gli aggregati di solito contengono gli oggetti più complessi nel sistema. Lavorare con loro richiede un approccio molto diverso rispetto alla maggior parte delle applicazioni CRUD.

L'utilizzo di soluzioni ORM popolari potrebbe portare a un modello di dominio semplicistico o sovraesposto, che spesso non è in grado di esprimere o applicare regole aziendali complesse.

Gli archivi di documenti possono semplificare la persistenza degli aggregati senza sacrificare la complessità del modello.

Il codice sorgente completo di tutti gli esempi è disponibile su GitHub.