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)