J'ai repris une dizaine de projets .NET en cours de vie. La majorité d'entre eux avaient le même problème : impossible de toucher un bout de code sans casser trois choses ailleurs. Controllers qui contiennent de la logique métier. Services qui appellent directement Entity Framework. Tests quasi absents parce que tout est couplé. C'est le code spaghetti — et Clean Architecture est l'antidote.

Le problème que Clean Architecture résout

Dans un projet mal architecturé, tout dépend de tout. Le controller connaît EF Core. La logique métier sait qu'elle tourne dans une API web. Les tests sont impossibles à écrire sans base de données.

Clean Architecture impose une règle simple mais absolue : la règle de dépendance. Les dépendances ne pointent que vers l'intérieur — jamais vers l'extérieur. Le domaine métier ne sait pas qu'il existe une base de données. Il ne sait pas qu'il est appelé via HTTP. Il ne connaît que ses propres règles.

"Une architecture propre, c'est un domaine métier que vous pouvez tester avec dotnet test sans avoir Docker, SQL Server, ni aucune infrastructure démarrée."

Les 4 couches et leurs responsabilités

1. Domain — le cœur immuable

Aucune dépendance extérieure. Que des interfaces, des entités, des value objects et des règles métier.

// Domain/Entities/Invoice.cs
public sealed class Invoice
{
    public InvoiceId Id { get; private set; }
    public Money Amount { get; private set; }
    public InvoiceStatus Status { get; private set; }

    private Invoice() { }

    public static Invoice Create(Money amount)
    {
        if(amount.Value <= 0)
            throw new DomainException("Le montant doit être positif.");

        return new Invoice
        {
            Id = InvoiceId.New(),
            Amount = amount,
            Status = InvoiceStatus.Draft
        };
    }

    public void Validate()
    {
        if(Status != InvoiceStatus.Draft)
            throw new DomainException("Seules les factures en brouillon peuvent être validées.");
        Status = InvoiceStatus.Validated;
    }
}

2. Application — les use cases

Orchestre les entités du domaine. Utilise des interfaces définies dans le Domain pour parler à l'infrastructure. C'est ici que vivent les commandes et queries CQRS avec MediatR.

// Application/Invoices/Commands/ValidateInvoice/ValidateInvoiceCommand.cs
public sealed record ValidateInvoiceCommand(Guid InvoiceId) : IRequest;

// Application/Invoices/Commands/ValidateInvoice/ValidateInvoiceCommandHandler.cs
internal sealed class ValidateInvoiceCommandHandler : IRequestHandler<ValidateInvoiceCommand>
{
    private readonly IInvoiceRepository _repository;
    private readonly IUnitOfWork _unitOfWork;

    public ValidateInvoiceCommandHandler(IInvoiceRepository repository, IUnitOfWork unitOfWork)
    {
        _repository = repository;
        _unitOfWork = unitOfWork;
    }

    public async Task Handle(ValidateInvoiceCommand command, CancellationToken ct)
    {
        var invoice = await _repository.GetByIdAsync(new InvoiceId(command.InvoiceId), ct)
            ?? throw new NotFoundException($"Facture {command.InvoiceId} introuvable.");

        invoice.Validate();
        await _unitOfWork.SaveChangesAsync(ct);
    }
}

3. Infrastructure — les détails techniques

Implémente les interfaces définies dans le Domain et Application. EF Core, API externes, messaging — tout ce qui est un "détail" selon Clean Architecture.

// Infrastructure/Persistence/Repositories/InvoiceRepository.cs
internal sealed class InvoiceRepository : IInvoiceRepository
{
    private readonly AppDbContext _context;

    public InvoiceRepository(AppDbContext context) => _context = context;

    public async Task<Invoice?> GetByIdAsync(InvoiceId id, CancellationToken ct)
        => await _context.Invoices.FirstOrDefaultAsync(i => i.Id == id, ct);
}

4. API — la couche de présentation

Controllers minimalistes qui délèguent immédiatement à MediatR. Aucune logique métier ici.

// Api/Controllers/InvoicesController.cs
[ApiController, Route("api/invoices")]
public sealed class InvoicesController : ControllerBase
{
    private readonly ISender _sender;

    public InvoicesController(ISender sender) => _sender = sender;

    [HttpPost("{id:guid}/validate")]
    public async Task<IActionResult> Validate(Guid id, CancellationToken ct)
    {
        await _sender.Send(new ValidateInvoiceCommand(id), ct);
        return NoContent();
    }
}

La structure de projet concrète

src/
├── YourProject.Domain/          ← zéro dépendance NuGet externe
├── YourProject.Application/     ← dépend de Domain + MediatR
├── YourProject.Infrastructure/  ← dépend de Domain + Application + EF Core
└── YourProject.Api/             ← dépend de tout, configure le DI

tests/
├── YourProject.Domain.Tests/           ← tests purs, zéro mock
├── YourProject.Application.Tests/      ← mocks des interfaces
└── YourProject.Integration.Tests/      ← vraie BDD, vrai SQL Server

Les erreurs les plus fréquentes que je vois en mission

Pourquoi ça change vraiment la vie en mission

Sur une mission de 6 mois, la Clean Architecture se justifie dès la première semaine. Les nouveaux devs comprennent où ajouter du code en 10 minutes. Les tests s'écrivent naturellement parce que les dépendances sont explicites. Et le jour où on remplace EF Core par Dapper, ou SQL Server par PostgreSQL, on touche uniquement l'Infrastructure — le Domain et l'Application ne savent même pas que quelque chose a changé.

Si vous voulez aller plus loin sur les pratiques qui complètent la Clean Architecture, je vous recommande de lire comment j'applique le TDD strict dans ce contexte, et comment l'utilisation de Claude Code accélère encore ce workflow.

Votre codebase ressemble à du spaghetti ?

J'effectue des audits d'architecture et des refactorisations progressives
sans arrêter la production. Mission courte ou longue, full remote.

✉ Parlons de votre projet Voir mes missions →