logo le blog invivoo blanc

Domain Driven Design Part 2 – Les principes

2 mai 2023 | Python | 0 comments

Dans le précédent article [6], nous avons commencé à explorer l’Odyssée du Domain Driven Design (DDD), tout en nous équipant des outils nécessaires. Maintenant, nous allons découvrir les principes dans cet article, alors que les mises en œuvre seront abordées dans l’article suivant.

Au cœur du Domain Driven Design (DDD) se trouve la reconnaissance que le développement de logiciels n’est pas une activité isolée, mais plutôt un moyen de répondre aux besoins d’un métier particulier. La conception axée sur le domaine fournit un ensemble de principes et de pratiques qui peuvent aider les développeurs à créer des logiciels qui s’alignent sur les buts et les objectifs du métier et offrent finalement de la valeur ajoutée aux utilisateurs finaux.

1. Création d’un langage ubiquitaire

Le Domain Driven Design (DDD) met en lumière l’utilisation d’un langage partagé entre les développeurs et les experts du domaine. Cela permet de s’assurer que tout le monde soit sur la même longueur d’onde et de partager une compréhension commune du domaine.

Pour créer un langage ubiquitaire, il est important d’établir une terminologie claire et cohérente partagée entre les développeurs et les experts du domaine.

Supposons que nous ayons un domaine où nous vendons des produits et que nous souhaitions créer un site Web de commerce électronique. Nous devons nous assurer que toutes les personnes impliquées dans le projet, y compris les développeurs et les experts du domaine, utilisent un langage commun pour décrire les entités et les concepts impliqués dans le domaine.

Nous pouvons prendre un exemple simple de panier d’achat dans le domaine du commerce électronique. Si le développeur n’a pas assez d’échanges avec l’expert du domaine, il le coderait comme ci-dessous.

class ShoppingBasket:
    def __init__(self):
        self.basket = []
    
    def add_element(self, element):
        self.basket.append(element)
    
    def remove_element(self, element):
        self.cart.remove(element)
    
    def get_basket_total(self):
        total = 0
        for element in self.basket:
            total += element.price
        return total

       

Dans le code ci-dessus, il n’y a pas de langage partagé ni de compréhension du domaine. Les méthodes sont nommées de manière générique et ne représentent pas le domaine du commerce électronique.

class ShoppingCart:
    def __init__(self):
        self.items = []
    
    def add_item(self, item):
        self.items.append(item)
    
    def remove_item(self, item):
        self.items.remove(item)
    
    def get_total_price(self):
        total_price = 0
        for item in self.items:
            total_price += item.price
        return total_price

Nous pouvons également utiliser le langage partagé pour définir des méthodes dans nos classes. Par exemple, ajoutons une méthode à la classe Product qui calcule le prix réduit en fonction d’un pourcentage :

class Product: 
	   … 
def discount_price(self, discount=0): 
    return self.price * (1-discount) 
 
 
class ShoppingCart : 
     def __init__(self): 
         self.items = [] 
     
     def add_item(self, item): 
         self.items.append(item) 
     
     def remove_item(self, item): 
         self.items.remove(item) 
     
     def calculate_total_price(self): 
        total_price = 0 
        for item in self.items: 
            total_price += item.price 
        return total_price  

Dans le code ci-dessus, les noms de classe et de méthode sont plus représentatifs du domaine du commerce électronique. Le langage du domaine est utilisé pour nommer la classe et les méthodes, et la méthode de calcul du prix total est nommée de manière plus appropriée. Cela permet aux experts du domaine et aux développeurs de communiquer et de se comprendre plus facilement.

En utilisant un langage ubiquitaire pour définir nos classes, méthodes et variables de modèle dans le domaine, nous nous assurons que toutes les personnes impliquées dans le projet ont une compréhension commune du domaine et peuvent communiquer efficacement.

2. Concentration sur le domaine métier

Le Domain Driven Design (DDD) insiste sur l’importance de comprendre le domaine métier et de créer un modèle de domaine qui le représente avec précision.

Pour mettre en œuvre ce principe, il est important de travailler en étroite collaboration avec des experts du domaine pour identifier et comprendre le domaine métier. Une fois le domaine compris, un modèle de domaine peut être créé pour mieux s’aligner aux exigences et aux objectifs du métier.

Par exemple, ShoppingCart est un élément important du domaine métier dans une application de commerce électronique. Il représente un conteneur contenant des articles qu’un client a l’intention d’acheter.

Il est important de comprendre le domaine métier et de créer un modèle de domaine qui le représente avec précision pour concevoir notre module.

class ShoppingCart:
    def __init__(self):
        self.items = []
    
    def add_item(self, item):
        self.items.append(item)
    
    def calculate_total_price(self):
        total_price = 0
        for item in self.items:
            total_price += item.price
        return total_price
    
    def calculate_shipping_cost(self):
        total_weight = 0
        for item in self.items:
            total_weight += item.weight
        
        if total_weight <= 10:
            return 5
        elif total_weight <= 20:
            return 10
        else:
            return 15


class Item:
    def __init__(self, name, price, weight):
        self.name = name
        self.price = price
            self.weight = weight

Dans le cas du ShoppingCart, nous pouvons voir qu’il y a certains attributs, tels que les articles qui sont actuellement dans le panier et le total_price de ces articles. Dans un premier temps, il présente aussi les principales fonctionnalités telles que l’ajout d’articles au panier, la suppression d’articles du panier et le calcul du prix total des articles dans le panier.

En modélisant le ShoppingCart de cette manière, nous pouvons créer un système logiciel qui s’aligne plus étroitement sur les besoins du métier.

3. Modélisation du domaine

Le Domain Driven Design (DDD) encourage la création d’un modèle de domaine qui capture les processus métier, les règles et les concepts de manière structurée.

Nous pouvons revoir notre modélisation de ShoppingCart développée dans l’exemple précédent. La classe ShoppingCart possède à la fois les méthodes calculate_total_price et calculate_shipping_cost, ce qui signifie qu’elle est étroitement liée à la fois à la logique de tarification et d’expédition. Si les exigences commerciales en matière de tarification ou d’expédition devaient changer, la classe ShoppingCart devrait être modifiée, ce qui pourrait entraîner des effets secondaires inattendus et un risque accru d’introduction de bogues. Nous pouvons structurer nos modèles de domaine d’une nouvelle manière.

class ShoppingCart:
    def __init__(self):
        self.items = []
    
    def add_item(self, item):
        self.items.append(item)
    
    def calculate_total_price(self):
        total_price = 0
        for item in self.items:
            total_price += item.price
        return total_price
    

class Item:
    def __init__(self, name, price):
        self.name = name
        self.price = price


class ShippingPolicy:
    def calculate_shipping_cost(self, items):
        total_weight = 0
        for item in items:
            total_weight += item.weight
        
        if total_weight <= 10:
            return 5
        elif total_weight <= 20:
            return 10
        else:
            return 15

Dans ce cas, la classe ShoppingCart n’a que la méthode calculate_total_price, ce qui signifie qu’elle est découplée de la logique d’expédition. La politique d’expédition est maintenant représentée par la classe ShippingPolicy, qui a la responsabilité unique de calculer les frais d’expédition en fonction d’une liste d’articles. Si les exigences commerciales en matière d’expédition devaient changer, seule la classe ShippingPolicy devrait être modifiée, ce qui réduit le risque d’introduction de bogues et rend le code plus maintenable.

4. Appui sur les racines d’agrégat

Le Domain Driven Design (DDD) recommande de s’appuyer sur les racines d’agrégat [1], en tant qu’éléments principaux dans le modèle de domaine, car elles représentent les bornes ou frontières de cohérence transactionnelle du système.

Dans le commerce électronique, nous avons les classes Product, ShoppingCart et Order pour passer une commande comme indiqué dans le code suivant.

class Product:
    def __init__(self, name, description, price):
        self.name = name
        self.description = description
        self.price = price

class ShoppingCart:
    def __init__(self):
        self.items = []

    def add_item(self, product):
        self.items.append(product)

    def remove_item(self, product):
        self.items.remove(product)

class Order:
    def __init__(self, customer_name, items):
        self.customer_name = customer_name
        self.items = items
        self.total_price = sum([item.price for item in items])

class OrderService:
    def place_order(self, shopping_cart, customer_name):
        order = Order(customer_name, shopping_cart.items)
        return order

Dans cet exemple, la classe Order contient toutes les informations sur une commande, y compris le nom du client, les articles commandés et le prix total. Cependant, aucun objet unique ne représente la frontière de cohérence transactionnelle du système.

Maintenant, voici un exemple de la même application de commerce électronique utilisant des racines d’agrégat.

class Product:
    def __init__(self, name, description, price):
        self.name = name
        self.description = description
        self.price = price

class ShoppingCart:
    def __init__(self):
        self.items = []

    def add_item(self, product):
        self.items.append(product)

    def remove_item(self, product):
        self.items.remove(product)

class Order:
    def __init__(self, customer_name):
        self.customer_name = customer_name
        self.items = []

    def add_item(self, product):
        self.items.append(product)

    def remove_item(self, product):
        self.items.remove(product)

    def get_total_price(self):
        return sum([item.price for item in self.items])

class OrderService:
    def place_order(self, shopping_cart, customer_name):
        order = Order(customer_name)
        for item in shopping_cart.items:
            order.add_item(item)
        return order

Dans cet exemple, la classe Order est la racine d’agrégat, représentant la frontière de cohérence transactionnelle du système. Les classes ShoppingCart et Product sont toujours présentes, mais elles n’ont aucune connaissance directe de la classe Order. Du fait, OrderService gère le placement de commandes et l’ajout d’articles aux commandes. En utilisant des racines d’agrégat, le code est organisé d’une manière qui reflète mieux le modèle de domaine et permet un code plus maintenable et extensible.

5. Utilisation des contextes bornés

Le Domain Driven Design (DDD) recommande de diviser un grand système en contextes bornés plus petits et plus gérables, chacun avec son propre modèle de domaine.

Pour utiliser des contextes bornés, nous divisons un grand système en contextes bornés plus petits et plus gérables, chacun avec son propre modèle de domaine.

Dans le domaine du commerce électronique, nous pouvons avoir des domaines Product, ShoppingCart et Order. Sans la notion de contexte borné, ils sont placés dans le même fichier ou module, conduisant à une architecture monolithique comme illustré ci-dessous, avec confusion et conflits dans la compréhension du domaine. Toute modification ou mise à jour d’un domaine peut affecter d’autres domaines, ce qui rend difficile la maintenance et la mise à l’échelle du système. Le code serait moins modulaire, ce qui rendrait plus difficile le test et le débogage de fonctionnalités spécifiques.

class Product:
    def __init__(self, name, description, price):
        self.name = name
        self.description = description
        self.price = price
    
    def get_name(self):
        return self.name
    
    def get_description(self):
        return self.description
    
    def get_price(self):
        return self.price


class ShoppingCart:
    def __init__(self):
        self.items = []
    
    def add_item(self, item):
        self.items.append(item)
    
    def remove_item(self, item):
        self.items.remove(item)
    
    def get_items(self):
        return self.items
    
    def calculate_total_price(self):
        total_price = 0
        for item in self.items:
            total_price += item.get_price()
        return total_price


class Order:
    def __init__(self, customer, items, total_price):
        self.customer = customer
        self.items = items
        self.total_price = total_price
    
    def get_customer(self):
        return self.customer
    
    def get_items(self):
        return self.items
    
    def get_total_price(self):
        return self.total_price 

Maintenant, les contextes bornés peuvent le rendre différent.

# In the Product Management bounded context
class Product:
    def __init__(self, name, description, price):
        self.name = name
        self.description = description
        self.price = price
    
    def get_name(self):
        return self.name
    
    def get_description(self):
        return self.description
    
    def get_price(self):
        return self.price


class ProductRepository:
    def __init__(self):
        self.products = []
    
    def add_product(self, product):
        self.products.append(product)
    
    def remove_product(self, product):
        self.products.remove(product)
    
    def get_all_products(self):
        return self.products
    

# In the Shopping bounded context
class ShoppingCart:
    def __init__(self):
        self.items = []
    
    def add_item(self, product):
        self.items.append(product)
    
    def remove_item(self, product):
        self.items.remove(product)
    
    def get_items(self):
        return self.items
    

# In the Order Management bounded context
class Order:
    def __init__(self, customer, items, total_price):
        self.customer = customer
        self.items = items
        self.total_price = total_price
    
    def get_customer(self):
        return self.customer
    
    def get_items(self):
        return self.items
    
    def get_total_price(self):
        return self.total_price
    

class OrderService:
    def __init__(self, product_repository):
        self.product_repository = product_repository
    
    def place_order(self, customer, cart):
        items = []
        total_price = 0
        
        for product in cart.get_items():
            if product in self.product_repository.get_all_products():
                items.append(product)
                total_price += product.get_price()
            else:
                raise ValueError("Product not found in repository")
        
        order = Order(customer, items, total_price)
        return order

Dans cette amélioration, nous avons trois contextes bornés possédant son propre modèle de domaine et son propre ensemble de responsabilités :

  • Product Management, reponsable de la gestion des produits
  • Shopping, reponsable de la gestion des paniers
  • Order Management, responsable de la gestion des commandes

En divisant le système en ces contextes plus petits et plus gérables, nous pouvons améliorer l’organisation globale et la maintenabilité de la base de code.

6. Bénéfices de la carte de contexte

La conception pilotée par le domaine (DDD) recommande d’utiliser la carte de contexte pour gérer les relations entre les contextes bornés et s’assurer qu’ils marchent ensemble de manière efficace.

Comme nous l’avons déjà vu Separate Ways [1] dans la carte de contexte, dans le domaine du commerce électronique, nous pouvons avoir les contextes bornés ProductManagement et OrderManagement comme ci-dessous.

# In the ProductManagement bounded context
class Product:
    def __init__(self, name, price, description):
        self.name = name
        self.price = price
        self.description = description

    def get_price(self):
        return self.price


# In the OrderManagement bounded context
class Order:
    def __init__(self, customer, items):
        self.customer = customer
        self.items = items

    def get_total_price(self):
        total_price = 0
        for item in self.items:
            total_price += item.product.get_price() * item.quantity
        return total_price


class OrderItem:
    def __init__(self, product, quantity):
        self.product = product
        self.quantity = quantity

Dans le code ci-dessus, les classes Product et Order sont étroitement couplées, ce qui rend difficile la modification ou l’ajout de fonctionnalités à l’une ou l’autre des classes, sans affecter l’autre. En d’autres termes, il n’y a pas de séparation claire des préoccupations. Cela peut compliquer la gestion des relations entre ces modules. Il faut s’assurer qu’ils fonctionnent ensemble de manière efficace.

# In the ProductCatalog bounded context
class Product:
    def __init__(self, name, price, description):
        self.name = name
        self.price = price
        self.description = description

    def get_price(self):
        return self.price


# In the OrderManagement bounded context
class Order:
    def __init__(self, customer, items):
        self.customer = customer
        self.items = items

    def get_total_price(self, product_catalog):
        total_price = 0
        for item in self.items:
            product = product_catalog.get_product_by_name(item.product_name)
            total_price += product.get_price() * item.quantity
        return total_price


class OrderItem:
    def __init__(self, product_name, quantity):
        self.product_name = product_name
        self.quantity = quantity


# In the ProductCatalog bounded context
class ProductCatalog:
    def __init__(self):
        self.products = []

    def add_product(self, product):
        self.products.append(product)

    def get_product_by_name(self, name):
        for product in self.products:
            if product.name == name:
                return product

Dans le code ci-dessus, le contexte ProductCatalog est responsable de la gestion des produits, et le contexte OrderManagement s’appuie sur lui pour calculer le prix total d’une commande. Cela rend le système plus modulaire et plus facile à entretenir, car les modifications apportées à un contexte sont moins susceptibles d’avoir un effet de régression sur d’autres contextes.

En séparant les préoccupations et en utilisant la carte de contexte, il devient plus facile de gérer la complexité et de s’assurer que le système est maintenable dans le temps.

7. Les événements de domaine

La conception pilotée par le domaine (DDD) invite l’utilisation d’événements de domaine à capturer les changements significatifs dans le domaine et pour permettre la communication entre des contextes bornés.

Nous pouvons maintenant poursuivre notre développement du projet de commerce électronique avec des contextes bornés d’inventaire et de paiement. Dans le premier exemple sans événements de domaine, la classe Order sera étroitement couplée aux classes Inventory et Payment, car elle a une connaissance directe de leurs méthodes. De plus, le processus de passation de commande est une fonction longue et étroitement couplée qui gère de multiples responsabilités.

from typing import List


# In the ProductCatalog bounded context
class Product:
    def __init__(self, name: str, price: float, description: str):
        self.name = name
        self.price = price
        self.description = description


class ProductCatalog:
    def __init__(self):
        self.products = []

    def add_product(self, product: Product):
        self.products.append(product)

    def remove_product(self, product: Product):
        self.products.remove(product)

    def find_product(self, product_name: str) -> Product:
        for product in self.products:
            if product.name == product_name:
                return product
        return None
      
 
# In the OrderManagement bounded context
class OrderItem:
    def __init__(self, product_name: str, quantity: int):
        self.product_name = product_name
        self.quantity = quantity

    def __repr__(self):
      return f"{self.product_name}: {self.quantity}"


class Order:
    def __init__(self, customer: str, items: List[OrderItem]):
        self.customer = customer
        self.items = items


class OrderPlacedEvent:
    def __init__(self, customer: str, items: List[OrderItem]):
        self.customer = customer
        self.items = items

    def publish(self):
        print(f"OrderPlacedEvent published: Customer {self.customer} with items {self.items}")


# In the InventoryManagement bounded context
class Inventory:
    def __init__(self):
        self.products = {}

    def add_product(self, product: Product, quantity: int):
        if product in self.products:
            self.products[product] += quantity
        else:
            self.products[product] = quantity

    def remove_product(self, product: Product, quantity: int):
        if product in self.products and self.products[product] >= quantity:
            self.products[product] -= quantity
            return True
        else:
            return False
    
    def find_product(self, product_name: str):
        for product in self.products:
            if product.name == product_name:
                return product
        return 
    
    def handle_order_placed_event(self, event: OrderPlacedEvent):
        for item in event.items:
            product = self.find_product(item.product_name)
            if product is not None:
                self.remove_product(product, item.quantity)
            else:
                print(f"Product {item.product_name} not found in inventory")


# In the PaymentManagement bounded context
class Payment:
    def __init__(self, order_total: float):
        self.order_total = order_total

    def process(self):
        print(f"Payment processed for {self.order_total} euros")

    def handle_order_placed_event(self, event: OrderPlacedEvent, product_catalog: ProductCatalog):
        total_price = sum(
            item.quantity * product_catalog.find_product(item.product_name).price
            for item in event.items
        )
        self.order_total += total_price


# In the OrderManagement bounded context
class OrderService:
    def __init__(self, product_catalog: ProductCatalog, inventory: Inventory, payment: Payment):
        self.product_catalog = product_catalog
        self.inventory = inventory
        self.payment = payment

    def place_order(self, customer: str, order_items: List[OrderItem]):
        items = []
        for order_item in order_items:
            product = self.product_catalog.find_product(order_item.product_name)
            if product is not None:
                if self.inventory.remove_product(product, order_item.quantity):
                    items.append(OrderItem(product.name, order_item.quantity))
                else:
                    print(f"Not enough inventory for product {product.name}")
                    return
            else:
                print(f"Product {order_item.product_name} not found in catalog")
                return
        order_placed_event = OrderPlacedEvent(customer, items)
        order_placed_event.publish()
        self.inventory.handle_order_placed_event(order_placed_event)
        self.payment.handle_order_placed_event(order_placed_event, self.product_catalog)
        self.payment.process()
        print("Order placed successfully")

 

# In the Application bounded context     
if __name__ == "__main__":
    product_catalog = ProductCatalog()
    product_catalog.add_product(Product("Book on DDD", 20.0, "Description: Domain Driven Desgin"))
    product_catalog.add_product(Product("Book on French Culture", 15.0, "Description: French Culture"))
  
    inventory = Inventory()
    inventory.add_product(product_catalog.products[0], 5)
    payment = Payment(0)
    order_service = OrderService(product_catalog, inventory, payment)
    order_service.place_order("Léa Lee", [OrderItem("Book on DDD", 2), OrderItem("Book on French Culture", 1)])
    # displaying
    # Not enough inventory for product Book on French Culture
    inventory.add_product(product_catalog.products[1], 5)
    payment = Payment(0)
    order_service = OrderService(product_catalog, inventory, payment)
    order_service.place_order("Léo Hu", [OrderItem("Book on DDD", 2), OrderItem("Book on French Culture", 1)])
    # displaying
    # OrderPlacedEvent published: Customer Léo Hu with items [Book on DDD: 2, Book on French Culture: 1] 
    # Payment processed for 55.0 euros
    # Order placed successfully

Dans cette version du code, nous avons ajouté une classe OrderPlacedEvent dans le contexte borné OrderManagement, tout en capturant le changement significatif de la passation d’une commande. Au lieu d’appeler directement les classes Inventory et Payment depuis la classe Order, nous publions maintenant l’objet OrderPlacedEvent, qui est chargé de notifier aux autres contextes bornés qu’une commande a été passée.

Cette implémentation présente plusieurs avantages par rapport à la version sans événements de domaine :

  • Couplage plus lâche : la classe Order ne dépend plus directement des classes Inventory et Payment, et la classe OrderPlacedEvent agit comme un intermédiaire entre elles. Cela rend le système plus lâchement couplé, ce qui améliore la maintenabilité et l’évolutivité.
  • Architecture orientée événements [7] : en utilisant des événements de domaine, nous pouvons créer une architecture pilotée par les événements, dans laquelle des modifications dans une partie du système déclenchent des événements qui peuvent être consommés par d’autres parties du système. Cela permet une conception de système plus flexible et modulaire, car les contextes bornés peuvent être plus facilement séparés et mis à l’échelle indépendamment.
  • Modularité accrue : étant donné que la classe OrderPlacedEvent capture le changement significatif de la passation d’une commande, elle peut être consommée par d’autres contextes bornés qui doivent réagir à l’événement. Cela permet une plus grande modularité, car des contextes bornés peuvent être conçus pour gérer des événements spécifiques sans avoir besoin de connaître l’ensemble du système.

Conclusion

Dans cet article, nous avons mis en lumière les principes de la conception pilotée par le domaine, qui met l’accent sur l’importance de comprendre le domaine du métier et de créer des logiciels alignés sur les besoins du métier. En effet, en les adoptant, les organisations peuvent créer des systèmes logiciels qui apportent une réelle valeur à leur métier. Maintenant, nous avons les outils et les principes pour bien passer notre prochaine épreuve de pratiques de l’Odyssée de la conception pilotée par le domaine.

Références

  • Evans, E. (2003). Domain-driven design: tackling complexity in the heart of software. Addison-Wesley Professional.
  • Vernon, V. (2011). Implementing domain-driven design. Addison-Wesley Professional.
  • Fowler, M. (2013). Patterns of enterprise application architecture. Addison-Wesley Professional.
  • Ghosh, S. (2016). Domain-driven design: A practical approach. Packt Publishing.
  • Larman, C. (2004). Applying UML and patterns: an introduction to object-oriented analysis and design and iterative development. Prentice Hall PTR.
  • L’article de L’Odyssée de la conception Pilotée par le Domain (1)
  • https://fr.wikipedia.org/wiki/Architecture_orient%C3%A9e_%C3%A9v%C3%A9nements