Architecture

Monolithe Modulaire

Un monolithe modulaire est une unité déployable avec des frontières internes strictes. Vous conservez les appels en cours de processus, une seule transaction et un seul pipeline, tandis que chaque module métier possède ses propres données et expose une interface publique restreinte — permettant ainsi de le transformer en service plus tard si nécessaire.

intermediate14 min readUpdated 16 sept. 2026
src/modules/billing/index.ts
ts
// src/modules/billing/index.ts
export type { Invoice, InvoiceId } from "./domain/invoice.js";
export { BillingService } from "./application/billing-service.js";
export { onOrderPlaced } from "./application/order-handlers.js";

// Everything under ./domain and ./infra stays private.
// Other modules import from this file and nothing deeper.
Déploiement
Une seule unité déployable
Frontières
Modules par capacité
Données
Une base de données, un propriétaire par table
Communication
Interfaces et événements in-process
Super-pouvoir
Transactions ACID
Mode de défaillance
Big ball of mud

Pourquoi c'est important

Pourquoi le monolithe modulaire est le choix par défaut logique

Un seul déploiement, un seul pipeline

Un seul build, une seule suite de tests et un seul rollback. Il n'y a pas d'orchestration, pas de service discovery et pas de réseau entre les modules.

Des jointures internes fortes

Chaque module métier possède son domaine et ses données, et expose une interface publique étroite. Les frontières sont appliquées par des outils, et non par de bonnes intentions.

Un chemin vers l'extraction

Parce que les modules communiquent via des interfaces et des événements, n'importe lequel d'entre eux peut devenir son propre service plus tard sans avoir à réécrire les autres.

Le tableau complet

Les trois règles qui maintiennent un monolithe modulaire

Les modules suivent les capacités métier, chacun possède ses données, et ils communiquent uniquement via une interface publique ou un événement.

Module

Capacité

Un module par capacité métier, contenant son propre code de domaine, d'application et d'infrastructure derrière une surface publique.

Propriété des données

Isolation

Une seule base de données, mais chaque module écrit uniquement dans ses propres tables ou son propre schéma. Personne ne fait de jointures à travers les frontières de modules.

Interface

Contrat

Les modules appellent une interface publiée ou publient un événement. L'interne est privé, c'est ce qui rend la frontière réelle.

HTML5 en un coup d'oeil

À quoi ressemblent de bonnes frontières de module

Modules

Des dossiers comme billing, catalog et shipping, chacun étant autonome.

Interface publique

Un seul fichier index est la seule chose que les autres modules peuvent importer.

Tables propriétaires

Un module est le seul écrivain de ses tables ou de son schéma.

Événements de domaine

Un module annonce des faits pour que les autres puissent réagir sans appel direct.

Graphe acyclique

Les dépendances pointent dans un seul sens et ne bouclent jamais.

Frontières appliquées

Les règles de lint font échouer le build lorsqu'un module tente d'accéder à l'interne d'un autre.

Flux

Le flux d'une requête à travers les modules

Tout se passe dans un seul processus, donc le cas courant est une transaction unique et un appel de fonction direct plutôt qu'un saut réseau.

  1. 1

    La couche HTTP mappe la requête

    Un contrôleur valide l'entrée et la traduit en un appel vers le module propriétaire. Il ne contient aucune règle métier propre.

  2. 2

    Le module propriétaire gère le cas d'utilisation

    Son service d'application charge l'agrégat, applique les règles et décide des changements. C'est là que réside le domaine.

  3. 3

    Le module utilise ses propres données

    Il lit et écrit via son propre repository, ne touchant que les tables qu'il possède. Aucune table d'un autre module n'est impliquée.

  4. 4

    Il appelle l'interface d'un autre module

    S'il a besoin d'un fait ou d'une action provenant d'une autre capacité, il appelle le service public de ce module ou émet un événement de domaine.

  5. 5

    La transaction est validée

    Lorsque le travail reste à l'intérieur d'un seul module, il s'agit d'une seule transaction ACID. La cohérence entre modules utilise des événements et un outbox.

  6. 6

    La réponse est retournée

    Le contrôleur mappe le résultat vers HTTP. Pas de frontière de sérialisation, pas de timeouts, pas de panne partielle entre les modules.

Le guide complet

Monolithe Modulaire: Tout ce que vous devez savoir

Qu’est-ce qu’un monolithe modulaire

Un monolithe modulaire est une application unique déployable avec des frontières de modules internes strictes. De l’extérieur, il ressemble exactement à un monolithe : un seul processus, un seul build, une seule base de données, un seul déploiement. À l’intérieur, il est organisé en modules qui possèdent leurs propres données et exposent une interface publique restreinte, et ces frontières sont appliquées rigoureusement plutôt que d’être de simples intentions.

Il ne s’agit pas d’un compromis ou d’une étape transitoire par défaut. C’est une architecture légitime que de nombreux systèmes ne devraient jamais quitter. Elle conserve les propriétés qui facilitent la construction de logiciels — appels in-process, transaction unique, pipeline unique, refactorisations peu coûteuses — tout en ajoutant la discipline nécessaire pour éviter qu’une base de code en croissance ne devienne un plat de spaghettis.

La distinction cruciale se fait entre un seul élément déployable et un bloc non structuré. Un monolithe traditionnel en couches n’a aucune notion de propriété : n’importe quel contrôleur peut accéder à n’importe quelle table, et un changement se répercute sur l’ensemble de la base de code. Un monolithe modulaire stipule que le module de facturation est le seul à comprendre les factures, et que tous les autres communiquent avec lui via une porte qu’il contrôle.

Pourquoi c’est le choix par défaut le plus judicieux

La plupart des équipes se tournent vers les microservices pour résoudre des problèmes qu’elles n’ont pas. Un monolithe modulaire répond aux problèmes qu’elles rencontrent réellement — une base de code difficile à modifier, une propriété du code floue, des livraisons lentes — sans pour autant ajouter un réseau entre les différentes parties.

Les avantages pratiques sont considérables :

  • Les appels internes sont gratuits. Lorsqu’un module appelle un autre module, il s’agit d’un appel de fonction, et non d’une requête avec un timeout, une politique de retry et un mode de défaillance.
  • Les transactions sont réelles. Un cas d’utilisation qui touche un seul module est validé de manière atomique. Il n’y a pas de saga, pas de compensation et aucune fenêtre d’incohérence.
  • Le refactoring se résume à un commit. Déplacer une frontière, renommer un concept ou fusionner deux modules est un travail ordinaire, et non une migration avec double écriture et basculement.
  • L’exploitation reste simple. Un seul build, un seul déploiement, un seul ensemble de logs, une seule astreinte. Ce n’est pas un détail pour une équipe de cinq personnes.
  • Le domaine a le temps de se stabiliser. Vous pouvez différer les décisions concernant les frontières jusqu’à ce que vous compreniez mieux le métier, au lieu de figer des suppositions dans l’infrastructure.

Le coût réel est que les modules ne tombent pas en panne et ne scalent pas indépendamment, et qu’un bug dans un module peut faire tomber l’ensemble de l’application. Pour la plupart des équipes et à la plupart des stades de développement d’un produit, c’est un compromis qui en vaut la peine.

Les modules suivent les capacités métier

Un module doit correspondre à une capacité métier (business capability), et non à une couche technique. La facturation, le catalogue, l’expédition, l’identité et les notifications sont des capacités. controllers, services, repositories et utils sont des couches ; les regrouper ainsi produit le monolithe monolithique classique où chaque modification de fonctionnalité nécessite de parcourir quatre répertoires.

Une bonne frontière de module possède les mêmes propriétés qu’une bonne frontière de service :

  • Elle contient un ensemble cohérent de règles qui évoluent ensemble.
  • Elle masque bien plus qu’elle n’expose.
  • Elle peut être comprise par une seule équipe sans avoir à lire le reste du système.

Concrètement, chaque module est un dossier avec sa propre structure interne :

src/modules/billing/
  domain/          # entities, value objects, invariants
    invoice.ts
    money.ts
  application/     # use cases and orchestration
    billing-service.ts
    order-handlers.ts
  infra/           # persistence and external clients
    invoice-repository.ts
    stripe-client.ts
  index.ts         # the only public entry point

Les dossiers domain et infra sont privés. index.ts ré-exporte le petit ensemble de types et de services que le reste de l’application est autorisé à utiliser. Ce fichier unique constitue le contrat du module, et il doit être suffisamment court pour être lu en une minute.

Si vous connaissez la règle de dépendance de la Clean Architecture, c’est la même idée appliquée au niveau du module : le domaine au centre ne dépend de rien à l’extérieur, et l’infrastructure dépend du domaine, et non l’inverse.

Le noyau partagé (shared kernel)

Certains concepts n’appartiennent réellement à aucun module spécifique. L’argent, un CustomerId, un Clock ou un type Result sont utilisés partout et n’appartiennent à personne. Placez-les dans un petit package shared explicite et gardez-le volontairement minimaliste.

src/shared/
  money.ts        # value object, no dependencies
  ids.ts          # branded id types
  clock.ts        # a testable time source

Un noyau partagé est un point de couplage, traitez donc sa croissance comme un signal d’alerte. Dès l’instant où shared contient un service, un repository ou quoi que ce soit qui connaît une règle métier, il devient un module sans propriétaire dont tous les autres modules dépendent. La règle d’or est que le dossier shared contient des types et des fonctions pures, jamais d’orchestration ou d’état.

Chaque module possède ses propres données

La règle qui confère toute sa puissance au monolithe modulaire est la propriété des données (data ownership). Chaque module est le seul autorisé à écrire dans ses tables ou son schéma, et aucun autre module ne peut lire ces tables directement. L’accès entre modules s’effectue via l’interface du module propriétaire ou par le biais d’un événement.

Dans une base de données unique, vous disposez de trois moyens pratiques pour exprimer cette propriété :

  • Des schémas séparés. billing.invoices, catalog.products, shipping.shipments. C’est le signal le plus clair, et cela facilite grandement une future scission de la base de données.
  • Des préfixes de table. billing_invoices, catalog_products. Plus simple, mais avec la même intention.
  • Des bases de données séparées dès le départ. C’est la frontière la plus stricte, mais vous perdez l’avantage des transactions uniques entre modules et cela augmente la charge opérationnelle.

La plupart des monolithes modulaires devraient commencer avec une seule base de données et des schémas séparés. L’élément clé est la règle de propriété, et non la séparation physique : seul le repository du module propriétaire manipule ses tables. Si le module d’expédition a besoin de l’adresse d’un client, il interroge le module client ; il ne fait pas de SELECT depuis customers.

C’est précisément ce qui rend l’extraction possible par la suite. Lorsqu’un module possède déjà ses données et communique via une interface, le déplacer vers son propre service devient un simple changement de déploiement plutôt qu’une refonte complète.

Imposer les frontières grâce à l’outillage

Les frontières qui ne sont basées que sur des conventions finissent par s’estomper. Sous la pression des délais, importer le dépôt d’un autre module est toujours plus rapide que d’ajouter une méthode à son interface, et un raccourci en entraîne rapidement vingt autres. Rendez la frontière mécanique.

Une règle de dépendance est simple à énoncer et facile à vérifier : un module peut importer ses propres fichiers et l’interface publique d’autres modules, et rien d’autre. Des outils comme eslint-plugin-boundaries et dependency-cruiser permettent d’exprimer cela et de faire échouer le build en cas de non-respect.

{
  "forbidden": [
    {
      "name": "no-cross-module-internals",
      "from": { "path": "^src/modules/([^/]+)/" },
      "to": {
        "path": "^src/modules/(?!$1)([^/]+)/(?!index\\.ts).+"
      }
    },
    {
      "name": "no-cycles",
      "from": {},
      "to": { "circular": true }
    }
  ]
}

Deux règles font l’essentiel du travail : interdire l’importation des composants internes d’un autre module et interdire les dépendances circulaires. Ajoutez une entrée CODEOWNERS par module afin que les revues de code soient assignées aux propriétaires du code, et traitez tout changement d’interface comme un petit changement d’API — cela mérite un second examen.

Communication inter-processus sans s’emmêler les pinceaux

Le mode de défaillance d’un monolithe est un graphe de dépendances où tout pointe vers tout. Un monolithe modulaire maintient ce graphe acyclique et peu profond. Il existe deux façons pour un module d’en utiliser un autre.

Appeler l’interface publique. Lorsque l’appelant a besoin d’une réponse immédiate, injectez le service de l’autre module et appelez-le. Orders appelle catalog.getProduct(sku) pour tarifer une ligne. L’appel est synchrone et s’effectue au sein du même processus, il est donc rapide et échoue via une exception, et non un timeout.

export class PlaceOrder {
  constructor(
    private readonly orders: OrderRepository,
    private readonly catalog: CatalogService,
  ) {}

  async execute(input: PlaceOrderInput): Promise<OrderId> {
    const order = Order.create(input.customerId, input.lines);
    for (const line of order.lines) {
      const product = await this.catalog.getProduct(line.sku);
      if (!product.isAvailable()) throw new OutOfStock(line.sku);
      order.priceLine(line, product.priceCents);
    }
    await this.orders.save(order);
    return order.id;
  }
}

Publier un événement. Lorsque l’appelant n’a pas besoin de réponse, il annonce un fait et les autres modules réagissent. Orders publie order.placed ; billing, analytics et notifications s’y abonnent. Le module order ne sait pas qu’ils existent, ce qui permet de supprimer le couplage.

Les événements permettent également de briser les cycles. Si orders a besoin d’un effet de bord provenant de shipping et que shipping dépend déjà de orders, un appel direct créerait une boucle. Un événement permet à shipping de réagir sans que orders n’en dépende. Limitez le nombre d’appels directs entre modules ; si deux modules s’appellent constamment, ils forment probablement un seul et même module ou leur frontière est mal placée.

Une seule base de données, des schémas séparés

L’utilisation d’une base de données unique est un avantage, pas un compromis. Cela vous permet de bénéficier des transactions, des clés étrangères, d’un seul pool de connexions et d’un historique de migrations unique. Ce à quoi vous renoncez est l’isolation physique, que vous remplacez par la règle de propriété.

Au sein d’une même base de données, privilégiez un schéma par module. Cela permet de garder des noms de tables propres, rend la propriété visible dans chaque requête et offre à chaque module son propre espace de nom pour les migrations. Lorsqu’un module est extrait ultérieurement, son schéma suit le mouvement.

Évitez les clés étrangères entre modules. Une clé étrangère allant de billing.invoices vers catalog.products couple fortement les deux modules au niveau de la base de données : le catalogue ne peut être supprimé ou restructuré sans tenir compte de la facturation, et l’extraction nécessite la suppression de la contrainte. Stockez l’id et validez-le via l’interface à la place. Le guide PostgreSQL détaille les schémas et les contraintes.

Les migrations exigent la même discipline. Chaque module possède ses propres fichiers de migration, et le démarrage de l’application ou une étape de migration les applique dans l’ordre. Comme il n’y a qu’un seul déploiement, vous pouvez migrer et livrer simultanément, un luxe dont les microservices ne disposent pas.

Transactions et cohérence au sein d’un module

Le « super-pouvoir » de la transaction unique ne fonctionne que lorsqu’un cas d’utilisation reste confiné à un seul module. Faites en sorte que ce soit le cas le plus courant. Un cas d’utilisation qui charge un agrégat, valide un invariant et l’enregistre à nouveau doit constituer une seule transaction qui soit validée entièrement (commit) ou annulée proprement (rollback).

await db.transaction(async (tx) => {
  const order = await orders.getForUpdate(orderId, tx);
  order.confirm();
  await orders.save(order, tx);
  await outbox.add(
    { type: "order.confirmed", orderId: order.id },
    tx,
  );
});

Deux patterns permettent de maintenir la cohérence entre les modules.

L’outbox. Écrivez l’événement de domaine dans une table outbox au sein de la même transaction que le changement d’état, puis un dispatcher le publie ensuite. Cela garantit que l’événement n’est pas perdu si le processus plante entre le commit et la publication ; c’est d’ailleurs le même pattern que vous utiliseriez après une extraction.

Un process manager. Lorsqu’un flux s’étend sur plusieurs modules, un petit coordinateur peut écouter les événements et émettre la commande suivante, en gérant explicitement les tentatives (retries) et les timeouts. C’est l’équivalent interne d’une saga, et c’est bien plus simple qu’une saga distribuée car l’état de coordination réside dans une table classique.

N’essayez pas de mettre en œuvre une transaction distribuée. Si un cas d’utilisation s’étend réellement sur plusieurs modules et doit être atomique, c’est généralement le signe que ces modules ne forment en réalité qu’un seul et même module.

Le chemin vers l’extraction

L’intérêt d’investir dans des frontières claires est l’optionalité. Un monolithe modulaire dont les modules communiquent via des interfaces et des événements peut être décomposé ultérieurement, module par module, en utilisant l’approche du strangler fig.

  1. Choisissez le module ayant la frontière la plus nette et la pression la plus forte. Un besoin de mise à l’échelle indépendante, le rythme de travail d’une équipe distincte ou une exigence de conformité sont de bonnes raisons.
  2. Confirmez qu’il possède ses propres données. Si d’autres modules lisent encore ses tables, corrigez cela d’abord en les redirigeant via l’interface.
  3. Attribuez-lui sa propre base de données et son propre pipeline. Déplacez son schéma, pointez son repository vers le nouveau stockage et maintenez l’interface stable.
  4. Remplacez les appels internes (in-process) par des appels réseau ou des événements. Les appelants dépendent déjà d’une interface, il s’agit donc d’un changement d’adaptateur et non d’une réécriture.
  5. Remplacez le dispatcher d’événements par un broker. Grâce au pattern outbox, le flux d’événements change à peine lorsque le transport devient Kafka ou RabbitMQ.

Comme la couture existe déjà, chaque étape est délimitée. C’est l’argument le plus fort en faveur du monolithe modulaire : ce n’est pas une destination différente des microservices, c’est l’option d’y parvenir délibérément, uniquement pour les parties qui le justifient.

Tester les modules en isolation

Les frontières de modules sont un atout majeur pour les tests. Puisqu’un module expose une interface publique et possède ses propres données, vous pouvez le tester indépendamment sans avoir à démarrer l’application complète.

  • Les tests de domaine sont purs et rapides. Instanciez l’agrégat, exercez les règles métier et vérifiez le résultat. Pas de base de données, pas de HTTP.
  • Les tests d’interface de module pilotent le service public du module contre une base de données de test limitée à son propre schéma. Ils vérifient le contrat dont dépendent les autres modules.
  • Les tests de contrat d’événements vérifient que le module publie les événements attendus par les consommateurs, avec les champs sur lesquels ils s’appuient.
  • Les tests de bout en bout (end-to-end) sollicitent la couche HTTP pour un petit nombre de flux critiques. Limitez-les, car ils sont lents.

L’alternative basée sur les couches force chaque test significatif à traverser toutes les couches, c’est pourquoi ces suites de tests deviennent lentes et instables. Tester à la frontière du module permet de garder la majorité des tests rapides tout en protégeant les interfaces essentielles.

Monolithe modulaire versus monolithe en couches

Il est important d’être précis, car les deux sont des « monolithes », mais un seul est modulaire.

Monolithe en couches Monolithe modulaire
Groupement Par couche technique Par domaine métier
Impact d’un changement S’étend à toutes les couches Reste confiné à un module
Accès aux données Toute couche accède à n’importe quelle table Le module possède ses propres tables
Propriété (Ownership) Floue Une équipe par module
Testabilité Prédominance des tests de bout en bout Tests limités au périmètre du module
Extraction Nécessite une réécriture Changement délimité

Le monolithe en couches n’est pas un mauvais choix pour une petite application ; il est simple et familier. Il devient cependant un handicap lorsque plusieurs personnes y travaillent, car il n’existe aucune jointure permettant de diviser le travail, ni aucun moyen de raisonner sur un changement de manière locale.

Le mode de défaillance : la “boule de boue” (big ball of mud)

Le monolithe modulaire échoue d’une manière bien précise : la dissolution des frontières. Tout commence par un raccourci raisonnable qui finit par devenir la norme.

Les signes d’alerte :

  • Un module importe le dossier infra d’un autre module.
  • Deux modules écrivent dans la même table.
  • Un package utils ou shared se transforme en une seconde application.
  • La modification du schéma d’un module fait échouer le build d’un autre module.
  • Le graphe de dépendances présente un cycle, généralement introduit par un unique import “juste pour cette fois”.

La prévention repose sur la même discipline que le reste de l’architecture : des règles de lint qui font échouer le build, des code owners par module, une interface publique restreinte et une culture de revue de code qui traite tout import interne comme un bug. Les frontières sont peu coûteuses à maintenir mais très coûteuses à rétablir ; imposez-les donc dès le premier module.

Événements au sein d’un même processus

Les événements in-process permettent de découpler les modules sans avoir recours à un broker. Un petit dispatcher reçoit un fait publié et appelle les handlers abonnés, le tout au sein du même processus et, si vous le souhaitez, de la même transaction.

type DomainEvent = { type: string; occurredAt: string };
type Handler = (event: DomainEvent, tx?: Transaction) => Promise<void>;

class EventBus {
  private handlers = new Map<string, Handler[]>();

  on(type: string, handler: Handler) {
    this.handlers.set(type, [...(this.handlers.get(type) ?? []), handler]);
  }

  async publish(event: DomainEvent, tx?: Transaction) {
    for (const handler of this.handlers.get(event.type) ?? []) {
      await handler(event, tx);
    }
  }
}

Deux points de vigilance. Si les handlers s’exécutent dans la transaction de l’appelant, un handler lent prolongera la transaction et tout échec entraînera le rollback de l’ensemble du cas d’utilisation. S’ils s’exécutent après le commit, un crash entre les deux peut entraîner la perte de l’événement. Le pattern outbox résout ce problème en écrivant l’événement dans une table outbox au sein de la même transaction, pour ensuite effectuer le dispatch depuis celle-ci.

await db.transaction(async (tx) => {
  await orders.save(order, tx);
  await tx.insert(outbox).values({
    type: "order.placed",
    payload: order.toEvent(),
  });
});

Comme l’événement est commit avec l’état, il ne peut pas être perdu, et un relay peut retenter le dispatch jusqu’à ce que chaque abonné l’ait traité. Lorsqu’un module est extrait ultérieurement, le relay est la seule chose qui change.

Un gestionnaire de processus pour les flux inter-modules

Lorsqu’un cas d’utilisation s’étend sur plusieurs modules et doit réagir aux échecs, un process manager le coordonne explicitement au lieu de masquer le flux dans une chaîne de gestionnaires d’événements. Il écoute les événements, conserve son propre état et émet des commandes.

class PlaceOrderProcess {
  async onOrderPlaced(event: OrderPlaced) {
    await this.catalog.reserve(event.orderId, event.lines);
  }

  async onReservationFailed(event: ReservationFailed) {
    await this.orders.cancel(event.orderId, "out_of_stock");
    await this.notifications.send(event.customerId, "order_cancelled");
  }

  async onReservationConfirmed(event: ReservationConfirmed) {
    await this.payments.charge(event.orderId, event.totalCents);
  }
}

Le process manager est l’équivalent interne d’une saga. Il rend le chemin nominal (happy path) et le chemin de compensation visibles en un seul endroit, ce que la chorégraphie a tendance à masquer. Conservez l’état du processus dans une table classique afin qu’un redémarrage puisse reprendre là où il s’était arrêté.

Versionner l’interface d’un module

L’index.ts d’un module est une API interne et mérite autant de soin qu’une API publique. D’autres modules compilent en s’appuyant dessus, donc un changement imprudent peut casser leurs builds.

  • Ajoutez, ne cassez pas. Ajoutez des paramètres optionnels et de nouvelles méthodes ; évitez de modifier une signature existante.
  • Gardez une surface réduite. Chaque export est une promesse. Si un type n’a pas besoin de sortir du module, ne l’exportez pas.
  • Dépréciez par étapes. Marquez l’ancienne méthode, migrez les appels dans des commits séparés, puis supprimez-la. Comme il s’agit d’une seule base de code, vous pouvez rechercher chaque appelant.
  • Testez le contrat. Les tests d’interface de module protègent la promesse que vous avez faite aux autres modules.

C’est moins coûteux que le versionnement d’une API réseau car il n’y a pas d’ordre de déploiement à respecter, mais la discipline est la même, et c’est ce qui évite qu’une future extraction ne se transforme en réécriture complète.

Migrations par module

Comme un monolithe modulaire utilise une seule base de données, il est tentant de conserver un ensemble unique et géant de fichiers de migration. Cela recréerait un couplage que l’architecture tente justement de supprimer. Au lieu de cela, laissez chaque module gérer ses propres migrations, limitées à son schéma ou à son préfixe de table.

migrations/
  catalog/   20260901_add_product_status.sql
  orders/    20260903_add_order_confirmed_at.sql
  billing/   20260905_add_invoice_paid_at.sql

Une migration qui modifie les tables d’un autre module est une violation de frontière déguisée et devrait être refusée lors de la revue de code. Maintenir des migrations modulaires signifie que la règle de propriété s’applique jusqu’au schéma, et cela rend le transfert des tables d’un module vers sa propre base de données aussi simple que de rejouer le contenu d’un seul dossier.

Quand fusionner à nouveau des modules

Toutes les frontières ne sont pas correctes dès le départ. Si deux modules changent systématiquement ensemble, partagent une transaction et s’appellent mutuellement dans les deux sens, ils ne forment en réalité qu’un seul module avec une coupure artificielle. Les fusionner à nouveau est une décision légitime et saine.

Les signes sont concrets : une pull request modifie régulièrement les deux modules, leur interface change à chaque nouvelle fonctionnalité, et le graphe de dépendances présente un cycle que vous devez constamment contourner. La fusion est simple dans un monolithe — déplacez le code, supprimez l’interface, mettez à jour les imports — et cela élimine un coût de coordination qui ne ferait que s’aggraver. L’objectif est d’avoir des frontières claires, et non un nombre précis de modules.

Maintenir la santé des jointures

Les frontières se dégradent silencieusement ; il est donc préférable de les vérifier régulièrement plutôt que d’attendre une réécriture complète. Quelques fitness functions automatisées permettent de détecter cette dérive tant qu’elle est encore peu coûteuse à corriger.

  • Faire échouer la CI lorsqu’un module importe les composants internes d’un autre module.
  • Faire échouer la CI lorsque le graphe de dépendances contient un cycle.
  • Faire échouer la CI lorsqu’une migration fait référence à une table appartenant à un autre module.
  • Suivre l’évolution du nombre de fichiers et d’exports publics par module ; un module qui ne cesse de croître est peut-être une frontière mal placée.
  • Exiger une revue CODEOWNERS pour tout changement apporté à l’interface publique d’un module.

Aucune de ces mesures ne nécessite d’infrastructure nouvelle. Ce sont des tests et des règles de lint classiques qui, ensemble, transforment l’accord tacite « nous avons convenu de respecter les frontières » en une contrainte appliquée par le build.

Bonnes pratiques

  • Définissez vos modules par capacité métier, et jamais par couche technique.
  • Attribuez à chaque module un unique fichier index public et gardez-le concis.
  • Faites en sorte que chaque module soit le seul habilité à écrire dans ses propres tables ou son propre schéma.
  • Interdisez les imports d’internals entre modules ainsi que les dépendances cycliques via des règles de lint en CI.
  • Privilégiez l’appel direct à une interface lorsque vous avez besoin d’une réponse, et un événement dans le cas contraire.
  • Maintenez un graphe de dépendances acyclique et peu profond ; si deux modules s’appellent mutuellement, fusionnez-les ou redéfinissez leurs frontières.
  • Utilisez une seule base de données avec un schéma par module et évitez les clés étrangères entre modules.
  • Gardez un cas d’utilisation au sein d’un seul module afin qu’il tienne dans une seule transaction.
  • Utilisez un outbox pour assurer la cohérence entre modules plutôt que des transactions distribuées.
  • Testez les modules via leur interface publique, avec quelques tests de bout en bout aux extrémités.
  • Concevez vos interfaces pour qu’elles soient compatibles avec les événements, afin qu’un module puisse être extrait sans nécessiter de réécriture.

Erreurs courantes

  • Parler de monolithe modulaire tout en partageant des tables et en important des composants internes.
  • Organiser les dossiers par couche en croyant que les modules sont réellement isolés.
  • Ignorer les règles de lint sous prétexte que « tout le monde connaît la convention ».
  • Laisser un package d’utilitaires partagés devenir un point de couplage caché.
  • Exécuter un flux transverse aux modules comme une transaction distribuée alors que les modules devraient n’en former qu’un seul.
  • Introduire des dépendances circulaires et tenter de les masquer avec des imports dynamiques.
  • Extraire un service avant que le module n’ait une interface propre ou ne possède ses propres données.
  • Placer les règles métier dans les controllers, empêchant ainsi de tester les modules de manière isolée.
  • Considérer le style comme une solution temporaire et ne jamais faire respecter les frontières.
  • Supposer qu’un déploiement unique signifie un domaine de panne unique et négliger le travail de résilience de base.

Et après ?

Si une pression concrète justifie plus tard un découpage, le guide sur les Microservices explique les gains, les coûts et la manière d’extraire un module à la fois. Pour organiser le code à l’intérieur de chaque module, consultez Clean Architecture, et pour découpler les modules via des faits plutôt que des appels, lisez Event-Driven Architecture. Lorsque vous avez besoin des modèles de stockage liés à la propriété des modules, la section PostgreSQL traite des schemas, des transactions et des contraintes.

En pratique

Module, appel, frontière, événement

Les quatre éléments qui rendent un monolithe modulaire plutôt que stratifié.

src/modules/catalog/index.ts
// Public surface of the catalog module.
export type { Product, ProductId, Sku } from "./domain/product.js";
export { CatalogService } from "./application/catalog-service.js";
export { onStockChanged } from "./application/stock-handlers.js";

// Private by convention and by lint rule:
// ./domain/**  entities, value objects, invariants
// ./infra/**   repositories, ORM mappings, clients

Interface publique vs accès interne

Un module qui importe le repository d'un autre module est couplé à son schéma et à son interne. Un module qui importe l'interface peut être remplacé ou extrait.

Préférer
import { CatalogService } from "../../catalog/index.js";

// The order module knows only what catalog promises.
const product = await catalog.getProduct(sku);
Éviter
import { ProductRepository } from "../../catalog/infra/product-repository.js";

// Now orders depends on catalog's tables and ORM mappings.
// A schema change in catalog breaks orders.
const product = await productRepository.findBySku(sku);

Monolithe modulaire vs microservices pour une petite équipe

Une petite équipe obtient les mêmes frontières internes avec un coût opérationnel bien moindre. N'extrayez un service que lorsqu'une raison concrète apparaît.

Préférer
// One process, one transaction, one deploy.
await this.orders.save(order);
await this.catalog.reserve(order.lines);

// Refactor and rename across modules in one commit.
Éviter
// Three services and a saga for the same use case.
const order = await orders.create(input);
await inventory.reserve(order.id);   // network
await payments.charge(order.id);     // network
// Any of the above can fail after the others succeeded.

Compromis

Pourquoi commencer par un monolithe modulaire ?

Ce style conserve l'essentiel de la simplicité d'un monolithe tout en vous donnant l'option de distribuer plus tard. Le piège est que les frontières n'existent que si vous les imposez.

Strengths

  • Les transactions restent simples

    Un cas d'utilisation qui touche un seul module est une transaction ACID unique avec un rollback réel. Pas de sagas, pas d'actions compensatoires, pas de cohérence éventuelle à expliquer.

  • Le refactoring est peu coûteux

    Renommer une interface, déplacer une classe ou diviser un module est un commit normal. Il n'y a pas de contrat versionné ou de fenêtre de migration à négocier.

  • L'exploitation reste légère

    Un seul build, un seul déploiement, un seul tableau de bord, une seule rotation d'astreinte. Une petite équipe peut le gérer sans groupe plateforme.

  • La jointure est déjà là

    Parce que les modules communiquent via des interfaces et des événements, en extraire un plus tard est un changement délimité plutôt qu'un projet d'archéologie.

Trade-offs

  • La discipline est la clé

    Rien dans le runtime n'empêche un module d'importer le repository d'un autre. Sans règles de lint et de revue, les frontières s'érodent en quelques semaines.

  • Un seul déploiement, un seul rayon d'impact

    Une fuite de mémoire ou un crash dans un module peut faire tomber toute l'application. Les modules ne tombent pas indépendamment comme le font les services.

  • Le scaling est tout ou rien

    Si un module a besoin de beaucoup plus de CPU que les autres, vous scalez l'application entière jusqu'à ce que vous extrayiez ce module.

  • La mémoire partagée est une tentation

    Les variables globales in-process et les caches partagés facilitent le couplage invisible des modules. Considérez l'état partagé comme une violation de frontière.

FAQ

Foire aux questions

Keep learning

Related topics from the roadmap.

$ commencer à apprendre

Prêt à apprendre Modular Monolith ?

Notre tutoriel interactif vous guide à travers Modular Monolith pas à pas — avec des quiz et du vrai code que vous pouvez exécuter dans le navigateur.