Agrégats DDD persistants

Agrégats DDD persistants

1. Vue d'ensemble

Dans ce didacticiel, nous allons explorer les possibilités de persistance desDDD Aggregates en utilisant différentes technologies.

2. Introduction aux agrégats

An aggregate is a group of business objects which always need to be consistent. Par conséquent, nous enregistrons 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. Toutefois, l'idée d'agrégat est également utile en dehors du contexte DDD.

Il existe de nombreux cas d’affaires où ce modèle peut s’avérer utile. As a rule of thumb, we should consider using aggregates when there are multiple objects changed as part of the same transaction.

Voyons comment nous pouvons 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;
    // ...
}

These classes form a simple aggregate. Les champsorderLines ettotalCost desOrder doivent être toujours cohérents, c'est-à-dire quetotalCost doit toujours avoir la valeur égale à la somme de tous lesorderLines.

Now, we all might be tempted to turn all of these into fully-fledged Java Beans. Mais, notez que l'introduction de simples getters et setters dansOrder pourrait facilement casser l'encapsulation de notre modèle et violer les contraintes métier.

Voyons ce qui pourrait mal tourner.

2.2. Conception globale d'agrégats

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 classeOrder, y comprissetOrderTotal.

Rien ne 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. En définitive, le coût total ne devrait pas être de zéro dollar!

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

2.3. Racine globale

Unaggregate root est une classe qui fonctionne comme un point d'entrée dans notre agrégat. All business operations should go through the root. De cette façon, la racine d'agrégat peut prendre soin de maintenir l'agrégat dans un état cohérent.

The root is what takes cares of all our business invariants.

Et dans notre exemple, la classeOrder est le bon candidat pour la racine agrégée. Nous devons juste apporter 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 facilementProduct etOrderLine en objets immuables, où toutes les propriétés sont définitives.

Comme on peut le constater, il s’agit d’un agrégat assez simple.

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

Cependant, pour le moment, nous ne parlons que de la persistance globale, pas de la conception globale. Restez à l'écoute, car ce domaine spécifique vous sera utile dans un instant.

Dans quelle mesure cela fonctionne-t-il avec les technologies de persistance? Nous allons jeter un coup d'oeil. Ultimately, this will help us to choose the right persistence tool for our next project.

3. JPA et Hibernate

Dans cette section, essayons de conserver notre agrégatOrder à l'aide de JPA et Hibernate. Nous utiliserons Spring Boot etJPA 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.

Probably the biggest problem when working with ORM frameworks is the simplification of our model design. Il est également parfois appeléObject-relational impedance mismatch. Réfléchissons à ce qui se passerait si nous voulions conserver notre agrégatOrder:

@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: Unknown entity: com.example.ddd.order.Order. Obviously, we’re missing some of the JPA requirements:

  1. Ajouter des annotations de mappage

  2. Les classesOrderLine etProduct 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 deMoney par des types simples

Hmm, nous devons modifier la conception de l'agrégatOrder 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. Changements apportés aux objets de valeur

Le premier problème lié à l’intégration d’un agrégat dans JPA est que nous devons casser la conception de nos objets de valeur: leurs propriétés ne peuvent plus être définitives et nous devons casser l’encapsulation.

We need to add artificial ids to the OrderLine and Product, even if these classes were never designed to have identifiers. Nous voulions qu'ils soient de simples objets de valeur.

Il est possible d'utiliser les annotations@Embedded et@ElementCollection à la place, mais cette approche peut beaucoup compliquer les choses lors de l'utilisation d'un graphe d'objets complexes (par exemple, l'objet@Embeddable ayant une autre propriété@Embedded, etc. .).

L'utilisation de l'annotation@Embedded ajoute simplement des propriétés plates à la table parent. Sauf que, les propriétés de base (par exemple de typeString) nécessitent toujours une méthode setter, qui viole la conception d'objet de valeur souhaitée.

Empty constructor requirement forces the value object properties to not be final anymore, breaking an important aspect of our original design. À vrai dire, Hibernate peut utiliser le constructeur privé no-args, ce qui atténue un peu le problème, mais il est encore loin d’être parfait.

Même en utilisant un constructeur privé par défaut, nous ne pouvons pas marquer nos propriétés comme final ou nous devons les initialiser avec des valeurs par défaut (souvent nulles) à l'intérieur du constructeur par défaut.

Cependant, si nous voulons être entièrement conformes à JPA, nous devons au moins utiliser une visibilité protégée pour le constructeur par défaut, ce qui signifie que les autres classes du même package peuvent créer des objets de valeur sans spécifier les valeurs de leurs propriétés.

3.2. Types complexes

Malheureusement, nous ne pouvons pas nous attendre à ce que JPA mappe automatiquement des types complexes tiers dans des tables. Voyez simplement combien de changements nous avons dû introduire dans la section précédente!

Par exemple, lorsque nous travaillons avec notre agrégatOrder, nous rencontrerons des difficultés pour conserver les champsJoda Money.

Dans un tel cas, nous pourrions finir par écrire des types personnalisés@Converterdisponibles à partir de JPA 2.1. Cela pourrait toutefois nécessiter un travail supplémentaire.

Alternativement, nous pouvons également diviser la propriétéMoney en deux propriétés de base. Par exemple,String pour l'unité monétaire etBigDecimal pour la valeur réelle.

Bien que nous puissions cacher les détails de l'implémentation et continuer à utiliser la classeMoney via l'API des méthodes publiques, la pratique montre que la plupart des développeurs ne peuvent pas justifier le travail supplémentaire et dégénéreraient simplement le modèle pour se conformer à la spécification JPA à la place.

3.3. Conclusion

Bien que JPA soit l'une des spécifications les plus adoptées au monde, ce n'est peut-être pas la meilleure option pour conserver notre agrégatOrder.

Si nous voulons que notre modèle reflète les vraies règles métier, nous devons le concevoir pour ne pas être une simple représentation 1: 1 des tables sous-jacentes.

Fondamentalement, nous avons trois options ici:

  1. Créez un ensemble de classes de données simples et utilisez-les pour conserver et recréer le riche modèle métier. Malheureusement, cela pourrait nécessiter beaucoup de travail supplémentaire.

  2. Acceptez les limites de JPA et choisissez le bon compromis.

  3. Considérons une autre technologie.

La première option a le plus gros potentiel. En pratique, la plupart des projets sont développés en utilisant la deuxième option.

Maintenant, considérons une autre technologie pour conserver les agrégats.

4. Magasin de documents

Un magasin de documents est un moyen alternatif de stocker des données. Au lieu d'utiliser des relations et des tables, nous sauvegardons des objets entiers. This makes a document store a potentially perfect candidate for persisting aggregates.

Pour les besoins de ce didacticiel, nous nous concentrerons sur les documents de type JSON.

Examinons de plus près à quoi ressemble notre problème de persistance des commandes dans un magasin de documents comme MongoDB.

4.1. Agrégation persistante à l'aide de MongoDB

Now, there are quite a few databases which can store JSON data, one of the popular being MongoDB. MongoDB stocke en fait BSON ou JSON sous forme binaire.

Grâce à MongoDB, nous pouvons stocker l'exemple d'agrégatOrderas-is.

Avant de continuer, ajoutons le démarreur Spring BootMongoDB:


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

Nous pouvons maintenant exécuter un scénario de test similaire à celui de l'exemple JPA, mais cette fois en utilisant 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 doit créer des constructeurs par défaut, des setters ou un convertisseur personnalisé pour la classeMoney.

Et voici ce que notre agrégatOrder apparaît dans le magasin:

{
  "_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.example.ddd.order.mongo.Order"
}

Ce simple document BSON contient l'ensemble de l'agrégatOrder en un seul morceau, ce qui correspond bien à notre idée originale que tout cela devrait être cohérent conjointement.

Notez que les objets complexes du document BSON sont simplement sérialisés sous la forme d'un ensemble de propriétés JSON normales. Grâce à cela, même les classes tierces (commeJoda Money) peuvent être facilement sérialisées sans avoir besoin de simplifier le modèle.

4.2. Conclusion

La persistance d'agrégats à l'aide de MongoDB est plus simple que l'utilisation de JPA.

This absolutely doesn’t mean MongoDB is superior to traditional databases. Il existe de nombreux cas légitimes dans lesquels nous ne devrions même pas essayer de modéliser nos classes comme des agrégats et utiliser à la place une base de données SQL.

Néanmoins, lorsque nous avons identifié un groupe d’objets qui doivent toujours être cohérents en fonction des exigences complexes, l’utilisation d’un magasin de documents peut être une option très intéressante.

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 utilisée dans 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 commerciales 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 disponibleover on GitHub.