N-Tier Architecture

L’architecture en couches organise le code en strates horizontales superposées, chacune ne s’adressant qu’à celle qui se trouve en dessous. Sa forme dominante, et de très loin, est le 3-tier : un contrôleur, un service, un dépôt.

C’est l’architecture par défaut du développement d’entreprise. La plupart des applications l’adoptent sans décision explicite, parce que c’est la forme que proposent les frameworks et celle qu’on obtient en séparant naïvement « ce que voit l’utilisateur », « ce que fait le programme » et « ce qui est stocké ».

Elle mérite d’être comprise pour trois raisons : c’est le point de départ de presque tous les projets, c’est le bon choix pour une large part d’entre eux, et ce sont ses limites précises qui ont donné naissance aux architectures hexagonales.

Couche ou étage ?

Le vocabulaire est trompeur. Deux notions distinctes se cachent derrière le même préfixe n- :

DéfinitionDécision
Layer — coucheRegroupement logique de codeOrganisation du code source
Tier — étageUnité de déploiement physiqueTopologie d’exécution

Le « trois-tiers » historique désignait trois machines : un poste client, un serveur d’applications, un serveur de base de données. Aujourd’hui, on emploie le terme pour parler de trois couches logiques à l’intérieur d’un même processus. Les deux choix sont indépendants : un monolithe déployé d’un bloc peut compter cinq couches, et deux microservices peuvent n’en avoir aucune.

C’est du découpage logique qu’il s’agit dans toute la suite de cette page.

Les trois couches

┌───────────────────────────────────────────────────────────────┐
│  Contrôleur      HTTP, DTO, validation de forme, codes retour │
├───────────────────────────────────────────────────────────────┤
│  Service         règles métier, orchestration, transaction    │
├───────────────────────────────────────────────────────────────┤
│  Dépôt           SQL, ORM, accès aux données                  │
└───────────────────────────────────────────────────────────────┘
                   les appels descendent, jamais l'inverse

Le contrat de chaque couche, et surtout ce qui n’y a pas sa place :

CoucheResponsable deN’a pas à connaître
ContrôleurTraduire HTTP en appel de service, valider la forme des entrées, choisir le code de retourUne règle métier, une requête SQL, une transaction
ServiceLes règles métier, l’enchaînement des opérations, la frontière transactionnelleHTTP, le format JSON, le dialecte SQL
DépôtLire et écrire, traduire entre les tables et les objetsToute règle métier, l’existence d’un utilisateur connecté

Deux frontières sont plus fragiles que les autres, et c’est là que la discipline se joue en pratique. Un if métier dans un contrôleur — « si le montant dépasse 1 000 €, refuser » — est la fuite la plus fréquente, parce qu’elle est commode : la donnée est là, la réponse HTTP est là. Et un WHERE statut = 'VALIDEE' dans un dépôt est une règle métier déguisée en requête : la définition de ce qu’est une commande valide vient de migrer dans la couche d’accès aux données.

Note

Deux variantes du 3-tier coexistent. Le layering strict interdit de sauter un niveau : le contrôleur ne peut pas appeler le dépôt directement. Le layering relâché l’autorise, ce qui évite d’écrire des services qui ne font que transmettre — mais affaiblit la garantie, puisque plus rien ne dit avec certitude qui appelle quoi. En pratique, le relâché est le plus répandu et le plus défendable sur les cas de lecture pure.

Un exemple complet

Les trois couches sur un même cas d’usage : la validation d’une commande, avec deux règles métier — une commande ne peut pas être vide, et elle ne peut pas faire dépasser l’encours autorisé du client.

​
// ================= Couche dépôt =================
// persistance/commande.repository.ts
export class CommandeRepository {
  constructor(private readonly pool: Pool) {}

  async enregistrer(commande: CommandeEntity): Promise<void> {
    await this.pool.query('INSERT INTO commandes ...', [commande.id]);
  }

  async parId(id: number): Promise<CommandeEntity | null> { /* ... */ }
}

// ================= Couche service =================
// service/commande.service.ts
import { CommandeRepository } from '../persistance/commande.repository';
import { ClientRepository } from '../persistance/client.repository';

export class CommandeService {
  private readonly commandes = new CommandeRepository(pool);   // Dépendance
  private readonly clients = new ClientRepository(pool);       // descendante

  async valider(commande: CommandeEntity): Promise<void> {
    if (commande.lignes.length === 0) {
      throw new MetierError('Commande vide');                  // Règle 1
    }

    const client = await this.clients.parId(commande.clientId);
    const total = commande.lignes.reduce((t, l) => t + l.montant, 0);
    if (client.encours + total > client.plafond) {
      throw new MetierError('Plafond dépassé');                // Règle 2
    }

    await this.commandes.enregistrer(commande);
  }
}

// ================= Couche contrôleur =================
// presentation/commande.controller.ts
import { CommandeService } from '../service/commande.service';

export class CommandeController {
  private readonly service = new CommandeService();

  @Post()
  async creer(@Body() dto: CreerCommandeDto): Promise<{ id: number }> {
    try {
      const entity = dto.versEntity();            // Traduction DTO → entité
      await this.service.valider(entity);
      return { id: entity.id };
    } catch (e) {
      if (e instanceof MetierError) throw new BadRequestException(e.message);
      throw e;
    }
  }
}
# ================= Couche dépôt =================
# persistance/commande_repository.py
class CommandeRepository:
    def __init__(self, session: Session) -> None:
        self._session = session

    def enregistrer(self, commande: CommandeORM) -> None:
        self._session.add(commande)
        self._session.commit()

    def par_id(self, id: int) -> CommandeORM | None:
        return self._session.get(CommandeORM, id)


# ================= Couche service =================
# service/commande_service.py
from persistance.commande_repository import CommandeRepository   # Dépendance
from persistance.client_repository import ClientRepository       # descendante


class CommandeService:
    def __init__(self, session: Session) -> None:
        self._commandes = CommandeRepository(session)
        self._clients = ClientRepository(session)

    def valider(self, commande: CommandeORM) -> None:
        if not commande.lignes:
            raise ErreurMetier("Commande vide")                  # Règle 1

        client = self._clients.par_id(commande.client_id)
        total = sum(l.montant for l in commande.lignes)
        if client.encours + total > client.plafond:
            raise ErreurMetier("Plafond dépassé")                # Règle 2

        self._commandes.enregistrer(commande)


# ================= Couche contrôleur =================
# presentation/routes_commande.py
from service.commande_service import CommandeService


@app.post("/commandes", status_code=201)
def creer(dto: CreerCommandeDto, session: Session = Depends(get_session)) -> dict:
    try:
        entity = dto.vers_orm()                    # Traduction DTO → entité
        CommandeService(session).valider(entity)
        return {"id": entity.id}
    except ErreurMetier as e:
        raise HTTPException(status_code=400, detail=str(e))
// ================= Couche dépôt =================
// src/persistance/commande.rs
pub struct CommandeRepository {
    pool: PgPool,
}

impl CommandeRepository {
    pub async fn enregistrer(&self, commande: &CommandeRow) -> Result<(), Erreur> {
        sqlx::query("INSERT INTO commandes ...")
            .bind(commande.id)
            .execute(&self.pool)
            .await?;
        Ok(())
    }

    pub async fn par_id(&self, id: i64) -> Result<Option<CommandeRow>, Erreur> {
        /* ... */
    }
}

// ================= Couche service =================
// src/service/commande.rs
use crate::persistance::client::ClientRepository;        // Dépendance
use crate::persistance::commande::CommandeRepository;    // descendante

pub struct CommandeService {
    commandes: CommandeRepository,
    clients: ClientRepository,
}

impl CommandeService {
    pub async fn valider(&self, commande: &CommandeRow) -> Result<(), Erreur> {
        if commande.lignes.is_empty() {
            return Err(Erreur::Metier("Commande vide"));          // Règle 1
        }

        let client = self.clients.par_id(commande.client_id).await?;
        let total: i64 = commande.lignes.iter().map(|l| l.montant).sum();
        if client.encours + total > client.plafond {
            return Err(Erreur::Metier("Plafond dépassé"));        // Règle 2
        }

        self.commandes.enregistrer(commande).await
    }
}

// ================= Couche contrôleur =================
// src/presentation/commande.rs
use crate::service::commande::CommandeService;

pub async fn creer(
    State(service): State<Arc<CommandeService>>,
    Json(dto): Json<CreerCommandeDto>,
) -> Result<(StatusCode, Json<Creee>), ErreurApi> {
    let row = dto.vers_row();                      // Traduction DTO → entité
    service.valider(&row).await.map_err(ErreurApi::depuis_metier)?;
    Ok((StatusCode::CREATED, Json(Creee { id: row.id })))
}

Ce code est correct, et il faut le dire clairement : sur une application de cette nature, il n’y a rien à lui reprocher. Il est lisible par n’importe quel développeur en dix secondes, il ne demande aucune notion préalable, et il se maintient. La plupart des critiques adressées au 3-tier visent des applications qui n’auraient jamais dû rester en 3-tier — pas le 3-tier lui-même.

Ce qu’il faut retenir n’est pas le code mais la direction des import : chaque couche nomme explicitement celle du dessous. C’est ce qui rend l’architecture immédiatement lisible, et c’est aussi exactement là que se situe sa limite structurelle, sur laquelle nous reviendrons.

L’arborescence

Le découpage se lit d’ordinaire directement dans les dossiers, ce qui explique une bonne part de sa popularité : la question « où poser ce fichier ? » a une réponse immédiate.

​
src/
  presentation/
    commande.controller.ts     contrôleurs HTTP
    dto/                       objets d'entrée et de sortie
  service/
    commande.service.ts        règles métier et orchestration
  persistance/
    commande.repository.ts     accès aux données
    entities/                  entités ORM
  main.ts
src/
  presentation/
    routes_commande.py         routeurs FastAPI / vues Django
    schemas.py                 modèles Pydantic d'entrée-sortie
  service/
    commande_service.py        règles métier et orchestration
  persistance/
    commande_repository.py     accès aux données
    models.py                  modèles SQLAlchemy
  main.py
src/
  presentation/
    mod.rs                     handlers axum, extracteurs
    dto.rs                     structures Serialize / Deserialize
  service/
    mod.rs                     règles métier et orchestration
  persistance/
    mod.rs                     requêtes sqlx / diesel
  main.rs
Tip

Un découpage concurrent consiste à grouper par fonctionnalité plutôt que par couche : commandes/, clients/, facturation/, chacun contenant son contrôleur, son service et son dépôt. Les trois couches subsistent mais verticalement, à l’intérieur de chaque module. Sur une application qui dépasse une trentaine d’endpoints, ce découpage vieillit nettement mieux : une évolution métier touche un dossier au lieu de trois, et deux développeurs sur deux fonctionnalités ne se marchent plus dessus. C’est un changement gratuit, sans contrepartie, et il repousse sensiblement le moment où les limites de la section suivante deviennent gênantes.

Ce qui explique sa domination

  • Le coût d’entrée est nul. Aucune notion à apprendre, aucune abstraction à introduire. Trois dossiers et le travail peut commencer.
  • Les frameworks la présupposent. Spring MVC, NestJS, Django, Rails, ASP.NET proposent tous des contrôleurs, des services et des dépôts : suivre le framework produit mécaniquement cette architecture.
  • Elle donne une place à chaque fichier. Sur une équipe qui change souvent, la prévisibilité du rangement compte davantage qu’on ne l’admet.
  • N’importe qui la lit. Un développeur qui arrive sur le projet n’a besoin d’aucune explication. C’est un avantage réel, et il est rarement compté.

À partir de quelle taille ?

C’est la bonne question, mais la métrique habituelle — le nombre de lignes — est la mauvaise. Le 3-tier a une propriété d’échelle très particulière, et la comprendre répond à presque tout :

Le 3-tier passe très bien à l’échelle du nombre d’entités. Il ne passe pas du tout à l’échelle de la densité de règles.

Ajouter la cinquantième entité CRUD à une application 3-tier coûte à peu près ce qu’a coûté la cinquième : un contrôleur, un service, un dépôt, et personne n’est gêné. Ajouter la cinquième règle métier à un même cas d’usage coûte plus que la quatrième, et la vingtième est ingérable — parce qu’aucune couche n’est faite pour les héberger, les composer et les tester.

D’où une conséquence contre-intuitive : une application de 300 000 lignes de CRUD reste confortable en 3-tier, alors qu’une application de 15 000 lignes de tarification y étouffe. La taille n’est pas le critère ; l’épaisseur du domaine l’est.

Ordres de grandeur

Ces chiffres sont des repères observés, pas des seuils. Ils servent à situer une application, pas à décider à sa place.

EndpointsRègles par cas d’usagePoints d’entréeÉquipesVerdict
Trop petit< 100 – 111 personneLes trois couches sont une cérémonie. Un module par fonctionnalité suffit.
Zone idéale10 – 1000 – 311 – 2Le 3-tier est le bon choix. Ne cherchez pas plus loin.
Zone de tension100 – 3003 – 822 – 4Les signaux de la section suivante apparaissent. Renforcer, pas réécrire.
Au-delà> 300> 83 et plus5 et plusLe coût devient structurel. Inverser les dépendances sur le cœur.

Un point souvent mal compris : le cas « trop petit » existe. Sur une API de six endpoints, écrire un service qui ne fait que transmettre au dépôt ajoute une indirection sans rien protéger. Un fichier par fonctionnalité, contenant la requête HTTP et la requête SQL, est plus honnête et plus court. Les couches viendront quand il y aura quelque chose à séparer.

La liste de contrôle

Le 3-tier est très probablement le bon choix si vous cochez la majorité de ces cases :

  • l’application déplace des données plus qu’elle n’en décide — les cas d’usage ressemblent à créer, lire, modifier, lister, avec quelques validations ;
  • il y a un seul point d’entrée, en général HTTP, et rien n’annonce le contraire ;
  • un seul mode de stockage, une seule base ;
  • les règles métier sont peu nombreuses et stables — elles changent tous les trimestres, pas toutes les semaines ;
  • l’équipe est petite et le framework impose déjà cette forme ;
  • la durée de vie attendue est de quelques années, pas de quinze ans.

À l’inverse, quatre situations doivent faire renoncer au 3-tier dès le départ, indépendamment de la taille : un domaine à règles denses et changeantes — moteur de tarification, calcul de droits, allocation de ressources ; plusieurs points d’entrée prévus dès la conception ; une exigence de tests unitaires du métier posée comme contrainte ; ou une durée de vie où l’on sait que la technologie de stockage changera.

Tip

La décision n’est pas binaire, et c’est le point le plus utile de cette page : on peut appliquer le 3-tier par sous-domaine. Le référentiel, l’administration et le back-office en 3-tier assumé ; le cœur de métier en hexagonal. C’est exactement la logique de dosage décrite dans Sous-domaines — et c’est presque toujours meilleur qu’un choix unique appliqué partout.

Les sept signaux de saturation

Les limites du 3-tier n’arrivent pas d’un coup : elles se manifestent dans un ordre assez constant. Les connaître permet de savoir où l’on en est, et de distinguer ce qui se répare sur place de ce qui ne se répare pas.

1. Le service qui grossit

Le premier, et le plus bénin. CommandeService accumule tous les cas d’usage liés aux commandes, et franchit les 500 puis les 1 500 lignes. Le fichier devient difficile à parcourir, les conflits de fusion se multiplient.

Réparable sur place, et facilement : un service par cas d’usage — ValiderCommande, AnnulerCommande — au lieu d’un service par entité. L’architecture n’est pas en cause.

2. La couche traversante

Symétrique du précédent : des services qui n’ajoutent rien et transmettent tel quel.

​
class ClientService {
  constructor(private readonly repo: ClientRepository) {}

  parId(id: number) { return this.repo.parId(id); }
  toutes()          { return this.repo.toutes(); }
  enregistrer(c: ClientEntity) { return this.repo.enregistrer(c); }
  // Mêmes noms, mêmes paramètres, mêmes retours : la couche ne porte rien
}
class ClientService:
    def __init__(self, repo: ClientRepository) -> None:
        self._repo = repo

    def par_id(self, id: int) -> ClientORM:
        return self._repo.par_id(id)

    def toutes(self) -> list[ClientORM]:
        return self._repo.toutes()

    def enregistrer(self, client: ClientORM) -> None:
        self._repo.enregistrer(client)
    # Mêmes noms, mêmes paramètres, mêmes retours : la couche ne porte rien
impl ClientService {
    pub fn par_id(&self, id: i64) -> Result<ClientRow, Erreur> {
        self.repo.par_id(id)
    }

    pub fn toutes(&self) -> Result<Vec<ClientRow>, Erreur> {
        self.repo.toutes()
    }

    pub fn enregistrer(&self, client: &ClientRow) -> Result<(), Erreur> {
        self.repo.enregistrer(client)
    }
    // Mêmes noms, mêmes paramètres, mêmes retours : la couche ne porte rien
}

Le critère est mesurable : si les méthodes du service ont les mêmes noms, les mêmes paramètres et les mêmes types de retour que celles du dépôt, la couche ne porte aucune information — et elle triple le coût de chaque ajout de champ.

Réparable sur place : supprimer la couche pour ces cas — c’est du layering relâché assumé. Mais c’est aussi un diagnostic à ne pas manquer : si tous vos services sont traversants, votre application est un CRUD, et c’est une information précieuse. Assumez-le au lieu de chercher à enrichir le modèle.

3. La règle dupliquée

Le premier signal qui touche à la correction, et non plus au confort. La règle du plafond client, écrite dans CommandeService, doit maintenant s’appliquer à l’avoir et au devis. Comme aucune couche ne la détient, elle est recopiée. Puis un jour le plafond devient glissant sur trente jours, et l’on corrige deux endroits sur trois.

C’est la conséquence directe de l’absence d’une couche domaine : le 3-tier place les règles dans les services, c’est-à-dire dans une couche d’orchestration, et une règle qui concerne plusieurs orchestrations n’a alors pas d’endroit.

Réparable sur place, et c’est le premier renforcement à faire : voir la section suivante.

4. Le service qui appelle un autre service

Le premier signal structurel : celui qu’on ne tord pas.

CommandeService a besoin de StockService, qui a besoin de TarifService. Les appels ne descendent plus, ils circulent horizontalement — ce que le modèle en couches n’a jamais prévu. Trois conséquences arrivent ensemble :

  • des cycles apparaissent, A appelle B qui rappelle A, et personne ne s’en aperçoit avant un débordement de pile ou un interblocage ;
  • la frontière transactionnelle devient illisible : qui ouvre la transaction, qui la valide, que se passe-t-il si le troisième service échoue ;
  • l’ordre des appels devient une règle métier implicite, écrite nulle part.

Aucun rangement de dossiers ne corrige cela, parce que le problème n’est pas le rangement : c’est que la couche service porte à la fois les règles et l’orchestration, deux responsabilités que le 3-tier ne distingue pas.

5. Le test qui exige une base de données

Vérifier la règle du plafond demande une base PostgreSQL, un jeu de données, et une transaction à annuler — parce que le service instancie ses dépôts et que les règles sont dans le service.

La progression est mécanique et se constate sur tous les projets qui l’atteignent : les tests deviennent lents, donc on en écrit moins ; ils deviennent instables — jeu de données partagé, ordre d’exécution — donc on les désactive ; et à la fin, les règles métier ne sont plus couvertes, donc plus refactorables. Le code se fossilise là où il aurait le plus besoin de bouger.

C’est le signal le plus grave des sept, et le plus facile à ignorer parce qu’il ne casse rien immédiatement.

6. Le modèle de persistance devenu contrat public

L’entité ORM circule du dépôt jusqu’à la réponse HTTP, sans traduction.

​
@Entity('commandes')
class CommandeEntity {
  @PrimaryGeneratedColumn() id!: number;
  @Column({ name: 'mnt_ttc_cts' }) mntTtcCts!: number;  // Colonne héritée de 2011
  @Column() supprime!: boolean;                         // Suppression logique
  @Column() version!: number;                           // Verrou optimiste
}

@Get(':id')
lire(@Param('id') id: number): Promise<CommandeEntity> {
  return this.repo.parId(id);          // Le schéma SQL devient le contrat public
}
class CommandeORM(Base):
    __tablename__ = "commandes"

    id: Mapped[int] = mapped_column(primary_key=True)
    mnt_ttc_cts: Mapped[int]      # Colonne héritée de 2011
    supprime: Mapped[bool]        # Suppression logique
    version: Mapped[int]          # Verrou optimiste


@app.get("/commandes/{id}")
def lire(id: int) -> CommandeORM:
    return session.get(CommandeORM, id)   # Le schéma SQL devient le contrat public
#[derive(Serialize, FromRow)]     // Sérialisé tel quel vers le client
struct CommandeRow {
    id: i64,
    mnt_ttc_cts: i64,             // Colonne héritée de 2011
    supprime: bool,               // Suppression logique
    version: i32,                 // Verrou optimiste
}

async fn lire(Path(id): Path<i64>, State(db): State<PgPool>) -> Json<CommandeRow> {
    let row = sqlx::query_as::<_, CommandeRow>("SELECT * FROM commandes WHERE id = $1")
        .bind(id)
        .fetch_one(&db)
        .await
        .unwrap();                // Le schéma SQL devient le contrat public
    Json(row)
}

Renommer une colonne casse alors les clients. Le verrou optimiste et l’indicateur de suppression logique, qui sont des détails d’implémentation, deviennent des éléments d’API publique. Les couches existent, mais elles ne protègent rien : la structure de la base traverse le bâtiment de bas en haut.

Partiellement réparable : introduire des DTO de sortie est mécanique, mais c’est du travail proportionnel au nombre d’endpoints déjà écrits — d’où l’intérêt de ne pas attendre ce signal.

7. Le deuxième point d’entrée

C’est très souvent l’événement déclencheur d’une migration, et il arrive brutalement : une tâche planifiée, un consommateur de file de messages ou une commande CLI doit appliquer les mêmes règles que l’API.

Si les règles sont dans les services, cela fonctionne — à condition que les services ne dépendent de rien de HTTP. Si une partie des règles a fui dans les contrôleurs — et le signal 1 dit qu’elle a probablement fui — il faut choisir entre les dupliquer et les extraire en urgence, sous contrainte de délai.

Où en êtes-vous ?

Signaux présentsSituationAction
1, 2ConfortDécouper les services par cas d’usage, assumer le CRUD là où il est
3VigilanceIntroduire une couche domaine
3 + 6TensionDomaine + DTO de sortie, en commençant par les endpoints publics
4 + 5Point de basculeInverser les dépendances sur le cœur
4, 5, 7 ensembleCoût structurelÉtrangler progressivement vers un hexagone

La combinaison 4 + 5 est le vrai seuil, et elle mérite d’être surveillée explicitement. Elle signifie que les règles n’ont nulle part où vivre et qu’on ne peut plus les vérifier. C’est la conjonction des deux qui est fatale : des règles mal rangées mais testées restent modifiables ; des règles bien rangées mais non testées le restent aussi. Les deux ensemble produisent du code que personne n’ose plus toucher.

Renforcer sans réécrire

Entre « 3-tier tel quel » et « architecture hexagonale complète », il existe une échelle intermédiaire, à gravir dans cet ordre. Chaque marche a un coût faible et un bénéfice immédiat, et beaucoup d’applications n’ont jamais besoin d’aller au bout.

1. Un service par cas d’usage. Remplacer CommandeService par ValiderCommande, AnnulerCommande. Coût nul, règle le signal 1, et rend visible la quantité réelle de logique par cas.

2. Des objets valeur. Remplacer montant: number par Montant, email: string par Email. C’est le meilleur rapport bénéfice/coût de toute la liste : aucun changement d’architecture, et une classe entière de bugs disparaît en même temps que les règles trouvent enfin un endroit. Voir Entités et objets valeur.

3. Une couche domaine — le passage à 4 couches. Insérer entre service et dépôt une couche qui contient les entités porteuses de comportement et les règles. Le service redevient de l’orchestration pure. C’est le remède au signal 3, et le 3-tier devient un 4-tier — sans rien inverser encore.

4. Le dépôt derrière une interface. Une seule application du principe d’inversion des dépendances : déclarer l’interface du dépôt dans la couche domaine, l’implémenter dans la persistance, l’injecter au lieu de l’instancier. C’est peu de travail, et cela règle le signal 5 — les règles deviennent testables sans base. Si vous ne devez faire qu’une chose de cette liste, faites celle-là.

5. Des DTO en sortie. Ne plus sérialiser l’entité ORM. Mécanique, proportionnel au nombre d’endpoints, règle le signal 6.

6. Le découpage par fonctionnalité. Passer de dossiers par couche à des dossiers par domaine fonctionnel, avec les couches à l’intérieur. Purement organisationnel, mais c’est ce qui permet ensuite d’extraire un module entier.

Arrivé à la marche 4, vous avez de fait un hexagone partiel. Le passage à l’architecture hexagonale complète n’est plus qu’une généralisation de ce qui existe déjà — et se fait module par module, jamais en une fois.

La limite structurelle

Tout ce qui précède se répare. Reste une limite qui ne se répare pas à l’intérieur du modèle, parce qu’elle en découle directement.

Dans une architecture en couches, la flèche pointe vers le bas : le service dépend du dépôt. Or ces deux couches n’ont pas la même durée de vie. Les règles de facturation valent dix ans ; le choix de PostgreSQL, du client HTTP ou du fournisseur de courriel change trois fois dans l’intervalle.

L’architecture rend donc la partie stable dépendante de la partie volatile, ce qui produit mécaniquement les signaux 5 et 6 : tester le métier exige l’infrastructure, et le vocabulaire de l’outil finit par remonter — des identifiants auto-incrémentés dans les règles, des null là où le domaine parlerait d’absence, des lots de mille lignes parce que c’est ce que la base préfère.

C’est précisément cette flèche que le principe d’inversion des dépendances permet de retourner, et que l’architecture hexagonale généralise à toute l’infrastructure.

Warning

Ce qui ne veut pas dire qu’il faut inverser par défaut. Une application dont le domaine est mince n’a rien à gagner à cette inversion : elle paie des ports, du mapping et de l’indirection pour protéger des règles qui n’existent pas. Le 3-tier n’est pas une étape immature qu’on dépasse — c’est le bon outil pour une catégorie d’applications très large, et la moitié des architectures hexagonales qu’on rencontre auraient mieux fait de rester en 3-tier.

Références