Décomposer un Monolithe en Microservices avec DDD, API First et Architecture Hexagonale

Par Kamanga23 août 202413 mins de lecture

Introduction : Pourquoi décomposer un monolithe ?

Mise en situation : tu bosses sur une énorme application monolithique, chaque nouvelle fonctionnalité te donne des sueurs froides, et chaque déploiement ressemble à une roulette russe. Tu corriges un bug ici, et hop, trois autres apparaissent là-bas. Le pire, c'est que tout est tellement entremêlé que même changer une ligne de code peut entraîner la chute de l’application entière. Si tu en es là, bienvenue dans le club.

Le problème ? Votre application est devenue un monstre indomptable, et il est peut-être temps de lui donner une bonne cure de microservices. Mais attends… casser ton monolithe en petits services indépendants, ça te semble risqué, non ? Après tout, personne ne veut transformer un monstre en une centaine de petits monstres incontrôlables.

Rassure-toi, ce n’est pas la peine de tout casser à l’aveugle. Avec les bonnes méthodes, tu peux diviser intelligemment ton monolithe en microservices. C’est là qu’interviennent des concepts comme le Domain-Driven Design (DDD), l’approche API First et l'architecture hexagonale. Ces outils te permettront de ne pas simplement découper au hasard, mais de structurer tes microservices autour des vrais besoins métiers, de les exposer proprement et de les organiser pour qu’ils restent faciles à maintenir.

Dans cet article, tu vas apprendre à :

  • Utiliser le DDD pour identifier les Bounded Contexts et structurer tes services autour des besoins réels.
  • Concevoir des APIs propres avec l'approche API First, pour exposer et consommer des services bien pensés dès le départ.
  • Structurer ton projet avec l'architecture hexagonale pour bien séparer les responsabilités entre application, domaine et infrastructure.

Je vais te montrer comment tout ça fonctionne avec des exemples concrets en Java, et je vais t’expliquer comment éviter les pièges que beaucoup rencontrent en passant des monolithes aux microservices. Crois-moi, tu n’es pas le premier à passer par là !


Domain-Driven Design (DDD) : Concentrons-nous sur les besoins métier

Le Domain-Driven Design (ou DDD pour les intimes) est souvent vu comme une approche compliquée, pleine de jargon. Mais en réalité, c’est un excellent moyen de structurer tes microservices autour des vrais besoins métier de ton application. Et crois-moi, c'est bien mieux que de découper ton monolithe au hasard.

Le principe des Bounded Contexts : où commencer ?

Le DDD repose sur une idée simple : ton application représente plusieurs domaines fonctionnels, et chacun de ces domaines peut avoir des règles, des comportements et des modèles de données spécifiques. Pour bien découper ton monolithe, il faut donc commencer par identifier ces Bounded Contexts — c'est-à-dire les frontières logiques où chaque microservice va évoluer.

Par exemple, si tu développes une application de e-commerce, tu pourrais avoir plusieurs domaines comme :

  • Catalogue produit : gérer les produits, les catégories, les descriptions.
  • Panier et commande : gérer les paniers d’achats, les commandes, les paiements.
  • Gestion des utilisateurs : gérer les comptes, les profils, les adresses.

Chaque domaine est indépendant (ou presque) et correspondra à un microservice. Ces Bounded Contexts définissent les limites de chaque service et empêchent les données de s'entremêler.

Tip : Comment bien choisir tes Bounded Contexts ?

  • Pense métier : Les développeurs aiment souvent découper en fonction du code, mais pense avant tout en termes de besoins métier. Quels sont les domaines fonctionnels qui ne se mélangent pas ?
  • Évite la sur-segmentation : Si tu découpes trop finement, tu risques d’avoir des microservices qui font très peu de choses mais dépendent énormément des autres. Ce qui est un cauchemar à gérer.
  • Teste tes hypothèses : Une bonne pratique est de discuter avec les équipes produit ou métier pour valider tes choix. Si une partie du code concerne des règles spécifiques à un domaine fonctionnel, tu es probablement sur la bonne voie.

Exemple en Java : Structurer un service autour d'un Bounded Context

Prenons l'exemple d'un service de gestion de panier dans une application e-commerce.

Tu peux structurer ton projet Java autour de ce Bounded Context de cette manière :

public class Cart {
    private List<Item> items;

    public void addItem(Item item) {
        items.add(item);
    }

    public void removeItem(Item item) {
        items.remove(item);
    }

    public double calculateTotal() {
        return items.stream()
            .mapToDouble(Item::getPrice)
            .sum();
    }
}

Ce service s'occupe exclusivement de la gestion du panier, et ne doit pas se préoccuper des autres parties de l’application comme la gestion des produits ou des utilisateurs. En appliquant ce découpage DDD, on s'assure que chaque service est bien isolé autour d'un besoin métier spécifique.

Alerte : Ne vous éparpillez pas !

Assurez-vous de ne pas découper trop finement, au risque de créer des services trop petits qui multiplient les dépendances entre eux. Restez concentré sur des domaines bien définis.


API First : L'importance d'exposer et de consommer correctement vos APIs

Maintenant que vous avez identifié vos Bounded Contexts grâce au DDD, la prochaine étape consiste à bien penser la communication entre vos microservices. C’est là qu’intervient l’approche API First. En gros, l'idée est simple : avant d’écrire la moindre ligne de code, vous concevez et documentez vos API. Cela vous force à réfléchir à comment vos services vont échanger des données et interagir entre eux dès le début, et ça vous évite de vous retrouver avec des API mal conçues qui deviennent vite un casse-tête.

Pourquoi l’approche API First est cruciale ?

Dans une architecture microservices, chaque service doit pouvoir communiquer avec les autres de manière claire, évolutive, et surtout, prévisible. Si vos APIs sont mal définies, vous allez créer de la dette technique dès le départ. Voici pourquoi une approche API First est essentielle :

  1. Prévisibilité : Quand une API est bien documentée, les équipes qui la consomment savent à quoi s'attendre. Plus de mauvaises surprises en découvrant un champ manquant ou une réponse différente de ce qui était anticipé.
  2. Évolutivité : Une API bien pensée est facile à faire évoluer sans casser les implémentations existantes. Cela vous permet d'ajouter de nouvelles fonctionnalités sans perturber les services qui consomment cette API.
  3. Dépendances réduites : Si chaque service expose une API claire et indépendante, il devient beaucoup plus facile de gérer les dépendances entre les équipes et les services.

Tip : La documentation avant l’implémentation

Utilisez des outils comme Swagger ou OpenAPI pour documenter vos APIs avant même de commencer à écrire du code. Cela vous permet de :

  • Valider l’API avec les autres équipes (back-end, front-end, mobile) pour vous assurer que tout le monde est sur la même longueur d’onde.
  • Prototyper rapidement les interactions entre services sans attendre l’implémentation complète.
  • Tester facilement les API avec des outils comme Postman avant d’entrer dans la logique métier plus complexe.

Exemple en Java avec Spring Boot : Définir une API pour le service Panier

Imaginons que vous ayez un microservice Panier, qui gère les actions liées au panier d’achats de vos utilisateurs. Voici comment vous pourriez exposer une API pour ajouter des articles au panier.

@RestController
@RequestMapping("/api/cart")
public class CartController {

    private final CartService cartService;

    public CartController(CartService cartService) {
        this.cartService = cartService;
    }

    @PostMapping("/add")
    public ResponseEntity<Cart> addItemToCart(@RequestBody Item item) {
        Cart updatedCart = cartService.addItem(item);
        return ResponseEntity.ok(updatedCart);
    }

    @GetMapping("/{cartId}")
    public ResponseEntity<Cart> getCart(@PathVariable String cartId) {
        Cart cart = cartService.getCart(cartId);
        return ResponseEntity.ok(cart);
    }
}

Ici, on expose deux endpoints simples :

  • Un endpoint POST /api/cart/add pour ajouter un item au panier.
  • Un endpoint GET /api/cart/{cartId} pour récupérer l’état du panier.

Avant même de coder cela, vous pourriez déjà définir ces endpoints avec Swagger afin que les équipes consommatrices puissent tester et donner leur feedback.

Alerte : Ne sous-estimez pas le versionnage d'API

Pensez à versionner vos APIs dès le départ.

Cela vous permet de déployer de nouvelles fonctionnalités sans casser les anciennes. Un exemple de structure d'URL :

/api/v1/cart/add

Architecture Hexagonale : Structurer le projet avec Application, Domaine et Infrastructure

L'architecture hexagonale (ou architecture en ports et adaptateurs) est un modèle qui vise à séparer clairement les différentes couches de votre application pour la rendre plus modulaire et maintenable. Dans le cadre d'une transition vers des microservices, elle permet de structurer vos services de manière à ce que le domaine métier reste au cœur de l'implémentation, tandis que les détails techniques (comme les bases de données, les APIs externes, etc.) soient placés en périphérie.

Le concept est simple : votre application est comme un hexagone (oui, tu l’avais deviné), où chaque côté représente une interface qui connecte le cœur de l’application (la logique métier) à ses dépendances extérieures (base de données, APIs, interface utilisateur).

Pourquoi adopter l’architecture hexagonale pour vos microservices ?

Voici trois avantages principaux de l'architecture hexagonale :

  1. Séparation claire des préoccupations : En plaçant la logique métier au centre, vous assurez que vos règles de gestion ne sont pas affectées par la manière dont les données sont stockées ou envoyées. Cela rend votre code plus facile à maintenir et à tester.
  2. Facilité de test : En isolant les dépendances extérieures (comme la base de données ou les appels d’API), il devient plus facile de tester vos services avec des tests unitaires et d’intégration.
  3. Évolutivité : Vous pouvez changer les technologies sous-jacentes (par exemple, passer d'une base SQL à NoSQL) sans impacter le cœur de votre application, car tout est isolé par des ports (interfaces).

Les trois couches principales

L'architecture hexagonale se décompose en trois grandes couches :

  • Domaine : C’est ici que se trouve toute la logique métier. Cette couche est totalement indépendante de la technologie utilisée.
  • Application : Elle orchestre les actions à réaliser et gère les cas d’utilisation. Elle appelle les services du domaine.
  • Infrastructure : Cette couche gère tout ce qui est technique, comme les interactions avec les bases de données, les APIs externes, etc. Elle s’adapte aux besoins du domaine via des interfaces.

Exemple d'implémentation en Java

Prenons l'exemple de notre service Panier (Cart). Nous allons structurer le code en suivant les principes de l'architecture hexagonale.

  1. Domaine : La couche qui gère les règles métiers. Elle est totalement indépendante de la base de données ou de toute autre technologie.
public class Cart {
    private List<Item> items = new ArrayList<>();

    public void addItem(Item item) {
        items.add(item);
    }

    public void removeItem(Item item) {
        items.remove(item);
    }

    public List<Item> getItems() {
        return items;
    }
}
  1. Application : La couche qui orchestre les actions, comme ajouter un produit au panier.
public class CartService {
    private final CartRepository cartRepository;

    public CartService(CartRepository cartRepository) {
        this.cartRepository = cartRepository;
    }

    public Cart addItemToCart(String cartId, Item item) {
        Cart cart = cartRepository.findById(cartId);
        cart.addItem(item);
        cartRepository.save(cart);
        return cart;
    }

    public Cart getCart(String cartId) {
        return cartRepository.findById(cartId);
    }
}
  1. Infrastructure : Ici, nous implémentons les détails techniques, comme le stockage des données.
@Repository
public class CartRepositoryImpl implements CartRepository {

    private final Map<String, Cart> database = new HashMap<>();

    @Override
    public Cart findById(String cartId) {
        return database.getOrDefault(cartId, new Cart());
    }

    @Override
    public void save(Cart cart) {
        database.put("cartId", cart);
    }
}

Tip : Utiliser des Adapters pour faciliter les tests

Une des forces de l'architecture hexagonale est de permettre un découplage fort entre la logique métier et les aspects techniques. En implémentant des adapters dans la couche infrastructure, vous pouvez facilement substituer des dépendances pour les tests.


Exemples en Java : De la théorie à la pratique

Nous allons créer un microservice qui gère un Panier d’achat dans une application de e-commerce. Ce service illustrera comment utiliser les trois concepts (DDD, API First et architecture hexagonale) pour structurer un service solide, maintenable et facilement testable.

Étape 1 : Définir le domaine métier avec DDD

Notre service Panier (Cart) doit gérer les actions principales suivantes :

  • Ajouter un article au panier
  • Supprimer un article
  • Calculer le total des articles dans le panier

Commençons par définir la logique métier de base dans la couche Domaine.

public class Cart {
    private final List<Item> items = new ArrayList<>();

    public void addItem(Item item) {
        items.add(item);
    }

    public void removeItem(Item item) {
        items.remove(item);
    }

    public double calculateTotal() {
        return items.stream()
            .mapToDouble(Item::getPrice)
            .sum();
    }

    public List<Item> getItems() {
        return new ArrayList<>(items); // retour d'une copie pour éviter les modifications extérieures
    }
}

Étape 2 : Orchestrer les cas d’utilisation dans la couche Application

Ensuite, nous avons besoin d’une couche Application pour orchestrer les interactions avec ce domaine.

public class CartService {
    private final CartRepository cartRepository;

    public CartService(CartRepository cartRepository) {
        this.cartRepository = cartRepository;
    }

    public Cart addItemToCart(String cartId, Item item) {
        Cart cart = cartRepository.findById(cartId)
                .orElse(new Cart());
        cart.addItem(item);
        cartRepository.save(cart);
        return cart;
    }

    public Cart removeItemFromCart(String cartId, Item item) {
        Cart cart = cartRepository.findById(cartId)
                .orElseThrow(() -> new IllegalArgumentException("Panier introuvable"));
        cart.removeItem(item);
        cartRepository.save(cart);
        return cart;
    }

    public double getCartTotal(String cartId) {
        Cart cart = cartRepository.findById(cartId)
                .orElseThrow(() -> new IllegalArgumentException("Panier introuvable"));
        return cart.calculateTotal();
    }

    public Cart getCart(String cartId) {
        return cartRepository.findById(cartId)
                .orElseThrow(() -> new IllegalArgumentException("Panier introuvable"));
    }
}

Étape 3 : Implémenter la couche Infrastructure

public interface CartRepository {
    Optional<Cart> findById(String cartId);
    void save(Cart cart);
}

@Repository
public class InMemoryCartRepository implements CartRepository {

    private final Map<String, Cart> database = new HashMap<>();

    @Override
    public Optional<Cart> findById(String cartId) {
        return Optional.ofNullable(database.get(cartId));
    }

    @Override
    public void save(Cart cart) {
        database.put("cartId", cart);
    }
}

Cet exemple stocke les paniers dans une simple HashMap pour simuler une base de données en mémoire.

Étape 4 : Exposer l’API avec Spring Boot et l’approche API First

@RestController
@RequestMapping("/api/v1/cart")
public class CartController {

    private final CartService cartService;

    public CartController(CartService cartService) {
        this.cartService = cartService;
    }

    @PostMapping("/{cartId}/add")
    public ResponseEntity<Cart> addItem(@PathVariable String cartId, @RequestBody Item item) {
        Cart updatedCart = cartService.addItemToCart(cartId, item);
        return ResponseEntity.ok(updatedCart);
    }

    @DeleteMapping("/{cartId}/remove")
    public ResponseEntity<Cart> removeItem(@PathVariable String cartId, @RequestBody Item item) {
        Cart updatedCart = cartService.removeItemFromCart(cartId, item);
        return ResponseEntity.ok(updatedCart);
    }

    @GetMapping("/{cartId}/total")
    public ResponseEntity<Double> getCartTotal(@PathVariable String cartId) {
        double total = cartService.getCartTotal(cartId);
        return ResponseEntity.ok(total);
    }

    @GetMapping("/{cartId}")
    public ResponseEntity<Cart> getCart(@PathVariable String cartId) {
        Cart cart = cartService.getCart(cartId);
        return ResponseEntity.ok(cart);
    }
}

Tips : Gérer les transactions distribuées

Dans une architecture microservices, une transaction peut impliquer plusieurs services. Utilisez le pattern de la cohérence éventuelle pour synchroniser les services plutôt que de tenter une transaction distribuée complexe.


Conclusion : Récapitulatif et pièges à éviter

Décomposer un monolithe en microservices peut sembler être une tâche complexe et intimidante, mais avec la bonne approche, cela

devient beaucoup plus gérable. En utilisant des concepts comme le Domain-Driven Design (DDD) pour bien comprendre et structurer vos besoins métier, l'approche API First pour exposer vos services proprement, et l'architecture hexagonale pour organiser votre code de façon modulaire et évolutive, vous mettez toutes les chances de votre côté pour réussir cette transition.

Petit récap’ des points clés :

  1. DDD vous aide à découper vos services en fonction des besoins métier, grâce à des Bounded Contexts bien définis.
  2. L'approche API First permet de bien réfléchir à la communication entre vos microservices dès le début, en exposant des interfaces claires et documentées.
  3. L'architecture hexagonale structure votre projet en trois couches (domaine, application, infrastructure), facilitant la maintenabilité et les tests.

Les pièges à éviter :

  • Ne pas découper trop finement : Vous ne voulez pas que vos services deviennent tellement petits qu'ils dépendent les uns des autres en permanence, créant un chaos ingérable.
  • Oublier la documentation d’API : Si vos APIs ne sont pas bien pensées et bien documentées, vous risquez de ralentir toute l’équipe.
  • Sous-estimer les dépendances entre services : Assurez-vous de bien gérer les transactions entre services, notamment en utilisant des événements ou des mécanismes de compensation pour éviter des incohérences.

FAQ : Réponses aux questions fréquentes

1. Comment savoir si un microservice est trop petit ou trop gros ?

Un bon microservice doit avoir une responsabilité claire et isolée. S'il doit constamment interagir avec d'autres services pour accomplir une tâche, il est probablement trop petit. À l'inverse, s’il englobe plusieurs domaines fonctionnels, il est probablement trop gros. Le DDD aide à déterminer la bonne taille grâce aux Bounded Contexts.

2. Est-ce que l'architecture hexagonale n'ajoute pas trop de complexité ?

Au début, cela peut sembler ajouter quelques couches supplémentaires, mais à long terme, cela simplifie grandement la maintenabilité de votre code. L'idée est de séparer les préoccupations métiers (le domaine) des détails techniques (l’infrastructure).

3. Comment gérer les transactions distribuées dans une architecture microservices ?

Les transactions distribuées peuvent être complexes. Une solution est d’utiliser le principe de cohérence éventuelle. Par exemple, utilisez des événements pour notifier les services et effectuer des ajustements en asynchrone.

4. L'API First, ça paraît beaucoup de documentation, est-ce vraiment nécessaire ?

Oui, absolument. Bien documenter vos APIs avant de coder vous fait gagner un temps précieux à long terme. Utilisez Swagger pour générer la documentation automatiquement.

5. Quand faut-il envisager de décomposer un monolithe en microservices ?

Quand votre monolithe devient trop lourd à maintenir : temps de déploiement long, dépendances fortes entre les modules, difficultés à scaler certaines parties du système.


Rédigé par Kamanga

Expert IT avec 25 ans d'expérience en développement logiciel, diplômé EPITECH et MBA. Spécialisé en software craftsmanship, gestion du changement, stratégie, direction des systèmes d'information, coaching et certifié en agilité.

Copyright © 2024
 Kamanga
  Powered by bloggrify