logo le blog invivoo blanc

Domain Driven Design Part 3 – La conception collaborative

4 mai 2023 | Python | 0 comments

Dans les précédents articles [6, 7], nous avons présenté les outils et les principes du Domain Driven Design (DDD). Cette fois, nous allons continuer l’Odyssée sur la mise en œuvre de la conception collaborative par des exemples codés en Python.

Le Domain Driven Design (DDD) met l’accent sur l’importance de la collaboration entre les développeurs, les analystes commerciaux et les autres parties prenantes dans le processus de développement logiciel. En travaillant ensemble, ils peuvent créer un modèle de domaine qui reflète avec précision les besoins du métier.

Le DDD invite à réviser la carte de contexte [7] pour analyser les liaisons en fonction de l’ouverture et la fermeture des contextes bornés.

1. Service Hôte Ouvert

Le service hôte ouvert (Open Host Service en anglais) est utilisé d’exposer le système externe en tant que service au modèle de domaine, plutôt que d’essayer de l’incorporer directement dans le domaine.

Supposons que nous ayons deux contextes bornés dans notre système de commerce électronique : la gestion des commandes et la gestion des clients. Le contexte borné Order Management est responsable de la gestion des commandes, tandis que le contexte borné Customer Management est responsable de la gestion des clients.

Nous voulons créer un Open Host Service qui permet au contexte borné Order Management d’accéder aux données du contexte borné Customer Management. Par ailleurs, nous allons profiter du module « dataclass » en Python 3.7+ qui fournit un moyen pratique de créer des classes avec des méthodes spéciales générées automatiquement, telles que __init__, __repr__ et __eq__.

from dataclasses import dataclass
from typing import List

# In the Customer Management bounded context
@dataclass
class Customer:
    id: str
    name: str
    email: str

class CustomerRepository:
    def __init__(self):
        self.customers = []
    
    def find_by_id(self, id):
        # Find the customer by id
        for customer in self.customers:
            if customer.id == id:
                return customer
        
        return None

# In the Order Management bounded context
@dataclass
class OrderItem:
    product_name: str
    quantity: int

@dataclass
class Order:
    customer_id: str
    items: List[OrderItem]

class OrderService:
    def __init__(self, customer_service):
        self.customer_service = customer_service
    
    def create_order(self, customer_id, items):
        # Use the customer service to get the customer information
        customer = self.customer_service.get_customer(customer_id)

        # Create the order
        order = Order(customer.id, items)

        # Save the order
        # to do

        return order

# The Open Host Service
class CustomerService:
    def __init__(self, customer_repository):
        self.customer_repository = customer_repository
    
    def get_customer(self, customer_id):
        # Find the customer by id
        customer = self.customer_repository.find_by_id(customer_id)

        if customer is None:
            raise ValueError("Client non trouvé")

        return customer

if __name__ == '__main__':
    # create some customers
    customer1 = Customer(1, 'Léa Lee', 'lea.lee@invivoo.com')
    customer2 = Customer(2, 'Léo Hu', 'leo.hu@invivoo.com')

    # add customers to the customer repository
    customer_repository = CustomerRepository()
    customer_repository.customers = [customer1, customer2]

    # create a customer service using the customer repository
    customer_service = CustomerService(customer_repository)

    # create an order service using the customer service
    order_service = OrderService(customer_service)

    # create an order for customer 1
    items = [OrderItem('jeans', 2), OrderItem('baskets', 5)]
    order = order_service.create_order(1, items)

    # print the order
    print(f"Order for customer {order.customer_id}:")
    for item in order.items:
        print(f"{item.quantity} x {item.product_name}")

Dans cet exemple, le contexte borné Customer Management expose un CustomerService qui permet au contexte borné Order Management d’accéder aux données de client. Le CustomerService prend un CustomerRepository comme dépendance, ce qui lui permet d’accéder aux données de client.

OrderService dans le contexte borné de Order Management prend un CustomerService comme dépendance et l’utilise pour obtenir les informations de client lors de la création d’une commande.

Cette implémentation d’un service d’hôte ouvert dans DDD permet une séparation des préoccupations entre les deux contextes bornés et fournit un moyen clair et normalisé pour le contexte borné de gestion des commandes d’accéder aux données du contexte borné de gestion des clients.

2. Noyau partagé 

Le noyau partagé (Shared Kernel en anglais) est une relation entre deux ou plusieurs contextes bornés qui partagent une base de code ou un schéma de base de données commun.


Supposons que notre système de commerce électronique a deux contextes bornés, Product et Order. Dans le contexte Product, nous avons une entité Product qui possède des propriétés telles que le nom, la description et le prix. Dans le contexte Order, nous avons une entité Order qui possède des propriétés telles que customer, items et total_price.

Pour partager l’entité Product entre les deux contextes, nous pouvons définir l’entité Product dans un module partagé.

# shared_kernel.py
from dataclasses import dataclass
from typing import List, Optional

@dataclass
class Product:
    id: int
    name: str
    description: str
    price: float

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

Désormais, dans les contextes bornés Order Management et Inventory Management, nous pouvons importer l’entité Product à partir du module partagé et l’utiliser au besoin.

Sinon, lorsqu’une classe de dataclass est créée, sa méthode __init__ est automatiquement générée en fonction des champs définis. Cependant, si vous souhaitez effectuer des opérations supplémentaires après l’initialisation de l’objet, vous pouvez définir la méthode __post_init__ dans la classe. En plus, elle ne prend aucun argument autre que self et peut accéder et modifier les attributs de l’objet selon les besoins. Elle sera appelée automatiquement par Python immédiatement après l’initialisation de l’objet.

# order_management.py
# In the Order Management context
from shared_kernel import Product


@dataclass
class Order:
    customer_id: int
    items: List[OrderItem]
    total_price: Optional[float] = None

    def __post_init__(self):
        self.total_price = sum([item.product.price * item.quantity for item in self.items])
        
    def add_item(self, product: Product, quantity: int):
        # Add a new item to the order
        item = OrderItem(product, quantity)
        self.items.append(item)
        
        # Recalculate the total price of the order
        self.total_price = sum([item.product.price * item.quantity for item in self.items])

# inventory_management.py
# In the Inventory Management context 
from shared_kernel import Product


class Inventory:
    def __init__(self):
        self.inventory: List[Product] = []
    
    def add_product(self, product: Product):
        # Add the product to the inventory
        self.inventory.append(product)
    
    def remove_product(self, product_name: str):
        # Check if the product is in the inventory
        for product in self.inventory:
            if product.name == product_name:
                # Remove the product from the inventory
                self.inventory.remove(product)
                return True
        
        return False


if __name__ == "__main__":
    # Create some example products
    product1 = Product(1, "CD Céline Dion", "Titanic", 30.0)
    product2 = Product(2, "Ticket Astérix ", "Astérix Attraction", 20.0)
    product3 = Product(3, "Guide Paris", "Guide Voyage", 10.0)


    # Create an example order
    order = Order(1, [OrderItem(product1, 2), OrderItem(product2, 1)])
    print("Order total price:", order.total_price)

    # Create an example inventory
    inventory = Inventory()
    inventory.add_product(product1)
    inventory.add_product(product1)
    inventory.add_product(product2)
    inventory.add_product(product3)
    print("Inventory:", inventory.inventory)

    # Remove a product from the inventory
    result = inventory.remove_product("CD Céline Dion")
    print("Product 1 removed from inventory:", result)
    print("Inventory:", inventory.inventory)    

De cette façon, toute modification apportée à l’entité Product dans le module partagé sera automatiquement répercutée dans les contextes Order et Inventory, garantissant la cohérence et évitant la duplication de code.

3. Client-fournisseur

Le client-fournisseur (Customer Supplier Teams en anglais) est une relation entre deux ou plusieurs contextes bornés où un contexte fournit un service ou des données à un autre contexte. Le contexte fournisseur est le fournisseur et le contexte récepteur est le client.

En e-commerce, l’équipe client serait responsable de la définition des exigences et des règles commerciales liées aux informations sur les produits et leurs stocks. Ils travailleraient en étroite collaboration avec l’équipe du fournisseur, qui serait l’inventaire qui gère les stocks de produits, y compris l’ajout et la suppression des données.

from dataclasses import dataclass
from typing import List


@dataclass
class Product:
    id: int
    name: str
    description: str
    price: float


# Inventory Management Bounded Context
@dataclass
class Inventory:
    products: List[Product]

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

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


# Order Management Bounded Context
@dataclass
class Order:
    id: int
    customer_id: int
    items: List[Product]

@dataclass
class OrderService:
    inventory: Inventory

    def place_order(self, order: Order):
        for item in order.items:
            if item not in self.inventory.products:
                raise ValueError(f"Product {item.name} is not in inventory.")
        # Place the order...


if __name__ == '__main__':
    # Create some sample data
    product1 = Product(1, "CD Céline Dion", "Titanic", 30.0)
    product2 = Product(2, "Ticket Astérix ", "Astérix Attraction", 20.0)
    product3 = Product(3, "Guide Paris", "Guide Voyage", 10.0)

    inventory = Inventory([product1, product2, product3])
    order = Order(1, 1, [product1, product1, product3])
    order_service = OrderService(inventory)

    # Test the order service
    try:
        order_service.place_order(order)
        print(f"Order {order} placed successfully.")
    except ValueError as e:
        print(f"Error placing order: {e}")

Dans cet exemple, le contexte Inventory Management définit la classe de données Inventory, qui gère une liste de produits, tandis que le contexte Order Management définit la classe de données Order et la classe OrderService, qui place les commandes. OrderService dépend de l’inventaire du contexte de gestion des stocks.

Dans cette relation, le service de commande est le client, qui dépend du fournisseur : l’inventaire pour vérifier si les articles d’une commande sont en stock avant de placer la commande.

En utilisant cette approche, l’équipe du client est en mesure de définir et de gérer sa logique de domaine séparément du fournisseur en amont, ce qui permet une plus grande flexibilité et une maintenabilité à long terme.

4. Conformiste 

Le conformiste (Conformist en anglais) donne les conventions et les règles d’un autre contexte que le contexte conformiste suit en ajustant son comportement pour correspondre aux exigences du contexte dirigeant.

Supposons que nous ayons un contexte borné : Rating Management pour gérer les évaluations de produits. Ce contexte est responsable de la gestion des notes et des avis des clients pour chaque produit. Les évaluations sont stockées dans une base de données et peuvent être consultées par d’autres contextes bornés dans le système.

Du fait, dans un autre contexte borné responsable de la gestion des recommandations de produits, les données d’évaluation ne sont pas stockées dans le format requis pour des calculs de recommandation efficaces. Afin d’utiliser les données de notation dans ce contexte, nous devons les transformer dans le format correct.

C’est là que le modèle conformiste entre en jeu. The Conformist est une classe qui convertit les données d’un format à un autre.

Voici un exemple d’implémentation :

from dataclasses import dataclass
from typing import List


@dataclass
class Product:
    id: int
    name: str
    description: str
    price: float

# Rating Management bounded context
@dataclass
class Rating:
    customer_id: int
    product_id: int
    rating: int
    review: str

class RatingConformist:
    def __init__(self, ratings: List[Rating]):
        self.ratings = ratings
    
    def get_ratings_by_product(self, product_id: int) -> List[int]:
        return [rating.rating for rating in self.ratings if rating.product_id == product_id]

# In the Product Recommendations bounded context
class ProductRecommendations:
    def __init__(self, ratings_conformist: RatingConformist):
        self.ratings_conformist = ratings_conformist
    
    def get_recommendations(self, product_id: int) -> List[Product]:
    # Get the ratings for the product using the RatingConformist
      product_ratings = self.ratings_conformist.get_ratings_by_product(product_id)
  
      # Get a list of all products that have been rated, except for the current product
      rated_products = set(r.product_id for r in self.ratings_conformist.ratings if r.product_id != product_id)
  
      # Calculate the average rating for each rated product and store the results in a list of tuples
      avg_ratings = [(product_id, sum(self.ratings_conformist.get_ratings_by_product(product_id)) / len(self.ratings_conformist.get_ratings_by_product(product_id)))
                     for product_id in rated_products]
  
      # Sort the list of tuples by average rating and get the best products
      top_products = sorted(avg_ratings, key=lambda x: x[1], reverse=True)[:1]
  
      # Get the recommended products based on the top product IDs
      recommended_products = [p for p in self.ratings_conformist.ratings if p.product_id in [tp[0] for tp in top_products]]
  
      # Return the recommended products
      return recommended_products
  


if __name__ == "__main__":
    # In the Rating Management bounded context
    ratings = [
        Rating(customer_id=1, product_id=1, rating=4, review="Good product"),
        Rating(customer_id=2, product_id=1, rating=5, review="Great product"),
        Rating(customer_id=3, product_id=1, rating=3, review="Okay product"),
        Rating(customer_id=1, product_id=2, rating=5, review="Awesome product"),
        Rating(customer_id=2, product_id=2, rating=4, review="Very good product"),
        Rating(customer_id=3, product_id=2, rating=4, review="Great product"),
        Rating(customer_id=1, product_id=3, rating=2, review="Not so good product"),
        Rating(customer_id=2, product_id=3, rating=3, review="Average product"),
        Rating(customer_id=3, product_id=3, rating=2, review="Disappointing product")
    ]

    # In the Product Recommendations bounded context
    rating_conformist = RatingConformist(ratings)
    product_recommendations = ProductRecommendations(rating_conformist)
    recommendations = product_recommendations.get_recommendations(product_id=2)

    print(recommendations)

Dans cet exemple, nous obtenons un ensemble de tous les produits notés (à l’exclusion du produit actuel), puis calculons la note moyenne pour chaque produit noté dans une compréhension de liste qui crée une liste de tuples. La liste des tuples est ensuite triée par note moyenne et les meilleurs produits sont sélectionnés. Enfin, les produits recommandés sont obtenus à l’aide d’une liste en compréhension et renvoyés.

5. Couche anti-corruption (ACL) 

La couche anti-corruption (Anti-corruption Layer en anglais) joue le rôle du pont entre les contextes ayant les incohérences des attributs, traduisant et validant les données échangées entre eux.

Disons que nous avons deux contextes bornés : Sales et Shipping. Le contexte Sales a une entité SalesCustomer qui a un nom et une adresse, tandis que le contexte Shipping a une entité ShippingCustomer similaire avec un nom et une adresse de livraison. Cependant, les deux contextes utilisent des termes différents pour désigner le même concept – « adresse de facturation » dans Sales et « adresse de livraison » dans Shipping. Pour éviter la confusion et les incohérences, nous pouvons utiliser une couche anti-corruption pour traduire entre les deux contextes.

Voici le code de la couche anti-corruption :

from dataclasses import dataclass


# In the Sales context
@dataclass
class SalesCustomer:
    name: str
    billing_address: Address


@dataclass
class Address:
    street: str
    city: str
    country: str
    zip_code: str



# In the Shipping context
@dataclass
class ShippingCustomer:
    name: str
    shipping_address: Address


# In the Anti-corruption Layer
class SalesToShippingTranslator:
    def translate_customer(self, customer):
        shipping_address = Address(
            street=customer.billing_address.street,
            city=customer.billing_address.city,
            country=customer.billing_address.country,
            zip_code=customer.billing_address.zip_code
        )
        translated_customer = ShippingCustomer(
            name=customer.name,
            shipping_address=shipping_address
        )
        return translated_customer

class ShippingService:
    def create_shipping_order(self, customer):
        print(f"Creating shipping order for customer {customer.name} with address {customer.shipping_address.street}, {customer.shipping_address.city}, {customer.shipping_address.country}, {customer.shipping_address.zip_code}")


if __name__ == "__main__":
    sales_customer = SalesCustomer(name="Léo", billing_address=Address(street="123 La Défense", city="Puteaux", country="France", zip_code="92880"))
    translator = SalesToShippingTranslator()
    translated_customer = translator.translate_customer(sales_customer)
    shipping_service = ShippingService()
    shipping_service.create_shipping_order(translated_customer)

Dans cet exemple, lorsque le contexte Sales doit envoyer une entité SalesCustomer au contexte Shipping, il peut utiliser le SalesToShippingTranslator pour traduire l’entité Customer en une entité ShippingCustomer.

De cette façon, les deux contextes peuvent communiquer sans dupliquer le code ni causer de confusion.

6. Chemins séparés 

Les Chemins séparés (Separate Ways en anglais) est la séparation des préoccupations par la décomposition d’un package ou un module monolithe en domaines ou sous-domaines distincts et bien définis.

Disons que nous avons un site Web de commerce électronique qui vend à la fois des produits physiques et numériques. Nous avons une classe Product qui représente les deux types de produits. Cependant, lorsque nous voulons traiter une commande, nous devons traiter différemment les produits physiques et numériques.

Pour appliquer le modèle Separate Ways, nous pouvons diviser la classe Product en deux classes distinctes : PhysicalProduct et DigitalProduct. Chaque classe peut avoir son propre ensemble d’attributs et de méthodes spécifiques au type de produit qu’elle représente.

from dataclasses import dataclass

@dataclass
class Address:
    street: str
    city: str
    zipcode: str
    country: str

@dataclass
class Customer:
    name: str
    email: str
    shipping_address: Address

@dataclass
class Product:
    name: str
    price: float
    sku: str

    def process_order(self, customer):
        if isinstance(self, PhysicalProduct):
            shipping_address = customer.shipping_address
            print(f"Creating shipping order for product {self.name} to customer {customer.name} with shipping address {shipping_address}")
            print(f"Processing payment for customer {customer.name} for amount {self.price}")
        elif isinstance(self, DigitalProduct):
            print(f"Sending download link for product {self.name} to customer {customer.name} at email address {customer.email}")
            print(f"Processing payment for customer {customer.name} for amount {self.price}")

@dataclass
class PhysicalProduct(Product):
    weight: float

@dataclass
class DigitalProduct(Product):
    download_url: str

if __name__ == "__main__":
    address = Address(street="123 La Défense", city="Puteaux", zipcode="92800", country="France")
    customer = Customer(name="Léo", email="leo.hu@invivoo.com", shipping_address=address)

    physical_product = PhysicalProduct(name="T-shirt", price=20.00, sku="TS001", weight=0.5)
    digital_product = DigitalProduct(name="E-book", price=10.00, sku="EB001", download_url="http://example.com/ebook")

    physical_product.process_order(customer)
    digital_product.process_order(customer)

Dans cet exemple, on crée une instance Customer avec son objet Adresse et deux instances de produit : PhysicalProduct et une DigitalProduct. Ensuite, il appelle la méthode process_order sur chaque instance de produit, en transmettant l’instance Customer. La logique de traitement de commande appropriée est exécutée en fonction du type de produit.

Du coup, le contexte PhysicalProduct et le contexte DigitalProduct peuvent être développés et déployés indépendamment.  

Conclusion

Dans cet article, nous avons bien démarré les épreuve sur les pratiques de la conception collaborative en Python, tout en révisant la carte de contexte dans la conception pilotée par le domaine (DDD). En suivant ces pratiques, les développeurs peuvent créer des systèmes logiciels bien conçus, efficaces et faciles à maintenir.

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 la Conception Pilotée par Domain (1)
  • L’article de la Conception Pilotée par Domain (2)