Architecture & conception

Couches, oignon, hexagonale, Clean

Ce qui les distingue vraiment, et quand chacune se justifie.

Vérifié en septembre 2026 · .NET 10 · environ 15 min

Couches, hexagonale, oignon, Clean : quatre manières de découper une application en blocs et de décider lequel a le droit de connaître lequel. Les trois dernières disent presque la même chose, et leurs auteurs l'écrivent eux-mêmes ; la première s'en sépare sur un point précis, le sens de la dépendance entre le métier et les données. Le cours relit chacune dans son texte d'origine, pour séparer ce qui les distingue de ce que la légende leur prête, puis montre quand aucune ne paie et ce qu'on leur oppose. Le fil rouge est une médiathèque et sa règle : un adhérent a au plus trois prêts en cours. Les exemples sont des projets .NET 10 créés par dotnet new, usings implicites compris, et plusieurs réunissent les fichiers de plusieurs projets ; ceux qui s'exécutent affichent ce que disent leurs commentaires.

Couches : la dépendance suit l'appel

Le découpage le plus répandu range le code en trois couches : présentation, domaine, accès aux données. Martin Fowler, qui le décrit en 2015 dans « Presentation Domain Data Layering », le tient pour une modularisation efficace, qu'il emploie et recommande : on pense à l'écran sans penser au SQL, et inversement. La règle est simple : une couche ne dépend que des couches du dessous, jamais de celles du dessus. Dans sa forme stricte, une couche n'appelle que sa voisine immédiate ; dans sa forme relâchée, toutes celles du dessous — distinction que des sources de seconde main rapportent au motif Layers de Buschmann et al., dans le premier volume de POSA (1996). Ce que la règle interdit est clair : le domaine ne connaît pas l'écran. Ce qu'elle impose l'est moins : le domaine connaît la base. Fowler signale lui-même la variante qui l'évite, un domaine qui ne dépend pas de ses sources de données, et note qu'on l'appelle souvent architecture hexagonale.

// ---- Mediatheque.Donnees/Donnees.cs : paquet Microsoft.EntityFrameworkCore.Sqlite 10.0.12
using Microsoft.EntityFrameworkCore;

namespace Mediatheque.Donnees;

// La couche du bas : la table des prets, telle que la base la voit.
public sealed class Pret
{
    public int Id { get; set; }

    public string Adherent { get; set; } = "";

    public string Livre { get; set; } = "";

    public bool Rendu { get; set; }
}

public sealed class ContexteMediatheque(DbContextOptions<ContexteMediatheque> options)
    : DbContext(options)
{
    public DbSet<Pret> Prets => Set<Pret>();
}

// ---- Mediatheque.Metier/Metier.cs : reference Mediatheque.Donnees
using Mediatheque.Donnees;
using Microsoft.EntityFrameworkCore;

namespace Mediatheque.Metier;

public sealed class ServicePrets(ContexteMediatheque contexte)
{
    public const int PretsMaximum = 3;

    // La regle de la mediatheque, ecrite dans les mots de la couche du
    // dessous : un DbContext, une entite EF, une requete traduite en SQL.
    public async Task<Pret> Emprunter(string adherent, string livre)
    {
        var enCours = await contexte.Prets.CountAsync(p => p.Adherent == adherent && !p.Rendu);
        if (enCours >= PretsMaximum)
        {
            throw new InvalidOperationException($"{adherent} a deja {enCours} prets en cours.");
        }

        var pret = new Pret { Adherent = adherent, Livre = livre };
        contexte.Prets.Add(pret);
        await contexte.SaveChangesAsync();
        return pret;
    }
}

// ---- Mediatheque.Console/Program.cs : reference Mediatheque.Metier, et lui seul
using Mediatheque.Donnees;
using Mediatheque.Metier;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

// La presentation ne reference que Metier, et nomme pourtant Donnees,
// EF Core et SQLite : la reference est transitive.
using var connexion = new SqliteConnection("Data Source=:memory:");
connexion.Open();
var options = new DbContextOptionsBuilder<ContexteMediatheque>().UseSqlite(connexion).Options;
await using var contexte = new ContexteMediatheque(options);
contexte.Database.EnsureCreated();

var service = new ServicePrets(contexte);
foreach (var livre in new[] { "Dune", "Fondation", "Hyperion", "Solaris" })
{
    try
    {
        var pret = await service.Emprunter("alice", livre);
        Console.WriteLine($"Pret {pret.Id} : {pret.Livre}");
    }
    catch (InvalidOperationException erreur)
    {
        Console.WriteLine(erreur.Message);
    }
}

// Pret 1 : Dune
// Pret 2 : Fondation
// Pret 3 : Hyperion
// alice a deja 3 prets en cours.

La règle de la médiathèque s'écrit avec un DbContext, une entité EF Core et CountAsync. La tester demande une base, ici SQLite en mémoire, et changer d'outil de persistance rouvre le métier. La console, qui ne référence que Mediatheque.Metier, nomme pourtant Mediatheque.Donnees, EF Core et SQLite : dans un projet SDK, les références de projet sont transitives. Jeffrey Palermo le résume : les dépendances transitives restent des dépendances. Quand le métier est mince, cela ne coûte rien ; la section sur le CRUD y revient.

Hexagonale : un dedans, un dehors, des ports

Alistair Cockburn publie « The Hexagonal (Ports & Adapters) Architecture » comme rapport technique daté du 4 septembre 2005 ; le motif s'y appelle Ports and Adapters, et « hexagonale » est son autre nom. Son point de départ est un échec répété : la logique métier s'infiltre dans l'interface, on crée une couche promise vierge, et faute de mécanisme qui détecte la violation, la couche se remplit en quelques années. Son remède : offrir chaque fonction de l'application par une API, que des tests de régression automatisés pilotent comme le ferait l'interface ; ce sont eux qui détectent la logique glissée dans la présentation. Le même mal frappe l'autre côté, quand le code ne tourne plus sans la base. Sa réponse : l'asymétrie qui compte n'est ni entre la gauche et la droite ni entre le haut et le bas, mais entre le dedans et le dehors. L'application parle au monde par des ports, chacun une conversation qui a un but, et pour chaque technologie un adaptateur traduit. Les ports primaires, ou pilotants, sont ceux par lesquels on pilote l'application, interface ou tests ; les secondaires, ou pilotés, ceux qu'elle pilote, base ou notification. Le critère est qui déclenche la conversation. Les six côtés, précise Cockburn, ne signifient rien : il rencontre d'ordinaire deux à quatre ports.

// ---- Mediatheque.Coeur/Coeur.cs : aucune reference, aucun paquet
namespace Mediatheque.Coeur;

public sealed record Pret(string Adherent, string Livre);

// Port secondaire, ou pilote : la conversation dont l'application a besoin,
// dans ses mots a elle. Rien n'y parle de SQL, de table ni d'EF Core.
public interface IPrets
{
    Task<int> CompterEnCours(string adherent);

    Task Enregistrer(Pret pret);
}

// Port primaire, ou pilotant : l'API publique de l'application. Qui la
// pilote (console, HTTP, test) la reference ; aucune interface n'est requise.
public sealed class Emprunts(IPrets prets)
{
    public const int PretsMaximum = 3;

    public async Task<Pret> Emprunter(string adherent, string livre)
    {
        var enCours = await prets.CompterEnCours(adherent);
        if (enCours >= PretsMaximum)
        {
            throw new InvalidOperationException($"{adherent} a deja {enCours} prets en cours.");
        }

        var pret = new Pret(adherent, livre);
        await prets.Enregistrer(pret);
        return pret;
    }
}

// ---- Mediatheque.Sqlite/Sqlite.cs : reference Mediatheque.Coeur, paquet EF Core Sqlite 10.0.12
using Mediatheque.Coeur;
using Microsoft.EntityFrameworkCore;

namespace Mediatheque.Sqlite;

// La ligne de table vit dans l'adaptateur : le coeur ne la voit jamais.
public sealed class LignePret
{
    public int Id { get; set; }

    public string Adherent { get; set; } = "";

    public string Livre { get; set; } = "";

    public bool Rendu { get; set; }
}

public sealed class ContextePrets(DbContextOptions<ContextePrets> options) : DbContext(options)
{
    public DbSet<LignePret> Prets => Set<LignePret>();
}

// Adaptateur secondaire : traduit la conversation du port en requetes.
public sealed class PretsSqlite(ContextePrets contexte) : IPrets
{
    public Task<int> CompterEnCours(string adherent) =>
        contexte.Prets.CountAsync(p => p.Adherent == adherent && !p.Rendu);

    public async Task Enregistrer(Pret pret)
    {
        contexte.Prets.Add(new LignePret { Adherent = pret.Adherent, Livre = pret.Livre });
        await contexte.SaveChangesAsync();
    }
}

// ---- Mediatheque.Console/Program.cs : reference Mediatheque.Coeur et Mediatheque.Sqlite
using Mediatheque.Coeur;
using Mediatheque.Sqlite;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

// Adaptateur primaire et racine de composition : seul projet a connaitre
// le coeur et tous ses adaptateurs.
using var connexion = new SqliteConnection("Data Source=:memory:");
connexion.Open();
var options = new DbContextOptionsBuilder<ContextePrets>().UseSqlite(connexion).Options;
await using var contexte = new ContextePrets(options);
contexte.Database.EnsureCreated();

// Le meme coeur, branche sur deux adaptateurs du meme port.
await Scenario(new Emprunts(new PretsEnMemoire()));
await Scenario(new Emprunts(new PretsSqlite(contexte)));

static async Task Scenario(Emprunts emprunts)
{
    foreach (var livre in new[] { "Dune", "Fondation", "Hyperion", "Solaris" })
    {
        try
        {
            var pret = await emprunts.Emprunter("alice", livre);
            Console.Write($"{pret.Livre}, ");
        }
        catch (InvalidOperationException erreur)
        {
            Console.WriteLine(erreur.Message);
        }
    }
}

// Dune, Fondation, Hyperion, alice a deja 3 prets en cours.
// Dune, Fondation, Hyperion, alice a deja 3 prets en cours.

// L'adaptateur en memoire : la base « mock » de Cockburn.
sealed class PretsEnMemoire : IPrets
{
    private readonly List<Pret> _prets = [];

    public Task<int> CompterEnCours(string adherent) =>
        Task.FromResult(_prets.Count(p => p.Adherent == adherent));

    public Task Enregistrer(Pret pret)
    {
        _prets.Add(pret);
        return Task.CompletedTask;
    }
}

Le port secondaire IPrets est déclaré par le cœur, dans ses mots, et l'adaptateur SQLite l'implémente en référençant le cœur : la flèche est retournée, et le cours sur SOLID montre la même inversion avec sa racine de composition. Côté primaire, aucune interface : la console appelle Emprunts directement, comme dans l'exemple de Cockburn, où le harnais de test instancie la classe de l'application. La dépendance y va déjà du dehors vers le dedans ; il n'y a rien à inverser. Le même cœur tourne sur deux adaptateurs du même port, en mémoire et sur SQLite, avec la même sortie : c'est l'intention même du motif, une application développée et testée à l'écart de ses bases et de ses périphériques. L'article, en revanche, ne dit rien de l'intérieur de l'hexagone. Il n'y range ni domaine ni couche application.

Oignon : des couches, tournées vers le centre

Jeffrey Palermo nomme l'architecture en oignon dans un billet du 29 juillet 2008, sans la prétendre tout à fait nouvelle : il cite l'hexagonale de Cockburn et partage sa prémisse, externaliser l'infrastructure derrière du code d'adaptation. Sa règle : tout code peut dépendre des couches plus centrales, aucun de celles plus extérieures. Au centre, le modèle du domaine, qui ne dépend que de lui-même ; autour, un premier anneau qui porte d'ordinaire les interfaces des dépôts ; au bord, l'interface, l'infrastructure et les tests. Le nombre d'anneaux du cœur varie. Le troisième billet, du 4 août, fixe quatre principes : l'application est bâtie autour d'un modèle objet indépendant ; les couches intérieures définissent les interfaces, les extérieures les implémentent ; le couplage va vers le centre ; tout le cœur se compile et s'exécute sans l'infrastructure. Il y oppose l'oignon aux couches strictes : une couche extérieure peut appeler n'importe quelle couche intérieure, ce que les couches relâchées permettaient déjà. Et il assume un parti pris, que Martin ne reprendra pas : l'architecture penche sans complexe vers l'objet, et met les objets avant tout.

// ---- Mediatheque.Domaine/Adherent.cs : aucune reference
namespace Mediatheque.Domaine;

// Le centre de l'oignon : un modele qui porte son etat et sa regle.
// Il ne depend que de lui-meme.
public sealed class Adherent(string nom)
{
    public const int PretsMaximum = 3;

    private readonly List<string> _enCours = [];

    public string Nom { get; } = nom;

    public IReadOnlyList<string> EnCours => _enCours.AsReadOnly();

    public bool PeutEmprunter => _enCours.Count < PretsMaximum;

    public void Emprunter(string livre)
    {
        if (!PeutEmprunter)
        {
            throw new InvalidOperationException($"{Nom} a deja {_enCours.Count} prets en cours.");
        }

        _enCours.Add(livre);
    }
}

// ---- Mediatheque.Application/IAdherents.cs : reference Mediatheque.Domaine
using Mediatheque.Domaine;

namespace Mediatheque.Application;

// L'anneau autour du modele : l'interface du depot. Seule l'interface est
// dans le coeur ; l'implementation, qui parle a une base, est au bord.
public interface IAdherents
{
    Task<Adherent> Charger(string nom);

    Task Enregistrer(Adherent adherent);
}

Ce que l'oignon ajoute à l'hexagone est une structure du dedans. Le modèle objet, au centre, porte la règle : PeutEmprunter ne dépend de rien. Le cours sur le DDD tactique place l'interface du repository dans le domaine, à côté de l'agrégat ; Palermo la met dans le premier anneau autour du modèle. Les deux respectent la règle, qui ne porte que sur le sens des flèches.

Clean : la règle de dépendance, et ce qui franchit les frontières

Robert C. Martin publie « The Clean Architecture » le 13 août 2012. Il y cite l'hexagonale, l'oignon, sa propre Screaming Architecture, DCI et BCE, écrit qu'elles diffèrent dans le détail mais se ressemblent beaucoup, et présente son schéma comme une tentative de les intégrer en une seule idée. Il nomme la règle qu'il lit dans toutes, la règle de dépendance : les dépendances de code source ne pointent que vers l'intérieur, et rien dans un cercle intérieur ne mentionne un nom déclaré dans un cercle extérieur, ni ses formats de données. Ses quatre cercles vont des entités, règles de l'entreprise ou objets métier d'une application seule, aux cas d'usage, règles propres à l'application, puis aux adaptateurs d'interface, contrôleurs, présentateurs et conversions vers la base, et enfin aux frameworks et pilotes. Les cercles sont schématiques et peuvent être plus de quatre ; la règle, elle, vaut toujours. Ce qu'il ajoute tient en trois points. Les entités, distinctes des cas d'usage, sont neutres quant au paradigme : un objet avec ses méthodes ou des structures de données et des fonctions, peu importe, là où Palermo place un modèle objet au centre. Ce qui franchit une frontière est une structure simple, jamais une entité ni une ligne de base. Et la sortie, que Cockburn faisait déjà passer par un port vers un adaptateur, prend chez lui la forme d'un port de sortie du cas d'usage, implémenté par le présentateur.

// ---- Mediatheque.Application/EmprunterLivre.cs : reference Mediatheque.Domaine
using Mediatheque.Domaine;

namespace Mediatheque.Application;

// Ce qui franchit la frontiere : des structures simples, pas l'Adherent.
public sealed record DemandeEmprunt(string Adherent, string Livre);

public sealed record EmpruntAccepte(string Livre, int PretsRestants);

// Port de sortie : declare dans le cercle du cas d'usage, implemente
// dehors par le presentateur. Le controle sort, la dependance entre.
public interface ISortieEmprunt
{
    void Accepte(EmpruntAccepte reponse);

    void Refuse(string motif);
}

public sealed class EmprunterLivre(IAdherents adherents, ISortieEmprunt sortie)
{
    public async Task Executer(DemandeEmprunt demande)
    {
        var adherent = await adherents.Charger(demande.Adherent);
        if (!adherent.PeutEmprunter)
        {
            sortie.Refuse($"{adherent.Nom} a deja {adherent.EnCours.Count} prets en cours.");
            return;
        }

        adherent.Emprunter(demande.Livre);
        await adherents.Enregistrer(adherent);
        sortie.Accepte(new EmpruntAccepte(demande.Livre, Adherent.PretsMaximum - adherent.EnCours.Count));
    }
}

// ---- Mediatheque.Console/Program.cs : reference Mediatheque.Application
using Mediatheque.Application;
using Mediatheque.Domaine;

var cas = new EmprunterLivre(new AdherentsEnMemoire(), new PresentateurConsole());
foreach (var livre in new[] { "Dune", "Fondation", "Hyperion", "Solaris" })
{
    await cas.Executer(new DemandeEmprunt("alice", livre));
}

// Dune emprunte, prets restants : 2
// Fondation emprunte, prets restants : 1
// Hyperion emprunte, prets restants : 0
// Refus : alice a deja 3 prets en cours.

// Le presentateur, dans le cercle des adaptateurs : il met en forme,
// il ne decide rien.
sealed class PresentateurConsole : ISortieEmprunt
{
    public void Accepte(EmpruntAccepte reponse) =>
        Console.WriteLine($"{reponse.Livre} emprunte, prets restants : {reponse.PretsRestants}");

    public void Refuse(string motif) => Console.WriteLine($"Refus : {motif}");
}

sealed class AdherentsEnMemoire : IAdherents
{
    private readonly Dictionary<string, Adherent> _adherents = [];

    public Task<Adherent> Charger(string nom) =>
        Task.FromResult(_adherents.TryGetValue(nom, out var adherent) ? adherent : new Adherent(nom));

    public Task Enregistrer(Adherent adherent)
    {
        _adherents[adherent.Nom] = adherent;
        return Task.CompletedTask;
    }
}

EmprunterLivre ne rend pas l'Adherent : EmpruntAccepte est taillé pour la frontière, et le présentateur met en forme sans rien décider. Le contrôle va du cas d'usage vers la console, la dépendance de la console vers le cas d'usage : c'est l'inversion appliquée au côté primaire. Une valeur de retour respecterait aussi la règle, puisque l'appelant dépend déjà du cas d'usage ; le port de sortie sert quand le cas d'usage doit piloter lui-même la présentation sans la connaître.

Ce qui les distingue vraiment

CouchesHexagonale (2005)Oignon (2008)Clean (2012)
Sens des dépendancesvers le bas, donc vers les donnéesle dedans ignore la nature du dehors, sans règle de code source formuléevers le centrevers l'intérieur : la règle de dépendance
Intérieur prescritles couches elles-mêmesaucun : un dedansmodèle au centre, anneaux autour, en nombre variableentités, puis cas d'usage, cercles schématiques
Apport propreséparer les préoccupations, en forme stricte ou relâchée dedans et dehors symétriques, ports par intention, primaires et secondaires, tests comme adaptateur primaire modèle objet au centre, anneaux nommés, interfaces de dépôt dans le premier entités distinctes des cas d'usage et neutres en paradigme, données simples aux frontières, présentateur derrière un port de sortie

Les trois derniers partagent l'essentiel : le métier ignore l'infrastructure, qui s'y branche par des interfaces qu'il déclare, et le cœur se compile et se teste sans elle. Cockburn le pose en intention, sans règle de code source : il range l'inversion des dépendances parmi les motifs voisins, et son exemple importe l'interface du dépôt d'un paquet qui contient aussi la fabrique du mock. Martin y lit la même règle. Le guide de Microsoft « Architect Modern Web Applications with ASP.NET Core and Azure » en tire la conclusion : il les traite comme une seule architecture connue sous plusieurs noms, et retient celui de Clean. Ce que la légende leur prête n'est pas une différence : ni la forme, puisque Cockburn dit ses six côtés arbitraires et Martin ses cercles schématiques, ni le nombre de couches, ni le nom des projets. Les différences réelles sont des différences de portée. L'hexagonale ne parle que de la frontière ; l'oignon structure le dedans autour d'un modèle objet ; Clean distingue les entités des cas d'usage, reste neutre sur le paradigme et légifère sur ce qui passe les frontières. Dans une solution .NET, oignon et Clean produisent d'ailleurs souvent le même graphe : le projet Mediatheque.Domaine est le centre de l'un et le cercle des entités de l'autre.

L'écart qui change le graphe des projets est donc celui des couches, et il se prouve sans diagramme. Des dossiers d'un même projet laissent tout passer ; des projets distincts font respecter le sens des références par le compilateur :

# SDK .NET 10.0.300, messages en anglais (DOTNET_CLI_UI_LANGUAGE=en).

# En couches : le metier reference les donnees.
dotnet reference list --project couches/Mediatheque.Metier
# Project reference(s)
# --------------------
# ..\Mediatheque.Donnees\Mediatheque.Donnees.csproj

# En hexagone : le coeur ne reference rien, l'adaptateur reference le coeur.
dotnet reference list --project hexagonale/Mediatheque.Coeur
# There are no Project to Project references in project hexagonale/Mediatheque.Coeur.
dotnet reference list --project hexagonale/Mediatheque.Sqlite
# Project reference(s)
# --------------------
# ..\Mediatheque.Coeur\Mediatheque.Coeur.csproj

# Un fichier du coeur qui ecrit « using Mediatheque.Sqlite; » ne compile pas :
#   error CS0234: The type or namespace name 'Sqlite' does not exist in the
#   namespace 'Mediatheque' (are you missing an assembly reference?)

# Et la reference inverse, que la commande accepte, rend la solution inconstructible :
dotnet reference add hexagonale/Mediatheque.Sqlite --project hexagonale/Mediatheque.Coeur
dotnet build hexagonale/Mediatheque.Console
#   error MSB4006: There is a circular dependency in the target dependency graph
#   involving target "_GenerateRestoreProjectPathWalk".

Le compilateur ne voit pas pour autant la logique métier écrite dans le projet d'interface, qui référence légitimement le cœur : cette détection-là, Cockburn la confie aux tests qui pilotent l'API. Le projet hôte référence le cœur et ses adaptateurs, et c'est permis : il est la racine de composition, et le guide de Microsoft note que le projet d'interface d'une application ASP.NET Core peut devoir référencer l'infrastructure pour enregistrer les services.

Quand aucun ne paie : l'application CRUD

Palermo l'écrit dès son premier billet : l'oignon ne convient pas aux petits sites web, il convient aux applications métier qui durent et à celles dont le comportement est complexe. Fowler remarque qu'on invoque souvent la substitution de l'accès aux données, mais qu'il entend rarement parler de quelqu'un qui l'a faite ; il garderait pourtant les couches sans elle, parce qu'elles réduisent le champ d'attention, et admet qu'un petit programme les range dans des fichiers distincts. Une fiche adhérent qu'on lit et qu'on modifie n'a aucune règle à protéger. Découpée en quatre projets concentriques, elle donne un code juste — un adhérent absent répond 404 —, mais ce qui cloche est ce qu'il coûte : rien, dans ces projets, ne fait autre chose que recopier.

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;

// Quatre projets montres ensemble : chaque bloc namespace en est un.
// Le besoin : lire et modifier le nom et le courriel d'un adherent.

namespace Fiches.Domaine
{
    // Une « entite » sans regle : des proprietes, et rien a proteger.
    public sealed class Adherent
    {
        public int Id { get; set; }

        public string Nom { get; set; } = "";

        public string Courriel { get; set; } = "";
    }

    public interface IAdherentRepository
    {
        Task<Adherent?> Lire(int id);

        Task<bool> Modifier(Adherent adherent);
    }
}

namespace Fiches.Application
{
    using Fiches.Domaine;

    public sealed record AdherentDto(int Id, string Nom, string Courriel);

    public interface IAdherentService
    {
        Task<AdherentDto?> Lire(int id);

        Task<bool> Modifier(AdherentDto dto);
    }

    // Un service qui recopie : l'entite vers le DTO, le DTO vers l'entite.
    public sealed class AdherentService(IAdherentRepository depot) : IAdherentService
    {
        public async Task<AdherentDto?> Lire(int id) =>
            await depot.Lire(id) is { } a ? new AdherentDto(a.Id, a.Nom, a.Courriel) : null;

        public Task<bool> Modifier(AdherentDto dto) =>
            depot.Modifier(new Adherent { Id = dto.Id, Nom = dto.Nom, Courriel = dto.Courriel });
    }
}

namespace Fiches.Infrastructure
{
    using Fiches.Domaine;

    // La ligne de table : la meme chose une troisieme fois.
    public sealed class AdherentLigne
    {
        public int Id { get; set; }

        public string Nom { get; set; } = "";

        public string Courriel { get; set; } = "";
    }

    public sealed class ContexteFiches(DbContextOptions<ContexteFiches> options) : DbContext(options)
    {
        public DbSet<AdherentLigne> Adherents => Set<AdherentLigne>();
    }

    public sealed class AdherentRepository(ContexteFiches contexte) : IAdherentRepository
    {
        public async Task<Adherent?> Lire(int id) =>
            await contexte.Adherents.FindAsync(id) is { } l
                ? new Adherent { Id = l.Id, Nom = l.Nom, Courriel = l.Courriel }
                : null;

        public async Task<bool> Modifier(Adherent adherent)
        {
            var ligne = await contexte.Adherents.FindAsync(adherent.Id);
            if (ligne is null)
            {
                return false;
            }

            ligne.Nom = adherent.Nom;
            ligne.Courriel = adherent.Courriel;
            await contexte.SaveChangesAsync();
            return true;
        }
    }
}

namespace Fiches.Web
{
    using Fiches.Application;
    using Fiches.Domaine;
    using Fiches.Infrastructure;

    public static class PointsFiches
    {
        // Appelee par Program.cs : builder.Services.AjouterFiches();
        // Le projet Web reference les trois autres pour ces enregistrements.
        public static void AjouterFiches(this IServiceCollection services)
        {
            services.AddDbContext<ContexteFiches>(o => o.UseSqlite("Data Source=fiches.db"));
            services.AddScoped<IAdherentRepository, AdherentRepository>();
            services.AddScoped<IAdherentService, AdherentService>();
        }

        // Appelee par Program.cs : app.MapFiches();
        public static void MapFiches(this WebApplication app)
        {
            app.MapGet("/adherents/{id:int}", async (int id, IAdherentService service) =>
                await service.Lire(id) is { } dto ? Results.Ok(dto) : Results.NotFound());

            app.MapPut("/adherents/{id:int}", async (int id, AdherentDto dto, IAdherentService service) =>
                await service.Modifier(dto with { Id = id }) ? Results.NoContent() : Results.NotFound());
        }
    }
}

La même forme est écrite trois fois, et chaque échange la recopie. Deux interfaces n'ont qu'une implémentation, et IAdherentService recopie AdherentService : le cours sur SOLID montre qu'un tel couple n'inverse rien. Le projet Web référence les trois autres pour enregistrer le contexte, le dépôt et le service. Ajouter un téléphone à la fiche touche sept endroits : les trois types et les quatre recopies. La version juste garde la séparation que Fowler défend, à la granularité qui convient, deux fichiers d'un projet :

// ---- Program.cs : projet Web, paquet Microsoft.EntityFrameworkCore.Sqlite 10.0.12
using Microsoft.EntityFrameworkCore;

// Le meme besoin, en un projet : le DbContext est deja un depot et une
// unite de travail, il n'y a rien a inverser ni a recopier.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<ContexteFiches>(o => o.UseSqlite("Data Source=fiches.db"));

var app = builder.Build();

// Pour l'exemple ; une application reelle applique ses migrations.
using (var portee = app.Services.CreateScope())
{
    portee.ServiceProvider.GetRequiredService<ContexteFiches>().Database.EnsureCreated();
}

// La lecture rend l'entite telle quelle : pour une fiche, sa forme est la
// reponse. L'ecriture prend un record, pour que le client ne fixe pas l'Id.
app.MapGet("/adherents/{id:int}", async (int id, ContexteFiches contexte) =>
    await contexte.Adherents.FindAsync(id) is { } adherent ? Results.Ok(adherent) : Results.NotFound());

app.MapPut("/adherents/{id:int}", async (int id, ModificationFiche modification, ContexteFiches contexte) =>
{
    var adherent = await contexte.Adherents.FindAsync(id);
    if (adherent is null)
    {
        return Results.NotFound();
    }

    adherent.Nom = modification.Nom;
    adherent.Courriel = modification.Courriel;
    await contexte.SaveChangesAsync();
    return Results.NoContent();
});

app.Run();

public sealed record ModificationFiche(string Nom, string Courriel);

// ---- Donnees.cs
using Microsoft.EntityFrameworkCore;

// La couche de donnees de cette fiche : un fichier, pas un projet.
public sealed class Adherent
{
    public int Id { get; set; }

    public string Nom { get; set; } = "";

    public string Courriel { get; set; } = "";
}

public sealed class ContexteFiches(DbContextOptions<ContexteFiches> options) : DbContext(options)
{
    public DbSet<Adherent> Adherents => Set<Adherent>();
}

La documentation d'EF Core le dit de DbContext : il combine les motifs unité de travail et repository. L'envelopper d'un dépôt, pour une fiche, n'isole rien qu'il ne fasse déjà. La lecture rend l'entité, dont la forme est la réponse ; l'écriture prend un record sans identifiant, que le client ne peut donc pas fixer. Le même téléphone touche trois endroits. Le cours sur le DDD tactique fait ce constat pour le modèle riche, disproportionné dans un back-office qui édite des fiches. Le signal pour changer de style est l'apparition d'une règle, comme la limite de prêts, pas la crainte qu'elle apparaisse.

Vertical slices : découper par requête

Jimmy Bogard décrit l'alternative dans « Vertical Slice Architecture », le 19 avril 2018. Son équipe avait démarré en oignon et l'avait quitté en quelques mois : plates ou concentriques, les couches restent des couches, et une fonctionnalité nouvelle en traverse toutes. Il organise donc l'application autour des requêtes, chacune regroupant ce qu'elle demande de l'entrée à la base, et couple le code selon l'axe du changement : couplage minimal entre les tranches, maximal dans une tranche. Les abstractions partagées, dépôts et services, disparaissent pour la plupart, et chaque tranche choisit sa façon de répondre, simple script de transaction ou modèle du domaine. Il pose une condition : l'équipe doit reconnaître les mauvaises odeurs du code et savoir remanier, sans quoi le style ne lui convient pas. Fowler, en 2015, allait dans le même sens : quand une application grandit, le premier niveau de modules suit le domaine, et chaque module se découpe en couches à l'intérieur.

// ---- Mediatheque.Web/Contexte.cs : projet Web, paquet EF Core Sqlite 10.0.12
using Microsoft.EntityFrameworkCore;

namespace Mediatheque.Web;

// Ce que les tranches partagent : l'outil, pas une couche.
public sealed class Pret
{
    public int Id { get; set; }

    public string Adherent { get; set; } = "";

    public string Livre { get; set; } = "";

    public bool Rendu { get; set; }
}

public sealed class ContexteMediatheque(DbContextOptions<ContexteMediatheque> options)
    : DbContext(options)
{
    public DbSet<Pret> Prets => Set<Pret>();
}

// ---- Mediatheque.Web/Prets/Emprunter.cs
using Microsoft.EntityFrameworkCore;

namespace Mediatheque.Web.Prets;

// Une tranche : tout ce que « emprunter un livre » demande, de la route a
// la base, dans un fichier. Elle ne partage que le DbContext.
public static class Emprunter
{
    public const int PretsMaximum = 3;

    public sealed record Demande(string Adherent, string Livre);

    public sealed record Reponse(int Id, string Livre);

    public static void Map(IEndpointRouteBuilder routes) =>
        routes.MapPost("/prets", Traiter);

    private static async Task<IResult> Traiter(Demande demande, ContexteMediatheque contexte)
    {
        var enCours = await contexte.Prets.CountAsync(p => p.Adherent == demande.Adherent && !p.Rendu);
        if (enCours >= PretsMaximum)
        {
            return Results.Problem(
                $"{demande.Adherent} a deja {enCours} prets en cours.",
                statusCode: StatusCodes.Status409Conflict);
        }

        var pret = new Pret { Adherent = demande.Adherent, Livre = demande.Livre };
        contexte.Prets.Add(pret);
        await contexte.SaveChangesAsync();
        return Results.Created($"/prets/{pret.Id}", new Reponse(pret.Id, pret.Livre));
    }
}

// ---- Mediatheque.Web/Prets/ListerEnCours.cs
using Microsoft.EntityFrameworkCore;

namespace Mediatheque.Web.Prets;

// Une autre tranche, un autre choix : une lecture n'a pas de regle,
// elle projette directement ce que l'ecran affiche.
public static class ListerEnCours
{
    public sealed record Ligne(string Livre);

    public static void Map(IEndpointRouteBuilder routes) =>
        routes.MapGet("/adherents/{adherent}/prets", Traiter);

    private static async Task<List<Ligne>> Traiter(string adherent, ContexteMediatheque contexte) =>
        await contexte.Prets
            .Where(p => p.Adherent == adherent && !p.Rendu)
            .Select(p => new Ligne(p.Livre))
            .ToListAsync();
}

// ---- Mediatheque.Web/Program.cs
using Mediatheque.Web;
using Mediatheque.Web.Prets;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<ContexteMediatheque>(o => o.UseSqlite("Data Source=mediatheque.db"));

var app = builder.Build();
using (var portee = app.Services.CreateScope())
{
    portee.ServiceProvider.GetRequiredService<ContexteMediatheque>().Database.EnsureCreated();
}

Emprunter.Map(app);
ListerEnCours.Map(app);
app.Run();

// Quatre POST /prets pour alice, ASP.NET Core 10 : 201, 201, 201, puis
//   409 {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.10","title":"Conflict",
//        "status":409,"detail":"alice a deja 3 prets en cours."}
// GET /adherents/alice/prets :
//   200 [{"livre":"Dune"},{"livre":"Fondation"},{"livre":"Hyperion"}]

L'emprunt vérifie sa règle et répond 409 avec un ProblemDetails ; la liste projette directement les colonnes qu'elle affiche. Bogard juge rarement utiles les règles rigides de dépendance des architectures en couches, en oignon ou Clean. Le cours s'écarte ici de lui : les tranches organisent l'application par fonctionnalité, oignon et Clean le sens des dépendances, et rien n'empêche une tranche d'appeler un modèle riche derrière un port. La constante PretsMaximum dans la tranche est le bon point de départ, et le jour où la prolongation d'un prêt aura besoin de la même règle, c'est l'odeur qui dit de la pousser dans un modèle du domaine.

Ce cours vous a servi ? Offrir un café Signaler une erreur