L'architecture, c'est le rangement du code : quoi va où, et qui a le droit de dépendre de qui. Bien rangé, on peut changer de base de données ou ajouter une fonctionnalité sans tout casser. Les règles métier sont au centre, les détails techniques (base, HTTP, e-mails) autour.
Choisir un style Senior
| Style | Principe | Adapté à | Coût |
|---|---|---|---|
| N-tiers classique | UI → BLL → DAL | CRUD simple, applis internes | Le métier fuit vers la DAL et l'UI |
| Clean / Onion / Hexagonale | Le domaine au centre, l'infrastructure en périphérie | Métier riche, longue durée de vie | Plus de projets et d'abstractions |
| Vertical Slice | Un dossier par cas d'usage, de l'endpoint à la requête SQL | API orientées fonctionnalités | Duplication tolérée, discipline requise |
| Monolithe modulaire | Un déploiement, des modules isolés communiquant par contrats | Défaut recommandé pour la plupart des équipes | Frontières à faire respecter par tests |
| Microservices | Un service = un déploiement = une base | Plusieurs équipes autonomes, besoins de scalabilité distincts | Réseau, cohérence éventuelle, observabilité, exploitation |
La règle de dépendance Confirmé
Arborescence type d'une solution
src/
Shop.Domain/ # Entités, Value Objects, événements de domaine — zéro package NuGet
Shop.Application/ # Commandes, requêtes, handlers, validateurs, interfaces (ports)
Shop.Infrastructure/ # DbContext EF Core, repositories, clients HTTP, messaging (adaptateurs)
Shop.Api/ # Endpoints, middleware, composition DI, Program.cs
tests/
Shop.Domain.Tests/
Shop.Application.Tests/
Shop.Api.IntegrationTests/ # WebApplicationFactory + Testcontainers
Shop.ArchitectureTests/ # vérifie la règle de dépendance
Directory.Build.props
Directory.Packages.props # versions NuGet centralisées
global.json
DDD : le vocabulaire utile Senior
Stratégique
Bounded Context : frontière où un terme a un sens unique (« Client » en facturation ≠ « Client » au support). Langage ubiquitaire : le code reprend les mots des experts métier. Context Map : relations entre contextes (anti-corruption layer, shared kernel).
Tactique
Entité : identité stable. Value Object : défini par ses valeurs, immuable (record). Agrégat : grappe cohérente modifiée via sa racine, une transaction = un agrégat. Événement de domaine : fait métier passé.
public sealed record Money(decimal Amount, string Currency) // Value Object
{
public static Money operator +(Money a, Money b) =>
a.Currency == b.Currency ? a with { Amount = a.Amount + b.Amount }
: throw new InvalidOperationException("Devises différentes");
}
public sealed class Order : AggregateRoot<OrderId> // Racine d'agrégat
{
private readonly List<OrderLine> _lines = [];
public IReadOnlyList<OrderLine> Lines => _lines;
public OrderStatus Status { get; private set; } = OrderStatus.Draft;
public void AddLine(ProductId product, int qty, Money unitPrice)
{
if (Status != OrderStatus.Draft) throw new DomainException("Commande déjà validée");
if (qty <= 0) throw new DomainException("Quantité invalide");
_lines.Add(new OrderLine(product, qty, unitPrice));
}
public void Submit()
{
if (_lines.Count == 0) throw new DomainException("Commande vide");
Status = OrderStatus.Submitted;
Raise(new OrderSubmitted(Id)); // événement de domaine
}
}
CQRS et pipeline de handlers Confirmé
Séparer les commandes (modifient l'état, passent par le domaine) des requêtes (lisent, peuvent projeter directement en DTO avec AsNoTracking ou Dapper). Un médiateur ajoute des comportements transverses autour de chaque handler : validation, journalisation, transaction.
public sealed record SubmitOrder(Guid OrderId) : IRequest<Result>;
public sealed class SubmitOrderHandler(IOrderRepository orders, IUnitOfWork uow)
: IRequestHandler<SubmitOrder, Result>
{
public async Task<Result> Handle(SubmitOrder cmd, CancellationToken ct)
{
var order = await orders.GetAsync(new OrderId(cmd.OrderId), ct);
if (order is null) return Result.NotFound();
order.Submit();
await uow.SaveChangesAsync(ct); // publie aussi les événements via l'outbox
return Result.Success();
}
}
// Comportement transverse : validation FluentValidation avant chaque handler
public sealed class ValidationBehavior<TReq, TRes>(IEnumerable<IValidator<TReq>> validators)
: IPipelineBehavior<TReq, TRes> where TReq : notnull
{
public async Task<TRes> Handle(TReq req, RequestHandlerDelegate<TRes> next, CancellationToken ct)
{
foreach (var v in validators) await v.ValidateAndThrowAsync(req, ct);
return await next();
}
}
MediatR et AutoMapper sont passés sous licence commerciale en 2025 pour leurs versions récentes (avec une édition gratuite sous conditions). Vérifiez la licence avant une montée de version. Alternatives : le générateur de sources Mediator (martinothamar), Wolverine, ou de simples handlers injectés — souvent suffisants. Pour le mapping, un mapping manuel ou Mapperly (source generator) évite la réflexion.
Garder une trace des décisions
Un ADR (Architecture Decision Record) est un fichier Markdown court dans le dépôt : contexte, décision, alternatives écartées, conséquences. Dans deux ans, il répond à « pourquoi c'est fait comme ça ? » sans archéologie.
- Microservices prématurés : un monolithe distribué, avec les inconvénients des deux.
- Repository générique au-dessus d'EF Core :
DbContextest déjà un Unit of Work etDbSetun repository. Un repository par agrégat, avec des méthodes métier, apporte de la valeur ; unIRepository<T>générique non. - Modèle anémique : des entités à propriétés publiques et toute la logique dans des services. On perd les invariants.
- Base partagée entre services : couplage par le schéma, aucune indépendance de déploiement.
À retenir
- Monolithe modulaire par défaut, découpage en services quand une contrainte réelle l'exige.
- Le domaine ne dépend de rien ; les tests d'architecture le garantissent.
- Une transaction modifie un agrégat ; la cohérence entre agrégats passe par des événements.