Vertical slice ou Clean Architecture en couches : pourquoi je range mon code .NET par cas d’usage
Vertical slice contre la Clean Architecture telle qu’on la construit le plus souvent en .NET, expliqué avec le back-office d’une guilde d’aventuriers : une feature par dossier, un domaine qui ne dépend de rien, et des tests pour que les règles tiennent dans le temps.
Tu connais sûrement ce projet .NET. Une solution en Clean Architecture, quatre projets : Domain, Application, Infrastructure, Api. Les dépendances pointent toutes vers le centre, c’est propre. Et pour ajouter un simple bouton « Accepter », tu ouvres sept fichiers répartis dans quatre projets. Sept fichiers, pour un bouton.
Attention, je n’ai rien contre la Clean Architecture elle-même. Ce qui me fatigue, c’est la façon dont on la construit dans la plupart des projets .NET : un projet par couche, un repository par entité et une interface devant chaque dépendance. Depuis quelques années, j’organise mes backends autrement : par cas d’usage plutôt que par couche technique, en gardant ce que la Clean a de meilleur. C’est ce qu’on appelle la vertical slice architecture. Sur mon projet actuel, le backend compte un peu plus de 300 slices, et je n’ai aucune envie de revenir en arrière.
Pour les exemples, on va s’amuser un peu et monter le back-office d’une guilde d’aventuriers : un tableau de quêtes, des aventuriers classés par rang, de l’or à la clé. Les patterns sont exactement ceux que j’utilise en prod, avec plus de dragons.
Ce que coûte la Clean Architecture en couches
La Clean Architecture, popularisée par Robert C. Martin (Uncle Bob pour les intimes), part d’une excellente intention : protéger le métier des détails techniques. Le domaine au centre, l’application autour, l’infrastructure et le web à l’extérieur, et une règle d’or : les dépendances pointent toujours vers le centre.
Cette idée, je la garde. Et rien dans cette règle n’impose de découper la solution en un projet par couche. Uncle Bob lui-même défend la Screaming Architecture : en ouvrant un projet, on devrait voir de quoi parle l’application, pas les outils qui la font tourner. Ouvre la solution d’un template Clean classique : elle crie surtout Domain, Application et Infrastructure.
C’est pourtant cette version en couches qu’on croise presque partout, et c’est elle que j’oppose à la vertical slice dans cet article. Voici la fonctionnalité « un aventurier accepte une quête » dans un template Clean classique :
Domain/Quests/Quest.cs
Application/Quests/Commands/AcceptQuest/AcceptQuestCommand.cs
Application/Quests/Commands/AcceptQuest/AcceptQuestCommandHandler.cs
Application/Quests/Commands/AcceptQuest/AcceptQuestCommandValidator.cs
Application/Common/Interfaces/IQuestRepository.cs
Infrastructure/Persistence/Repositories/QuestRepository.cs
Api/Endpoints/QuestEndpoints.csSept fichiers dans quatre projets pour une seule fonctionnalité. Et le handler n’a pas le droit de parler à la base : il passe par IQuestRepository, une interface qui n’a qu’une seule implémentation et qui grossit à chaque cas d’usage. GetByIdAsync, puis GetPostedQuestsForRankAsync pour le tableau, puis une méthode de plus pour le cas suivant. Au bout d’un an, elle en compte quarante, et chacune ne sert qu’à un seul endroit. Tout ça pour pouvoir changer d’ORM un jour, ce qui n’arrive à peu près jamais.
Le vrai souci, c’est que les changements arrivent à la verticale. Le maître de guilde ne te demande jamais « modifie la couche Application ». Il te demande « un aventurier blessé ne doit plus pouvoir accepter de quête ». Et ce changement-là traverse les projets d’un coup : la règle dans le domaine, une méthode de plus dans le repository et son implémentation, le handler à adapter. Le code est rangé à l’horizontale, le travail se fait à la verticale, et tu passes ta journée à zigzaguer.
Une slice, c’est un cas d’usage
L’idée a été popularisée par Jimmy Bogard (le créateur de MediatR et d’AutoMapper) en 2018, justement en réaction aux architectures découpées en couches, comme on les construisait souvent avec Clean ou Onion. Sa formule : « minimize coupling between slices, and maximize coupling in a slice ». Autrement dit, le moins de liens possible entre les slices, et tout ce qui va ensemble au même endroit.
Concrètement, une slice regroupe tout ce dont un cas d’usage a besoin : le contrat de la requête, l’endpoint, le handler, la validation. Voici comment est rangée notre guilde :
Api/
├── Domain/
│ ├── Adventurers/
│ │ └── Adventurer.cs
│ └── Quests/
│ └── Quest.cs
├── Features/
│ ├── Adventurers/
│ │ ├── PromoteAdventurer/
│ │ └── RegisterAdventurer/
│ └── Quests/
│ ├── AbandonQuest/
│ ├── AcceptQuest/
│ │ └── AcceptQuest.cs
│ ├── CompleteQuest/
│ ├── CompleteQuestAsParty/
│ ├── ListQuests/
│ ├── PostQuest/
│ └── QuestPayout.cs
└── Infrastructure/
├── Notifications/
└── Persistence/Le chemin suit toujours le même schéma : Features/<Ressource>/<CasDUsage>/. Tu lis la liste des dossiers, tu as la liste de ce que l’appli sait faire. Publier, accepter, abandonner, terminer, promouvoir : tout est écrit en toutes lettres, rien n’est planqué au fond d’un service.
À quoi ressemble une slice
Voici celle qui permet à un aventurier d’accepter une quête :
public sealed class AcceptQuest
{
public sealed record Request
{
[FromRoute] public Guid QuestId { get; set; }
[FromBody] public RequestBody Body { get; set; } = null!;
public sealed record RequestBody(Guid AdventurerId);
}
public sealed class Endpoint()
: PatchMinimalEndpoint<Request>("quests/{questId:guid}/accept");
public sealed class Handler(AppDbContext db, TimeProvider timeProvider)
{
public async Task Handle(Request request, CancellationToken ct)
{
var quest = await db.Quests.SingleOrDefaultAsync(x => x.Id == request.QuestId, ct)
?? throw new NotFoundException(QuestErrors.QuestNotFound);
var adventurer = await db.Adventurers.SingleOrDefaultAsync(x => x.Id == request.Body.AdventurerId, ct)
?? throw new NotFoundException(QuestErrors.AdventurerNotFound);
quest.AcceptBy(adventurer, timeProvider.GetUtcNow());
}
}
}Un fichier, moins de trente lignes, et tout y est. Le jour où quelqu’un se demande ce qui se passe quand un aventurier accepte une quête, il ouvre AcceptQuest.cs et il a sa réponse.
Tu as vu ? Le handler parle directement à EF Core, sans repository. En Clean Architecture, c’est le péché capital. Ici, je l’assume : le couplage existe, mais il reste enfermé dans la slice. Et puis EF Core est déjà une abstraction : le DbSet, c’est un repository, le DbContext, une unit of work. Pas la peine d’en empiler une deuxième par-dessus. Si une slice a un jour besoin d’une vraie abstraction, elle la crée pour elle, pas pour tout le monde.
Si la slice est aussi courte, c’est grâce à deux outils. QuickApi, ma bibliothèque open source, transforme la classe Endpoint en route documentée dans OpenAPI, sans une ligne de plus (et si tu te demandes où sont passés les controllers, c’est expliqué ici). Wolverine trouve le handler tout seul et l’enveloppe dans une transaction, d’où l’absence de SaveChangesAsync : la sauvegarde et les domain events partent au commit.
La slice orchestre, le domaine décide
C’est le contresens le plus fréquent sur la vertical slice : croire qu’elle remplace le domaine. Si tu mets toute ta logique métier dans tes handlers, tu n’as pas fait de la vertical slice, tu as fait des transaction scripts avec des dossiers bien rangés. Et la règle « un aventurier de rang E ne part pas affronter un dragon » finira copiée dans trois handlers, avec trois variantes.
Chez moi, c’est écrit noir sur blanc : une slice orchestre, elle ne porte pas les invariants métier. Dans AcceptQuest, le handler va chercher la quête et l’aventurier, puis il passe la main au domaine. C’est l’aggregate qui tranche :
public void AcceptBy(Adventurer adventurer, DateTimeOffset acceptedAt)
{
if (Status != QuestStatus.Posted)
throw new DomainException(QuestErrors.NotOnTheBoard);
// Un aventurier de rang E ne part pas affronter un dragon
if (adventurer.Rank < RequiredRank)
throw new DomainException(QuestErrors.RankTooLow);
Status = QuestStatus.Accepted;
AdventurerId = adventurer.Id;
AcceptedAt = acceptedAt;
}Vertical slice et DDD font très bon ménage. Le domaine protège les règles, la slice raconte le cas d’usage. C’est même là que je garde ce que la Clean Architecture a de meilleur : mon domaine ne connaît ni EF Core, ni ASP.NET, ni Wolverine. Et la slice AbandonQuest a beau ressembler beaucoup à AcceptQuest, ça ne me dérange pas : ce qui les distingue vit dans Quest.Abandon(), pas dans le handler.
Chaque slice fait à sa façon
Autre effet que j’adore : chaque slice choisit sa technique, et les autres n’en savent rien.
Une slice d’écriture charge l’aggregate complet, appelle ses méthodes et laisse EF Core suivre les changements. Une slice de lecture comme ListQuests, celle qui affiche le tableau de la guilde, n’a pas besoin de tout ça. Elle part d’un AsNoTracking(), applique ses filtres et projette directement vers la réponse paginée :
var query = db.Quests
.AsNoTracking()
.Where(x => x.Status == QuestStatus.Posted);
if (request.Region is not null)
query = query.Where(x => x.Region == request.Region);
if (request.MaxRank is not null)
query = query.Where(x => x.RequiredRank <= request.MaxRank);
// ... la récompense minimale, puis le tri et la paginationPas de IQuestRepository entre les deux, pas de méthode GetPostedQuestsByRegionAndRankAndMinimumReward dans une interface partagée par tout le monde. La requête vit là où elle sert. Si un jour le tableau rame, je peux le réécrire en SQL brut ou le brancher sur une vue matérialisée sans toucher à une seule autre slice.
C’est d’ailleurs pour ça que vertical slice et CQRS vont si bien ensemble : séparer les commands des queries devient naturel quand chaque cas d’usage a déjà son propre fichier.
Une slice n’appelle jamais une autre slice
C’est la règle qui fait tenir l’ensemble. Si CompleteQuestAsParty se met à appeler le handler de CompleteQuest, tu recrées des couches, juste en plus discret. Et le jour où tu modifies CompleteQuest, tu casses la version de groupe sans t’en rendre compte.
Quand deux slices ont besoin de la même chose, j’ai trois endroits possibles, dans cet ordre :
- Le domaine. Si c’est une règle métier, elle va dans l’aggregate. Neuf fois sur dix, l’histoire s’arrête là.
- Un fichier partagé au niveau de la ressource. Prends le paiement des récompenses : terminer une quête seul ou en groupe déclenche le versement de l’or. Cette logique vit une seule fois dans
Features/Quests/QuestPayout.cs, à côté des slices, et c’est le seul endroit du code qui ouvre le coffre de la guilde. Personne n’est payé deux fois. - L’infrastructure, pour la technique pure : générer un parchemin en PDF, envoyer un pigeon voyageur (un email, en vrai), appeler une API externe. La slice dépend d’une interface, jamais d’une autre slice.
Et parfois, la bonne réponse, c’est de dupliquer. Deux slices qui chargent une quête de la même façon, ce n’est pas un problème à régler : ce sont trois lignes qui se ressemblent aujourd’hui et qui divergeront sûrement demain. Sandi Metz l’a très bien résumé : la duplication coûte beaucoup moins cher qu’une mauvaise abstraction.
Par contre, méfie-toi du dossier Services ou Helpers qui réapparaît « juste pour ce cas-là ». Six mois plus tard, c’est un fichier de 2 000 lignes que tout le monde a peur de toucher.
Des règles qui se vérifient toutes seules
Il faut le reconnaître, la Clean en couches a un vrai atout : ses règles sont imposées par les références entre projets. Si Domain essaie d’utiliser EF Core, ça ne compile pas, point. Avec la vertical slice dans un seul projet, ce garde-fou disparaît. Alors je le remplace par des tests, qui tournent dans la CI à chaque pull request :
[Fact]
public void Slices_ShouldNotReferenceOtherSlices_WhenUsingFeatureTypes()
{
var violations = SourceFile.LoadAll()
.SelectMany(file => file.GetNamespaceReferences()
.Where(reference => ReferencesAnotherSlice(file, reference))
.Select(reference => $"{file.RelativePath} references {reference}"))
.ToArray();
violations.Should().BeEmpty();
}Le test parcourt les fichiers du projet, lit leurs using, et échoue dès qu’une slice importe le namespace d’une autre slice. Le même principe vérifie qu’aucun fichier ne traîne à la racine de Features. Il existe des bibliothèques pour ça, comme NetArchTest ou ArchUnitNET, mais quelques regex et xUnit m’ont largement suffi.
Ça compte d’autant plus que je bosse beaucoup avec des agents IA. Un agent qui ne sait pas où mettre un bout de code va te créer un Helpers sans l’ombre d’une hésitation. Avec les règles écrites dans le AGENTS.md et des tests qui cassent au moindre écart, il se corrige tout seul. J’en parle plus en détail dans mon article sur le monorepo et les agents IA.
Tester une slice
Une slice, c’est un cas d’usage, donc je la teste comme un cas d’usage : par l’API, avec une vraie base. Mes tests d’intégration démarrent un PostgreSQL avec Testcontainers, remettent la base à zéro avec Respawn entre deux tests, et appellent les endpoints en HTTP :
[Fact]
public async Task AcceptQuestEndpoint_ShouldRefuse_WhenAdventurerRankIsTooLow()
{
var client = Fixture.Factory.CreateClient();
var dragonQuestId = await PostQuestAsync(client, "Terrasser le dragon du mont Brûlé", requiredRank: "S");
var rookieId = await RegisterAdventurerAsync(client, "Gérard", rank: "E");
var response = await client.PatchAsJsonAsync($"/api/quests/{dragonQuestId}/accept", new { adventurerId = rookieId });
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var quest = await client.GetFromJsonAsync<QuestResponse>($"/api/quests/{dragonQuestId}");
quest!.Status.Should().Be("Posted");
}Désolé Gérard.
Pas de mock de repository, pas de mock de service, pour la bonne raison qu’il n’y en a pas. Le test vérifie ce que l’utilisateur verra, et il survit à n’importe quel refactoring interne de la slice. Les règles du domaine ont leurs propres tests unitaires, rapides et sans base. Et si tu veux savoir comment je mesure tout ça, j’ai écrit un article sur la couverture de code en .NET.
Alors, Clean en couches ou vertical slice ?
Bon, j’avoue, je force un peu le trait depuis le début. Mises côte à côte, voilà ce qui sépare vraiment la Clean telle qu’on la construit d’habitude et la vertical slice :
| Clean en couches | Vertical slice | |
|---|---|---|
| Découpage | Par couche technique | Par cas d’usage |
| Règle de dépendance | Partout : l’application ne connaît pas la base | Pour le domaine seulement |
| Abstractions | Systématiques (repositories, ports, adapters) | Au cas par cas, quand il y a un vrai besoin |
| Une fonctionnalité | Répartie sur trois ou quatre projets | Dans un seul fichier |
| Garde-fous | Le compilateur, via les références de projet | Des conventions et des tests d’architecture |
Ce qui brouille les pistes, c’est que les templates Clean les plus connus en .NET rangent déjà leur couche Application par features (Features/TodoItems/Commands/CreateTodoItem), avec un handler MediatR par cas d’usage. Ça ressemble à des slices. Mais l’endpoint reste dans le projet web, le handler dans Application et l’accès aux données dans Infrastructure : la fonctionnalité est toujours découpée en morceaux.
Ce que je fais, moi, c’est du « clean au centre, pragmatique autour » : un domaine pur, protégé comme le voudrait Uncle Bob, et des slices qui assument leur dépendance à la technique, puisqu’elle reste enfermée dans un seul fichier. Au passage, l’arborescence crie enfin ce que fait l’application.
Le découpage en couches reste un bon choix dans certains cas, comme une grosse équipe qui a besoin des garde-fous du compilateur, ou une infrastructure qui doit vraiment pouvoir être remplacée. Mais pour une appli métier classique, avec une API et une base de données, la vertical slice me fait écrire beaucoup moins de code pour le même résultat.
Ce qu’il faut savoir avant de te lancer
Tout n’est pas rose pour autant.
- La duplication se voit. Deux slices qui se ressemblent, ça pique les yeux au début. Il faut accepter que ce soit normal, et ne factoriser que quand une vraie règle commune apparaît.
- Il faut décider où va le code partagé. Domaine, fichier de ressource ou infrastructure : sans règle claire, chacun improvise, et les dossiers fourre-tout reviennent en douce.
- Sans tests d’architecture, ça ne tient pas sur la durée. Les conventions, tout le monde les respecte la première semaine. Six mois et trois devs plus tard (ou un agent IA pressé), une slice en appelle une autre et personne ne l’a vu passer en review. Mets les tests en place dès le premier jour : les ajouter sur un projet déjà lancé, c’est commencer par corriger toutes les violations accumulées entre-temps.
- Les dossiers se remplissent vite. Sur mon projet actuel, une seule ressource compte une quarantaine de slices, surtout parce que les formulaires sont sauvegardés au fil de l’eau, un bloc par slice. Chaque slice reste petite (entre 30 et 170 lignes), mais sans un nommage strict « verbe + ressource », tu t’y perds.
- Sur une petite API CRUD, le débat ne vaut pas grand-chose. Cinq écrans de saisie sans règle métier, ça tient dans n’importe quelle organisation, Clean ou pas.
Le mot de la fin
La vertical slice, c’est surtout un changement de point de vue : on range le code comme on reçoit les demandes.
Reprends le maître de guilde et son aventurier blessé. Dans la guilde rangée en slices, la règle va dans Quest.AcceptBy, et AcceptQuest.cs n’a même pas besoin de bouger, puisqu’il charge déjà l’aventurier. Pas d’interface à rallonger, pas de projet à traverser. Avec plus de 300 slices au compteur, c’est exactement ce qui me permet d’ajouter une fonctionnalité sans avoir peur de casser les autres.
Si ta solution en quatre projets commence à te ralentir, ou si tu veux un avis sur ton découpage, écris-moi. On en parle volontiers.