Clean code & architecture

Architecture microservices robuste et évolutive : migrer d’un monolithe pas à pas

Migrer d'un monolithe vers des microservices sans big bang : Strangler Fig, découpage DDD, Kafka avec Outbox, Saga, API Gateway, Resilience4j et observabilité. Code Spring Boot et indicateurs DORA.

Illustration : un monolithe qui se découpe en services indépendants reliés par un bus d'événements

En bref

Une architecture microservices robuste ne se construit pas en un « big bang ». On migre progressivement avec le pattern Strangler Fig, on découpe selon les bounded contexts (DDD) avec une base de données par service, on privilégie les événements Kafka publiés de façon fiable (Outbox), on gère les transactions avec des Sagas, et on rend le tout observable et résilient. Le succès se mesure avec les indicateurs DORA, pas au nombre de services.

Lexique
Microservice

Service autonome, déployable seul, qui porte une capacité métier complète et possède ses propres données.

Monolithe modulaire

Application déployée d’un bloc mais découpée en modules aux frontières claires, alignés sur les domaines métier.

Monolithe distribué

Anti-pattern : des services séparés mais si couplés qu’ils doivent être modifiés et déployés ensemble.

DDD (Domain-Driven Design)

Approche de conception qui aligne le code sur le langage et les règles du métier.

Bounded context

Frontière à l’intérieur de laquelle un modèle métier a un sens précis. En microservices : idéalement un service par contexte.

Event Storming

Atelier collaboratif avec le métier pour cartographier les événements d’un domaine et repérer les bounded contexts.

Strangler Fig

Pattern de migration progressive : une façade route le trafic, et les fonctionnalités sont extraites du monolithe une à une.

API Gateway

Point d’entrée unique des clients : routage, authentification, rate limiting et fallbacks.

Database per service

Règle selon laquelle chaque service possède sa propre base ; aucun autre service n’accède directement à ses tables.

Apache Kafka

Plateforme de streaming d’événements distribuée, utilisée pour la communication asynchrone entre services.

Clé de partition Kafka

Clé d’un message qui détermine sa partition ; les messages de même clé sont lus dans l’ordre.

Transactional Outbox

Pattern qui enregistre l’événement dans une table outbox, dans la même transaction que la donnée métier, avant de le publier.

CDC / Debezium

Change Data Capture : lecture du journal de la base pour publier les changements. Debezium est l’outil open source de référence.

At-least-once

Garantie de livraison : chaque message arrive au moins une fois, d’éventuels doublons sont possibles.

Consommateur idempotent

Consommateur qui produit le même résultat s’il reçoit plusieurs fois le même événement.

Saga

Suite de transactions locales coordonnées par événements, avec des compensations en cas d’échec, à la place d’une transaction distribuée.

Chorégraphie / orchestration

Deux styles de Saga : chaque service réagit aux événements (chorégraphie) ou un orchestrateur central pilote les étapes.

Compensation

Action métier qui annule l’effet d’une étape déjà réalisée (ex. rembourser un paiement).

2PC / XA

Validation en deux phases : protocole de transaction distribuée, à éviter entre microservices.

Circuit breaker

Coupe-circuit qui cesse temporairement d’appeler un service en échec, pour éviter les pannes en cascade.

Resilience4j

Bibliothèque Java de résilience : circuit breaker, retry, rate limiter, bulkhead, time limiter.

Rate limiting

Limitation du nombre de requêtes par client et par période, pour protéger les services.

Service mesh (Istio)

Couche d’infrastructure qui gère au niveau réseau le trafic, la sécurité mTLS et la résilience entre services.

OpenTelemetry

Standard ouvert de collecte des traces, métriques et logs, indépendant de l’outil de visualisation.

Tracing distribué

Suivi d’une requête à travers tous les services, grâce à un identifiant commun (traceId).

Indicateurs DORA

Quatre métriques de performance de livraison : fréquence de déploiement, délai de mise en production, taux d’échec des changements, temps de restauration.

Lag consumer

Retard d’un consommateur Kafka : nombre de messages publiés mais pas encore traités.

Tests de contrat

Tests qui vérifient qu’un fournisseur d’API respecte ce qu’attendent ses consommateurs (Pact, Spring Cloud Contract).

Probes liveness / readiness

Sondes Kubernetes : la première détecte un service bloqué à redémarrer, la seconde indique s’il peut recevoir du trafic.

Introduction

Passer d’un monolithe à des microservices est un tournant majeur dans la vie d’un système. Il ne s’agit pas de « découper en petits services », mais de construire une architecture distribuée résiliente, observable et maintenable par des équipes autonomes.

À l’intérieur de chaque service, l’architecture hexagonale avec Spring Boot garde le domaine métier indépendant de Kafka, de la base et du framework. Et quand la base d’un service grossit, le partitionnement PostgreSQL permet de la faire monter en charge sans redécouper le service.

Comme le souligne Sam Newman dans Building Microservices, les microservices ne sont pas un objectif en soi : ce sont un moyen de gagner en autonomie d’équipe, en vitesse de livraison et en capacité de montée en charge. Si ces bénéfices ne sont pas attendus, un monolithe modulaire reste souvent le meilleur choix.

Ce guide présente une démarche éprouvée, étape par étape, avec du code Spring Boot 3, Kafka et Spring Cloud Gateway.


Problématique observée

  • Monolithe en croissance : chaque déploiement devient risqué et lent, les équipes se marchent dessus.
  • Couplage fort : une petite modification dans un module impacte plusieurs autres.
  • Scalabilité limitée : impossible de scaler uniquement le module critique (ex. le paiement pendant les soldes).
  • Time-to-market allongé : plusieurs équipes doivent se synchroniser pour une release globale.

Tentatives ratées (leçons apprises)

  1. Découpage prématuré : extraire des services sans comprendre les domaines métier produit une explosion d’appels inter-services, un « monolithe distribué » pire que l’original.
  2. Microservices trop petits : un service par entité (Client, Adresse, Ligne de commande…) multiplie les coûts d’orchestration, de déploiement et de supervision.
  3. Base de données partagée : les services restent couplés par le schéma ; une migration SQL casse trois équipes.
  4. Mélange de protocoles sans standard : REST, gRPC et SOAP sans gouvernance créent une dette technique immédiate.
  5. Double écriture DB + Kafka : sauvegarder en base puis publier un message sans garantie atomique fait perdre des événements à la première panne.

Vue d’ensemble de l’architecture cible

Architecture microservices cible Les clients passent par une API Gateway qui route vers trois services : User, Order et Payment. Chaque service possède sa propre base de données et communique via Apache Kafka. L’observabilité repose sur OpenTelemetry, Prometheus et Grafana. Web · Mobile · Partenaires API Gateway Auth JWT · Rate limiting · Routage · Circuit breaker User ServiceContexte : Identitéprofils, rôles Order ServiceContexte : Commandepanier, commandes Payment ServiceContexte : Paiementautorisations, remboursements users DBorders DBpayments DB Apache Kafka orders.events · payments.events · users.events Observabilité : OpenTelemetry → Jaeger/Tempo · Prometheus + Grafana · logs corrélés par traceId
Architecture cible : un service par bounded context, une base par service, des événements Kafka entre les services.

Solution technique : la migration en 8 étapes

Étape 1 — Migrer progressivement avec le Strangler Fig

Plutôt qu’une réécriture complète, on place une façade (l’API Gateway) devant le monolithe, puis on extrait les fonctionnalités une à une. Chaque route basculée vers un nouveau service est un petit déploiement réversible. Le monolithe « s’étiole » jusqu’à disparaître, comme l’arbre étouffé par le figuier étrangleur qui a donné son nom au pattern (Martin Fowler).

Migration progressive avec le pattern Strangler Fig Phase 1 : la gateway route tout le trafic vers le monolithe. Phase 2 : les routes commandes et paiement sont basculées vers de nouveaux services. Phase 3 : le monolithe est décommissionné, il ne reste que des services. PHASE 1 · FAÇADEPHASE 2 · EXTRACTIONPHASE 3 · DÉCOMMISSION API GatewayAPI GatewayAPI Gateway Monolithe User · Order · Payment · Catalog Monolithe User · Catalog Order Payment User Order Payment Catalog Tout le trafic passe par la façadeLes routes basculent une à uneLe monolithe disparaît
Strangler Fig : chaque extraction est un petit déploiement réversible, jamais un big bang.

Commencez par un contexte à forte valeur et faible couplage (souvent le paiement ou le catalogue), pas par le cœur le plus enchevêtré.

Étape 2 — Découper selon les bounded contexts (DDD)

Un service correspond à un bounded context, pas à une entité ou à une table :

  • Identité (User Service) : profils, rôles, préférences.
  • Commande (Order Service) : panier, commandes, statuts.
  • Paiement (Payment Service) : autorisations, remboursements.

Règle non négociable : une base de données par service. Un service ne lit jamais les tables d’un autre ; il passe par son API ou consomme ses événements. Un atelier d’Event Storming avec le métier est le moyen le plus rapide d’identifier ces frontières.

Étape 3 — Choisir le bon mode de communication

BesoinModeTechnologieExemple
Réponse immédiate à l’utilisateurSynchroneREST ou gRPCAfficher le profil client
Notifier d’autres services d’un fait métierAsynchroneKafka (événements)OrderPlaced
Traitement long ou en lotAsynchroneKafka ou RabbitMQGénération de factures
Transaction sur plusieurs servicesAsynchroneSaga + événementsCommande → paiement → stock

Par défaut, privilégiez l’asynchrone entre services : un service indisponible ne bloque plus toute la chaîne. Gardez le synchrone pour les lectures dont l’utilisateur attend la réponse, toujours protégées par un timeout et un circuit breaker.

Étape 4 — Publier des événements fiables avec l’Outbox

Le piège classique : sauvegarder la commande en base, puis appeler kafkaTemplate.send(). Si l’application tombe entre les deux, la commande existe mais l’événement est perdu. Le Transactional Outbox résout ce problème : l’événement est écrit dans une table outbox dans la même transaction que la commande, puis un relais le publie dans Kafka.

Pattern Transactional Outbox L’Order Service écrit la commande et l’événement dans la même transaction. Un relais lit la table outbox et publie dans Kafka. Les consommateurs traitent les événements de façon idempotente. Order Service @Transactional 1 TRANSACTION ACID orders outbox Relais Debezium (CDC) ou poller Kafka orders.events ①②③ ④ Payment, Stock… consomment livraison « at-least-once » → consommateurs idempotents
Outbox : ① écriture atomique commande + événement, ② le relais lit la table, ③ publication Kafka, ④ consommation idempotente.

La table outbox :

CREATE TABLE outbox (
  id            UUID         PRIMARY KEY,
  aggregate_id  VARCHAR(64)  NOT NULL,
  event_type    VARCHAR(128) NOT NULL,
  payload       JSONB        NOT NULL,
  created_at    TIMESTAMPTZ  NOT NULL DEFAULT now(),
  published_at  TIMESTAMPTZ
);

Le service métier écrit la commande et l’événement dans une seule transaction :

@Service
public class OrderService {

    private final OrderRepository orders;
    private final OutboxRepository outbox;

    public OrderService(OrderRepository orders, OutboxRepository outbox) {
        this.orders = orders;
        this.outbox = outbox;
    }

    @Transactional
    public Order placeOrder(PlaceOrderCommand cmd) {
        Order order = orders.save(Order.create(cmd));
        outbox.save(OutboxEvent.of(order.getId(), "OrderPlaced",
                new OrderPlaced(order.getId(), order.getCustomerId(), order.getTotal())));
        return order;
    }
}

Le relais publie les événements en attente. La clé Kafka est l’identifiant de la commande : tous les événements d’une même commande arrivent dans la même partition, donc dans l’ordre.

@Component
public class OutboxRelay {

    private final OutboxRepository outbox;
    private final KafkaTemplate<String, String> kafka;

    public OutboxRelay(OutboxRepository outbox, KafkaTemplate<String, String> kafka) {
        this.outbox = outbox;
        this.kafka = kafka;
    }

    @Scheduled(fixedDelay = 500)
    @Transactional
    public void publishPending() {
        // SELECT ... FOR UPDATE SKIP LOCKED : plusieurs instances sans doublon de traitement
        for (OutboxEvent e : outbox.lockNextBatch(100)) {
            ProducerRecord<String, String> record =
                    new ProducerRecord<>("orders.events", e.getAggregateId(), e.getPayload());
            record.headers().add("eventId", e.getId().toString().getBytes(StandardCharsets.UTF_8));
            record.headers().add("eventType", e.getEventType().getBytes(StandardCharsets.UTF_8));
            kafka.send(record).join();
            e.markPublished();
        }
    }
}

En production, Debezium (Change Data Capture) et son Outbox Event Router remplacent avantageusement le poller : pas de requêtes périodiques, latence plus faible.

Comme la livraison est « at-least-once », un consommateur peut recevoir deux fois le même événement. Il doit donc être idempotent :

@Component
public class OrderEventsListener {

    private final ProcessedEventRepository processed;
    private final PaymentService payments;

    public OrderEventsListener(ProcessedEventRepository processed, PaymentService payments) {
        this.processed = processed;
        this.payments = payments;
    }

    @KafkaListener(topics = "orders.events", groupId = "payment-service")
    @Transactional
    public void on(ConsumerRecord<String, String> record) {
        String eventId = new String(record.headers().lastHeader("eventId").value(), StandardCharsets.UTF_8);
        if (processed.existsById(eventId)) {
            return; // doublon : déjà traité
        }
        payments.authorize(record.value());
        processed.save(new ProcessedEvent(eventId));
    }
}

Étape 5 — Remplacer les transactions distribuées par une Saga

Pas de transaction XA/2PC entre services. Une Saga enchaîne des transactions locales ; si une étape échoue, des actions de compensation annulent les étapes précédentes. En chorégraphie, chaque service réagit aux événements des autres ; en orchestration, un orchestrateur central pilote les étapes (plus lisible au-delà de 4 ou 5 étapes).

Saga, Outbox, Circuit breaker : ce sont des design patterns appliqués à l’échelle d’un système distribué. Pour revoir les fondamentaux, lisez Design patterns : la base de tout développement logiciel robuste.

Saga en chorégraphie avec compensation Flux nominal : OrderPlaced, PaymentAuthorized puis StockReserved confirment la commande. En cas de rupture de stock, OutOfStock déclenche le remboursement, puis PaymentRefunded annule la commande. Order Service Payment Service Stock Service ① OrderPlaced ② PaymentAuthorized ③ StockReserved → commande CONFIRMÉE ✗ OutOfStock → remboursement PaymentRefunded → commande ANNULÉE flux nominalcompensation
Saga chorégraphiée : chaque échec déclenche une compensation, la cohérence est « éventuelle » mais garantie.

Étape 6 — Une API Gateway comme point d’entrée unique

La gateway centralise l’authentification (OAuth2/JWT), le routage, le rate limiting et les fallbacks. Avec Spring Cloud Gateway :

spring:
  cloud:
    gateway:
      server:
        webflux:                 # Spring Cloud 2025+ (avant : spring.cloud.gateway.routes)
          routes:
            - id: order-service
              uri: http://order-service:8080
              predicates:
                - Path=/api/orders/**
              filters:
                - StripPrefix=1
                - name: CircuitBreaker
                  args:
                    name: ordersCB
                    fallbackUri: forward:/fallback/orders
                - name: RequestRateLimiter
                  args:
                    redis-rate-limiter.replenishRate: 50
                    redis-rate-limiter.burstCapacity: 100

À noter : depuis Spring Cloud 2025, les propriétés de la gateway sont préfixées par spring.cloud.gateway.server.webflux (ou webmvc). Le RequestRateLimiter nécessite Redis et un bean KeyResolver (par utilisateur ou par IP).

Étape 7 — Rendre chaque appel synchrone résilient

Tout appel réseau finira par échouer. Avec Resilience4j, on combine timeout, retry et circuit breaker, avec un fallback dégradé plutôt qu’une erreur en cascade :

@Service
public class UserProfileClient {

    private final RestClient restClient;
    private final ProfileCache cache;

    public UserProfileClient(RestClient.Builder builder, ProfileCache cache) {
        this.restClient = builder.baseUrl("http://user-service:8080").build();
        this.cache = cache;
    }

    @CircuitBreaker(name = "userProfile", fallbackMethod = "cachedProfile")
    @Retry(name = "userProfile")
    public UserProfile fetch(UUID userId) {
        return restClient.get().uri("/users/{id}", userId).retrieve().body(UserProfile.class);
    }

    private UserProfile cachedProfile(UUID userId, Throwable cause) {
        return cache.getOrDefault(userId, UserProfile.anonymous(userId));
    }
}
resilience4j:
  circuitbreaker:
    instances:
      userProfile:
        sliding-window-size: 20
        failure-rate-threshold: 50
        wait-duration-in-open-state: 10s
  retry:
    instances:
      userProfile:
        max-attempts: 3
        wait-duration: 200ms

Sur Kubernetes, un service mesh comme Istio peut porter ces politiques au niveau réseau, sans code applicatif.

Étape 8 — Observabilité et déploiement

Sans observabilité, un système distribué est une boîte noire. Les trois piliers :

  • Traces distribuées : OpenTelemetry (via Micrometer Tracing dans Spring Boot) exporte vers Jaeger ou Grafana Tempo ; chaque requête est suivie de la gateway jusqu’au consommateur Kafka.
  • Métriques : Prometheus + Grafana (latence p95, taux d’erreur, lag des consumers Kafka).
  • Logs centralisés : ELK ou Loki, toujours avec le traceId pour corréler.
# application.yml (Spring Boot 3.x)
management:
  tracing:
    sampling:
      probability: 0.1
  otlp:
    tracing:
      endpoint: http://otel-collector:4318/v1/traces
  endpoints:
    web:
      exposure:
        include: health,prometheus

Côté déploiement : une image Docker par service, Kubernetes pour le scaling et l’auto-réparation, des probes liveness/readiness (exposées par Spring Boot Actuator), et une pipeline CI/CD par service avec tests de contrat (Spring Cloud Contract ou Pact) pour livrer sans casser les consommateurs.


Mesurer les résultats : les indicateurs DORA

Le succès d’une migration ne se mesure pas au nombre de services, mais à la capacité de livrer vite et sans casse. Mesurez avant la migration pour avoir une base de comparaison.

Indicateur DORACe qu’il mesureCe que les microservices doivent améliorer
Fréquence de déploiementCombien de mises en production par équipeChaque équipe déploie seule, à la demande
Délai de mise en productionDu commit à la productionPipelines courtes, par service
Taux d’échec des changementsPart des déploiements qui causent un incidentDéploiements plus petits, donc moins risqués
Temps de restaurationDurée pour rétablir le service après incidentPanne isolée, rollback d’un seul service

Complétez avec des métriques techniques par service : latence p95, taux d’erreur, disponibilité (SLO) et lag des consumers Kafka.


Bonnes pratiques à appliquer

  • Gouvernance API : contrats versionnés et documentés (OpenAPI pour REST, AsyncAPI ou Schema Registry pour Kafka).
  • Asynchrone par défaut entre services, synchrone uniquement pour les lectures temps réel.
  • Jamais de transaction distribuée : Saga + Outbox + consommateurs idempotents.
  • Une base par service, sans exception.
  • CI/CD indépendante par service, avec tests de contrat.
  • Taille des services : assez petits pour qu’une équipe les maîtrise, assez grands pour porter une capacité métier complète.
  • Sécurité dès le départ : JWT validé à la gateway, mTLS entre services, secrets hors du code.

Quand ne PAS passer aux microservices ?

  • Une seule petite équipe (moins de 10 développeurs) sur un seul produit.
  • Un domaine métier encore flou, qui change chaque mois.
  • Pas encore de CI/CD automatisée ni de supervision.

Dans ces cas, un monolithe modulaire bien découpé (modules alignés sur les bounded contexts) offre l’essentiel des bénéfices, et prépare une extraction future.


Références


FAQ

Peut-on faire des microservices sans DDD ?

Techniquement oui, mais sans bounded contexts clairs, on obtient presque toujours un « monolithe distribué » : des services qui s’appellent en permanence et doivent être déployés ensemble. C’est pire qu’un monolithe classique.

Comment gérer une transaction qui touche plusieurs services ?

Avec le pattern Saga : une suite de transactions locales coordonnées par des événements (chorégraphie) ou par un orchestrateur, avec des actions de compensation en cas d’échec. Combinez-le avec l’Outbox pour ne perdre aucun événement.

Pourquoi ne pas simplement appeler kafkaTemplate.send() après l’enregistrement en base ?

Parce que les deux opérations ne sont pas atomiques. Si l’application tombe entre l’écriture en base et l’envoi, l’événement est perdu. Le pattern Transactional Outbox écrit l’événement dans la même transaction que la donnée métier.

Les microservices sont-ils adaptés à toutes les entreprises ?

Non. Pour une startup ou une petite équipe, un monolithe modulaire est souvent préférable. Les microservices deviennent rentables quand plusieurs équipes doivent livrer indépendamment.

Par où commencer la migration d’un monolithe ?

Placez une API Gateway devant le monolithe (Strangler Fig), mesurez les indicateurs DORA, puis extrayez un premier contexte à forte valeur et faible couplage, comme le paiement ou le catalogue.

Quelle stack technique recommander ?

Backend : Spring Boot, Quarkus ou Micronaut. Communication : REST ou gRPC pour le synchrone, Kafka pour les événements. Infrastructure : Docker, Kubernetes, éventuellement Istio. Observabilité : OpenTelemetry, Prometheus, Grafana, Jaeger ou Tempo.


Pour aller plus loin

Kafka, Docker et Spring Boot sont au cœur de cette architecture. Nos formations Java, Spring Boot, Kafka et Docker vous font construire ces patterns pas à pas, cluster Kafka local inclus.

Une leçon, un kata, chaque mois

Les nouveaux articles et un exercice de refactoring, directement par e-mail.

S'inscrire à la newsletter