Monolithe, modulaire, microservices

C’est la première question qui dépasse le périmètre d’une application, et celle qui est le plus systématiquement mal posée. « Faut-il faire des microservices ? » n’a pas de réponse, parce que ce n’est pas une question d’architecture : c’est une question de topologie de déploiement.

La décision de modélisation — où passent les frontières du métier — est un sujet distinct, traité par les contextes délimités. Cette page traite de ce qui vient après : quelles frontières déjà identifiées méritent de devenir des unités de déploiement séparées, et à quel prix.

Les quatre formes

MonolitheMonolithe modulaireMicroservicesMonolithe distribué
Unités de déploiement11NN
Frontières internesFloues ou absentesExplicites et vérifiéesLe réseauFloues
Base de donnéesUne, partagéeUne instance, schémas séparésUne par servicePartagée
Appel entre modulesAppel de fonctionAppel de fonction via une API de moduleAppel réseauAppel réseau
Déplacer une frontièreFacileFacileTrès coûteuxTrès coûteux
Défaillance partielleImpossibleImpossiblePermanentePermanente
VerdictDéfaut acceptableLe bon défautSur besoin nomméÀ éviter absolument

La quatrième colonne est le résultat le plus fréquent d’une migration mal conduite : on a payé le prix du réseau — latence, pannes partielles, complexité d’exploitation — sans obtenir le bénéfice, puisque les services restent couplés par un modèle ou une base commune. On déploie séparément des choses qui doivent changer ensemble.

Le monolithe n’est pas le problème

Le mot est devenu une insulte, et c’est une confusion qu’il faut défaire d’emblée : ce qui est mauvais, ce n’est pas le monolithe, c’est le monolithe sans structure interne — la grande boule de boue.

Ce sont deux choses différentes, et les avoir confondues est ce qui a vendu les microservices à des équipes qui n’en avaient pas besoin. Le raisonnement implicite était : notre code est un plat de spaghettis, donc découpons-le en services. Or distribuer des spaghettis produit des spaghettis distribués — avec de la latence en prime. La discipline qui manquait manquera toujours après.

Un monolithe avec des frontières internes réelles est une excellente architecture. C’est même la seule qui permette de se tromper sur l’emplacement d’une frontière sans que cela coûte un trimestre.

Le monolithe modulaire

C’est la thèse de cette page : c’est le bon défaut, et la grande majorité des systèmes n’ont jamais besoin d’en sortir.

Une seule unité de déploiement, N modules dont les frontières sont aussi réelles que celles de services — mais franchies par un appel de fonction plutôt que par le réseau. On obtient l’essentiel du bénéfice de la séparation, sans en payer le prix.

Quatre conditions le rendent réel. Aucune n’est optionnelle : il suffit qu’une seule manque pour retomber sur un monolithe ordinaire avec des dossiers bien nommés.

Une API de module explicite. Chaque module expose une surface publique minuscule ; tout le reste est inaccessible depuis l’extérieur. C’est le point le plus important, et le seul qui demande un mécanisme de langage.

Des schémas de base séparés. Une seule instance PostgreSQL, mais un schéma par module, et aucune jointure inter-schémas. C’est la condition qu’on sacrifie le plus souvent, et sa violation est irréversible en pratique : une fois que trois rapports font des jointures entre modules, la frontière n’existe plus.

Aucune référence directe aux entités d’un autre module. On échange des identifiants et des structures de données plates, jamais des objets du domaine voisin.

Une frontière vérifiée par la CI, pas par la discipline — voir les tests d’architecture.

L’API de module

​
// ───────── modules/facturation/index.ts ─────────
// LE SEUL fichier importable depuis l'extérieur du module.

export type { FactureEmise } from './api/types';
export { FacturationApi } from './api/facturation-api';

// Rien d'autre n'est exporté : ni les entités, ni les dépôts, ni les cas d'usage.

// ───────── modules/facturation/api/facturation-api.ts ─────────
export class FacturationApi {
  constructor(private readonly emettre: EmettreFacture) {}

  // Signature volontairement « distante » : données plates, résultat explicite
  async emettreFacture(cmd: {
    commandeId: string;
    clientId: string;
    montantCentimes: number;
  }): Promise<{ factureId: string } | { erreur: 'client-inconnu' | 'montant-invalide' }> {
    /* traduit vers le domaine, appelle le cas d'usage, retraduit */
  }
}

// ───────── modules/expedition/… ─────────
import { FacturationApi } from '../facturation';          // ✅ la façade
import { Facture } from '../facturation/domaine/facture';  // ❌ interdit par la CI

La frontière est portée par les exports du package.json du module, dans un workspace npm ou pnpm, et vérifiée par dependency-cruiser.

# ───────── modules/facturation/__init__.py ─────────
# LE SEUL module importable depuis l'extérieur.

from .api import FacturationApi, FactureEmise

__all__ = ["FacturationApi", "FactureEmise"]

# Rien d'autre : ni les entités, ni les dépôts, ni les cas d'usage.


# ───────── modules/facturation/api.py ─────────
from dataclasses import dataclass


@dataclass(frozen=True)
class DemandeFacture:          # Données plates, comme si elles arrivaient du réseau
    commande_id: str
    client_id: str
    montant_centimes: int


class FacturationApi:
    def __init__(self, emettre: EmettreFacture) -> None:
        self._emettre = emettre

    def emettre_facture(self, demande: DemandeFacture) -> FactureEmise | ErreurFacture:
        """Traduit vers le domaine, appelle le cas d'usage, retraduit."""


# ───────── modules/expedition/… ─────────
from modules.facturation import FacturationApi              # ✅ la façade
from modules.facturation.domaine.facture import Facture     # ❌ refusé par
                                                            #    import-linter

Python n’offre aucune visibilité inter-paquets : la frontière tient entièrement à import-linter, dont les contrats déclarent que modules.expedition ne peut atteindre que modules.facturation — et non ses sous-paquets.

// ───────── crates/facturation/src/lib.rs ─────────
// LA seule surface publique du module.

mod domaine;          // privé : invisible depuis l'extérieur du crate
mod application;      // privé
mod infrastructure;   // privé

pub mod api;          // public : la façade
pub use api::{FacturationApi, FactureEmise, ErreurFacture};

// ───────── crates/facturation/src/api.rs ─────────
pub struct DemandeFacture {       // Données plates, comme venues du réseau
    pub commande_id: String,
    pub client_id: String,
    pub montant_centimes: i64,
}

impl FacturationApi {
    pub fn emettre_facture(
        &self,
        demande: DemandeFacture,
    ) -> Result<FactureEmise, ErreurFacture> {
        // traduit vers le domaine, appelle le cas d'usage, retraduit
        todo!()
    }
}

// ───────── crates/expedition/src/… ─────────
use facturation::FacturationApi;              // ✅ la façade
use facturation::domaine::Facture;            // ❌ ne compile pas : mod privé

C’est la version la plus solide des trois : la frontière est garantie par le compilateur, sans configuration ni linter. Un mod privé n’est pas atteignable, point final.

Tip

Le détail qui rend l’extraction future presque gratuite : donner à l’API de module une signature de type distant dès le départ. Données plates en entrée, résultat explicite en sortie — pas d’objet du domaine, pas d’exception qui traverse, pas de transaction partagée. Le jour où ce module devient un service, l’appelant ne change pas : seule l’implémentation de la façade devient un client HTTP.

C’est aussi un coût — on écrit une traduction pour un appel en mémoire — et il ne se justifie que sur les modules dont on pense sérieusement qu’ils partiront un jour.

L’arborescence

crates/ ou packages/ ou modules/
  facturation/
    api/                 la façade — le seul point d'entrée public
    domaine/             privé
    application/         privé
    infrastructure/      privé, schéma SQL `facturation`
  expedition/
    api/
    domaine/
    ...
  partage/               identifiants et objets valeur vraiment universels
  app/                   racine de composition, serveur HTTP, une seule sortie

Chaque module est un hexagone complet, avec en plus une façade publique. Le dossier partage/ est à surveiller de près : c’est un noyau partagé, légitime pour des identifiants, désastreux dès qu’il contient des règles.

Ce que le réseau change

Voici le prix réel, et c’est la section qui devrait décider à elle seule. Chaque ligne est une propriété qu’un appel de fonction offre gratuitement et qu’un appel réseau facture.

Appel de fonctionAppel réseau
RésultatIl retourne ou il lèveIl peut expirer alors que le travail a été fait
AtomicitéUne transaction couvre l’ensembleAucune. Compensation ou cohérence différée
LatenceNanosecondesMillisecondes, qui s’additionnent
DébogageUne pile d’appelsUne trace distribuée à corréler
DéploiementAtomiqueCompatibilité ascendante obligatoire
TypesVérifiés à la compilationUn contrat à versionner et à tester
Données du voisinUne lectureUne copie locale, donc périmée
Refactoring inter-moduleUn renommageUne migration en deux temps

Trois conséquences méritent d’être détaillées, parce qu’elles surprennent toujours.

L’expiration ambiguë. Un appel réseau qui échoue ne dit pas si le travail a été effectué. Toute opération devient donc obligatoirement idempotente, ou bien on accepte des doubles facturations. Ce n’est pas une optimisation, c’est un prérequis, et cela change la signature de chaque opération.

La disponibilité se multiplie. Une chaîne de cinq appels synchrones, chacun à 99,9 % de disponibilité, donne 99,5 % au bout de la chaîne — soit 3 h 36 par mois d’indisponibilité au lieu de 43 minutes. C’est le calcul qui condamne les chaînes d’appels synchrones, et il n’a rien d’un cas pathologique : on l’atteint avec une passerelle, deux services métier et un service d’authentification.

Le déploiement cesse d’être atomique. On ne peut plus renommer un champ : il faut l’ajouter, migrer les producteurs, migrer les consommateurs, puis retirer l’ancien — quatre déploiements ordonnés au lieu d’un commit. Tout changement de contrat devient un projet en deux temps.

Les trois seules raisons légitimes

L’échelle indépendante. Une partie du système a besoin de quarante machines et une autre de deux. Raison réelle, mais bien plus rare qu’annoncée : un monolithe derrière un répartiteur de charge se réplique horizontalement très loin, et la mémoire gaspillée par les modules inactifs coûte moins cher que l’exploitation de douze services.

La cadence de déploiement indépendante. Une équipe doit livrer dix fois par jour, une autre une fois par trimestre après revue réglementaire. C’est la raison la plus souvent authentique, et celle qu’on invoque le moins.

L’autonomie d’équipe. Au-delà d’un certain nombre de personnes, la coordination sur une unité de déploiement unique devient le goulet d’étranglement : conflits de fusion, tests bloqués, livraisons qui s’attendent. C’est la loi de Conway prise par le bon bout — on change les frontières du logiciel pour refléter celles des équipes.

Warning

Et les raisons qui n’en sont pas, énoncées franchement.

« C’est l’état de l’art. » Un argument d’autorité, pas une analyse.

« Pour découpler le code. » Un module fait cela, mieux et pour beaucoup moins cher. Le réseau n’est pas un outil de découplage : c’est un outil de découplage du déploiement.

« Pour utiliser plusieurs langages. » Bénéfice quasi nul, coût réel : deux chaînes de construction, deux écosystèmes de bibliothèques, deux profils de recrutement. Se justifie sur un besoin très spécifique, jamais par principe.

« Parce que notre monolithe est illisible. » Le découpage n’ajoute pas la discipline qui manquait. Modularisez d’abord ; si vous n’y arrivez pas dans un seul processus, vous n’y arriverez pas à travers un réseau.

« Pour recruter. » Cela existe, ce n’est pas une décision d’architecture.

À partir de quelle taille

Le facteur déterminant n’est ni le nombre de lignes, ni le trafic : c’est le nombre d’équipes.

ÉquipesVerdict
1 – 3Monolithe modulaire, sans discussion. Distribuer ici n’apporte rien
4 – 8Monolithe modulaire, plus un ou deux services extraits sur un besoin nommé
Plus de 8La distribution répond à un problème d’organisation réel

Le nombre de lignes n’entre pas dans le tableau, et c’est volontaire : un monolithe modulaire de plusieurs millions de lignes fonctionne parfaitement s’il est correctement cloisonné, tandis que trois services sur deux développeurs sont une punition quotidienne.

Les quatre prérequis

Avant d’extraire le premier service, ces quatre éléments doivent déjà exister. S’il en manque un seul, n’extrayez rien : le service coûtera plus qu’il ne rapporte.

  1. Un pipeline CI/CD entièrement automatisé, capable de livrer un service sans intervention manuelle.
  2. De l’observabilité distribuée : traces corrélées, journaux agrégés, métriques par service. Sans elle, le premier incident inter-services est inexplicable.
  3. Une politique de contrat écrite : versionnement, compatibilité ascendante, tests de contrat.
  4. Quelqu’un dont c’est le travail d’exploiter tout cela.

Il faut compter plusieurs mois de travail de plateforme avant que le premier service soit rentable. C’est ce délai, jamais budgété, qui fait échouer la plupart des migrations.

Le chemin : extraire, jamais découper d’emblée

L’ordre suivant est celui qui échoue le moins souvent, et il commence loin du réseau.

  1. Identifier les contextes — travail de modélisation, pas de découpage. Voir Contextes délimités.
  2. Modulariser dans le monolithe : un module par contexte, une façade publique, frontière vérifiée en CI.
  3. Séparer les schémas de base et supprimer les jointures inter-modules. C’est l’étape la plus longue et la plus utile — elle donne à elle seule l’essentiel du bénéfice.
  4. Faire passer tous les appels inter-modules par les façades, avec des signatures de type distant.
  5. Nommer le besoin : échelle, cadence ou autonomie. Sans besoin nommé, on s’arrête ici — et c’est un très bon endroit pour s’arrêter.
  6. Extraire un seul module, celui qui porte ce besoin. La façade devient un client HTTP ; le code appelant ne change pas.
  7. Mesurer, puis s’arrêter si le besoin est satisfait.

L’étape 6 est presque gratuite si les étapes 2 à 4 ont été faites, et c’est tout l’intérêt de l’ordre : le travail de modularisation n’est pas une préparation à la distribution, c’est le gros du bénéfice, et la distribution n’en est qu’un prolongement optionnel.

Note

Le mouvement inverse — reconsolider — est légitime et scandaleusement sous-utilisé. Fusionner deux services qui changent toujours ensemble, ou ramener dans le monolithe un service dont le besoin a disparu, est une amélioration, pas un aveu. L’équipe de Prime Video l’a documenté publiquement en 2023 sur un service de surveillance audio/vidéo, avec 90 % de coût en moins après retour à une architecture monolithique.

Ce que l’extraction change dans le code

​
// L'appelant, AVANT extraction — et APRÈS : identique.
const resultat = await this.facturation.emettreFacture({
  commandeId: commande.id.valeur,
  clientId: commande.clientId.valeur,
  montantCentimes: commande.totalTTC().enCentimes(),
});

// Seule l'implémentation de la façade change.
// Avant : appel en mémoire
export class FacturationApi {
  async emettreFacture(cmd: DemandeFacture): Promise<Resultat> {
    return this.emettre.executer(cmd);
  }
}

// Après : client HTTP + ce que le réseau impose
export class FacturationApiHttp implements FacturationApi {
  async emettreFacture(cmd: DemandeFacture): Promise<Resultat> {
    return this.http.post('/factures', cmd, {
      timeout: 2000,                              // Nouveau
      idempotencyKey: cmd.commandeId,             // Nouveau, obligatoire
      retry: { tentatives: 3, backoff: 'exponentiel' },  // Nouveau
    });
  }
}
# L'appelant, AVANT extraction — et APRÈS : identique.
resultat = self._facturation.emettre_facture(
    DemandeFacture(
        commande_id=commande.id.valeur,
        client_id=commande.client_id.valeur,
        montant_centimes=commande.total_ttc().en_centimes(),
    )
)


# Seule l'implémentation de la façade change.
# Avant : appel en mémoire
class FacturationApi:
    def emettre_facture(self, demande: DemandeFacture) -> Resultat:
        return self._emettre.executer(demande)


# Après : client HTTP + ce que le réseau impose
class FacturationApiHttp:
    def emettre_facture(self, demande: DemandeFacture) -> Resultat:
        return self._http.post(
            "/factures",
            json=asdict(demande),
            timeout=2.0,                                  # Nouveau
            headers={"Idempotency-Key": demande.commande_id},  # Nouveau, obligatoire
        )                                                 # + reprises en amont
// L'appelant, AVANT extraction — et APRÈS : identique.
let resultat = self.facturation.emettre_facture(DemandeFacture {
    commande_id: commande.id().to_string(),
    client_id: commande.client_id().to_string(),
    montant_centimes: commande.total_ttc().en_centimes(),
})?;

// La façade devient un trait, et l'implémentation change.
pub trait Facturation {
    fn emettre_facture(&self, d: DemandeFacture) -> Result<FactureEmise, ErreurFacture>;
}

// Avant : appel en mémoire
impl Facturation for FacturationLocale {
    fn emettre_facture(&self, d: DemandeFacture) -> Result<FactureEmise, ErreurFacture> {
        self.emettre.executer(d)
    }
}

// Après : client HTTP + ce que le réseau impose
impl Facturation for FacturationHttp {
    fn emettre_facture(&self, d: DemandeFacture) -> Result<FactureEmise, ErreurFacture> {
        self.client
            .post("/factures")
            .timeout(Duration::from_secs(2))              // Nouveau
            .header("Idempotency-Key", &d.commande_id)    // Nouveau, obligatoire
            .json(&d)
            .send()
            .map_err(ErreurFacture::Technique)?           // Nouveau : l'erreur
            .json()                                       // technique existe
            .map_err(ErreurFacture::Technique)
    }
}

Le code appelant ne bouge pas — c’est le retour sur investissement des étapes 2 à 4. Mais notez ce qui apparaît dans la façade : un délai d’attente, une clé d’idempotence, une politique de reprise, une erreur technique qui n’existait pas. Ces quatre lignes sont le prix du réseau, rendu visible.

Comment les services communiquent

Sujet qui mériterait sa propre page ; l’essentiel tient en une règle.

Synchrone — REST, gRPCAsynchrone — messages, événements
Convient àUne question dont on a besoin de la réponse maintenantLa propagation d’un fait déjà survenu
CoupleLa disponibilité : si B tombe, A tombeRien, au prix d’un délai
ImposeDes délais d’attente, des reprises, des disjoncteursLa cohérence différée, l’idempotence, l’ordre non garanti
DébogageSimple : une traceDifficile : plus de fil d’exécution unique

La règle : synchrone pour interroger, asynchrone pour notifier. Un service qui appelle synchroniquement pour annoncer quelque chose a choisi le mauvais style, et il vient d’attacher sa disponibilité à celle de son voisin sans nécessité.

Côté publication, le problème de la double écriture — enregistrer en base et publier sur le bus, sans atomicité — et sa solution, le patron outbox, sont traités dans Événements de domaine.

Les pièges

Le monolithe distribué. N services, un modèle ou une base partagés. Tout le coût, aucun bénéfice. C’est le résultat par défaut d’un découpage fait avant la modularisation.

Le service par entité. ServiceClient, ServiceAdresse, ServiceContact : trois services pour ce qui est un seul agrégat. Chaque cas d’usage devient une chorégraphie réseau, et une transaction devient impossible. Un service correspond à un contexte, pas à une table.

Les transactions distribuées. Dès qu’on cherche un commit en deux phases, le diagnostic est posé : la frontière est au mauvais endroit. La réponse n’est pas un outil, c’est de déplacer la frontière — ou d’accepter la cohérence différée.

Le service partagé que tout le monde appelle. Extraire « le service Client » que les huit autres interrogent crée un point de défaillance unique et un goulet d’étranglement organisationnel : plus personne ne livre sans son accord.

L’absence de politique de contrat. Sans versionnement ni tests de contrat, chaque déploiement est un pari. C’est le premier prérequis à sauter, et le premier à manquer.

Pas de retour en arrière prévu. Une extraction doit pouvoir être annulée. Si la façade a disparu et que quinze appelants tapent directement sur l’API HTTP, c’est devenu impossible.

Comparaison

MonolitheModulaireMicroservices
Coût d’entréeNulFaibleÉlevé : plateforme, observabilité, contrats
Déplacer une frontièreFacileFacileTrès coûteux
Tester de bout en boutTrivialTrivialDifficile
DéboguerUne pile d’appelsUne pile d’appelsTraces distribuées
TransactionsACID partoutACID partoutCompensation
Échelle sélectiveNonNonOui
Cadence indépendanteNonNonOui
Autonomie d’équipeFaibleMoyenneÉlevée
Nombre d’équipes visé1 – 21 – 8Plus de 8
Défaillance partielleImpossibleImpossiblePermanente

Le tableau se lit ligne par ligne, et non colonne par colonne : les trois avantages en gras de la dernière colonne sont les seuls que la distribution apporte. Si aucun des trois n’est un besoin nommé et daté, la colonne du milieu gagne.

Références