Clean Architecture avec .NET : Guide pratique

Maîtriser Clean Architecture en .NET avec C#. Découvrez les principes SOLID, la séparation des couches et les patterns d'implémentation pour des applications maintenables.

Guide Clean Architecture avec .NET et C#

Clean Architecture, popularisée par Robert C. Martin (Uncle Bob), propose une organisation du code qui place la logique métier au centre de l'application, indépendante des frameworks et des détails d'implémentation. Cette approche architecturale garantit la testabilité, la maintenabilité et l'évolutivité des applications .NET. Ce guide présente une implémentation pratique avec ASP.NET Core.

Pourquoi Clean Architecture ?

Les applications qui mélangent logique métier et code d'infrastructure deviennent rapidement difficiles à maintenir. Clean Architecture impose une séparation stricte qui permet de modifier les détails techniques sans impacter le cœur métier.

Les principes fondamentaux de Clean Architecture

Clean Architecture repose sur le principe d'inversion des dépendances : les couches internes ne connaissent pas les couches externes. Le domaine métier reste isolé et peut évoluer indépendamment des choix techniques comme le framework web ou la base de données.

text
┌─────────────────────────────────────────────────────────┐
│                     Presentation                        │
│  (Controllers, Razor Pages, Blazor, API Endpoints)     │
├─────────────────────────────────────────────────────────┤
│                    Infrastructure                       │
│    (EF Core, External APIs, File System, Email)        │
├─────────────────────────────────────────────────────────┤
│                      Application                        │
│       (Use Cases, Commands, Queries, DTOs)             │
├─────────────────────────────────────────────────────────┤
│                        Domain                           │
│     (Entities, Value Objects, Domain Services)          │
└─────────────────────────────────────────────────────────┘

Les dépendances pointent toujours vers l'intérieur : Presentation → Infrastructure → Application → Domain. Le Domain ne référence aucun autre projet.

Structure du projet .NET en Clean Architecture

L'organisation des projets reflète les différentes couches. Chaque couche correspond à un projet distinct dans la solution Visual Studio, garantissant une séparation physique des responsabilités.

bash
# terminal
# Création de la structure de solution
dotnet new sln -n CleanArchitecture

# Création des projets par couche
dotnet new classlib -n CleanArchitecture.Domain -o src/CleanArchitecture.Domain
dotnet new classlib -n CleanArchitecture.Application -o src/CleanArchitecture.Application
dotnet new classlib -n CleanArchitecture.Infrastructure -o src/CleanArchitecture.Infrastructure
dotnet new webapi -n CleanArchitecture.Api -o src/CleanArchitecture.Api

# Ajout des projets à la solution
dotnet sln add src/CleanArchitecture.Domain
dotnet sln add src/CleanArchitecture.Application
dotnet sln add src/CleanArchitecture.Infrastructure
dotnet sln add src/CleanArchitecture.Api

# Configuration des références entre projets
cd src/CleanArchitecture.Application
dotnet add reference ../CleanArchitecture.Domain

cd ../CleanArchitecture.Infrastructure
dotnet add reference ../CleanArchitecture.Application

cd ../CleanArchitecture.Api
dotnet add reference ../CleanArchitecture.Infrastructure

Cette structure garantit que le Domain reste indépendant et que les dépendances suivent la règle de dépendance (Dependency Rule) de Clean Architecture.

La couche Domain : le cœur métier

La couche Domain contient les entités métier, les objets valeur (Value Objects) et les interfaces des repositories. Aucune dépendance externe n'est autorisée dans cette couche.

Domain/Entities/Order.cscsharp
namespace CleanArchitecture.Domain.Entities;

// Entité avec identité et cycle de vie métier
public class Order
{
    // Identifiant unique de la commande
    public Guid Id { get; private set; }

    // Référence au client (Value Object pour l'email)
    public string CustomerEmail { get; private set; }

    // Collection d'items (relation one-to-many)
    private readonly List<OrderItem> _items = new();
    public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();

    // Statut de la commande (enum métier)
    public OrderStatus Status { get; private set; }

    // Montant total calculé
    public decimal TotalAmount => _items.Sum(i => i.TotalPrice);

    // Dates de suivi
    public DateTime CreatedAt { get; private set; }
    public DateTime? ShippedAt { get; private set; }

    // Constructeur privé pour forcer l'utilisation de la factory method
    private Order() { }

    // Factory method pour créer une commande valide
    public static Order Create(string customerEmail)
    {
        // Validation des règles métier à la création
        if (string.IsNullOrWhiteSpace(customerEmail))
            throw new DomainException("L'email client est requis.");

        if (!IsValidEmail(customerEmail))
            throw new DomainException("Format d'email invalide.");

        return new Order
        {
            Id = Guid.NewGuid(),
            CustomerEmail = customerEmail,
            Status = OrderStatus.Pending,
            CreatedAt = DateTime.UtcNow
        };
    }

    // Méthode métier : ajouter un item
    public void AddItem(Product product, int quantity)
    {
        // Règle métier : impossible d'ajouter à une commande expédiée
        if (Status == OrderStatus.Shipped)
            throw new DomainException("Impossible de modifier une commande expédiée.");

        if (quantity <= 0)
            throw new DomainException("La quantité doit être positive.");

        // Vérification si le produit existe déjà
        var existingItem = _items.FirstOrDefault(i => i.ProductId == product.Id);
        if (existingItem != null)
        {
            existingItem.IncreaseQuantity(quantity);
        }
        else
        {
            _items.Add(OrderItem.Create(this, product, quantity));
        }
    }

    // Méthode métier : confirmer la commande
    public void Confirm()
    {
        if (Status != OrderStatus.Pending)
            throw new DomainException("Seules les commandes en attente peuvent être confirmées.");

        if (!_items.Any())
            throw new DomainException("Une commande doit contenir au moins un article.");

        Status = OrderStatus.Confirmed;
    }

    // Méthode métier : expédier la commande
    public void Ship()
    {
        if (Status != OrderStatus.Confirmed)
            throw new DomainException("La commande doit être confirmée avant expédition.");

        Status = OrderStatus.Shipped;
        ShippedAt = DateTime.UtcNow;
    }

    private static bool IsValidEmail(string email) =>
        email.Contains('@') && email.Contains('.');
}

L'entité Order encapsule ses règles métier et protège son état interne. Les modifications passent obligatoirement par des méthodes métier qui valident les invariants.

Domain/Entities/OrderItem.cscsharp
namespace CleanArchitecture.Domain.Entities;

// Entité enfant avec identité propre
public class OrderItem
{
    public Guid Id { get; private set; }
    public Guid OrderId { get; private set; }
    public Guid ProductId { get; private set; }
    public string ProductName { get; private set; }
    public decimal UnitPrice { get; private set; }
    public int Quantity { get; private set; }

    // Calcul du prix total de la ligne
    public decimal TotalPrice => UnitPrice * Quantity;

    private OrderItem() { }

    // Factory method avec validation
    public static OrderItem Create(Order order, Product product, int quantity)
    {
        return new OrderItem
        {
            Id = Guid.NewGuid(),
            OrderId = order.Id,
            ProductId = product.Id,
            ProductName = product.Name,
            UnitPrice = product.Price,
            Quantity = quantity
        };
    }

    // Méthode pour augmenter la quantité
    public void IncreaseQuantity(int additionalQuantity)
    {
        if (additionalQuantity <= 0)
            throw new DomainException("La quantité additionnelle doit être positive.");

        Quantity += additionalQuantity;
    }
}
Domain/Enums/OrderStatus.cscsharp
namespace CleanArchitecture.Domain.Enums;

// Énumération des statuts de commande
public enum OrderStatus
{
    Pending = 0,      // En attente de confirmation
    Confirmed = 1,    // Confirmée, prête pour expédition
    Shipped = 2,      // Expédiée
    Delivered = 3,    // Livrée
    Cancelled = 4     // Annulée
}
Value Objects

Les Value Objects comme Money, Address ou Email encapsulent des concepts métier sans identité propre. Leur égalité se base sur leurs valeurs, non sur une référence. Cette approche renforce l'expressivité du domaine.

Les interfaces Repository dans le Domain

Les interfaces des repositories sont définies dans le Domain, mais leur implémentation se trouve dans Infrastructure. Ce pattern respecte le principe d'inversion des dépendances.

Domain/Interfaces/IOrderRepository.cscsharp
namespace CleanArchitecture.Domain.Interfaces;

// Interface du repository Order
public interface IOrderRepository
{
    // Récupération par identifiant
    Task<Order?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);

    // Récupération avec items inclus
    Task<Order?> GetByIdWithItemsAsync(Guid id, CancellationToken cancellationToken = default);

    // Récupération par email client
    Task<IEnumerable<Order>> GetByCustomerEmailAsync(
        string email,
        CancellationToken cancellationToken = default);

    // Ajout d'une nouvelle commande
    Task AddAsync(Order order, CancellationToken cancellationToken = default);

    // Mise à jour d'une commande existante
    Task UpdateAsync(Order order, CancellationToken cancellationToken = default);

    // Suppression d'une commande
    Task DeleteAsync(Guid id, CancellationToken cancellationToken = default);
}
Domain/Interfaces/IUnitOfWork.cscsharp
namespace CleanArchitecture.Domain.Interfaces;

// Pattern Unit of Work pour la gestion transactionnelle
public interface IUnitOfWork : IDisposable
{
    // Repositories accessibles via UoW
    IOrderRepository Orders { get; }
    IProductRepository Products { get; }

    // Sauvegarde atomique de tous les changements
    Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);

    // Gestion explicite des transactions
    Task BeginTransactionAsync(CancellationToken cancellationToken = default);
    Task CommitTransactionAsync(CancellationToken cancellationToken = default);
    Task RollbackTransactionAsync(CancellationToken cancellationToken = default);
}

Le pattern Unit of Work coordonne les opérations sur plusieurs repositories dans une même transaction.

Prêt à réussir tes entretiens .NET ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

La couche Application : orchestration des Use Cases

La couche Application contient la logique applicative (use cases), les DTOs et les interfaces des services externes. Elle orchestre les interactions entre le domaine et le monde extérieur.

Application/Orders/Commands/CreateOrderCommand.cscsharp
using MediatR;

namespace CleanArchitecture.Application.Orders.Commands;

// Command représentant l'intention de créer une commande
public record CreateOrderCommand(
    string CustomerEmail,
    List<OrderItemDto> Items
) : IRequest<Guid>;

// DTO pour les items de la commande
public record OrderItemDto(
    Guid ProductId,
    int Quantity
);
Application/Orders/Commands/CreateOrderCommandHandler.cscsharp
using MediatR;
using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Interfaces;

namespace CleanArchitecture.Application.Orders.Commands;

// Handler qui implémente le use case
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, Guid>
{
    private readonly IUnitOfWork _unitOfWork;

    public CreateOrderCommandHandler(IUnitOfWork unitOfWork)
    {
        _unitOfWork = unitOfWork;
    }

    public async Task<Guid> Handle(
        CreateOrderCommand request,
        CancellationToken cancellationToken)
    {
        // Création de l'entité Order via factory method
        var order = Order.Create(request.CustomerEmail);

        // Ajout des items à la commande
        foreach (var item in request.Items)
        {
            // Récupération du produit depuis le repository
            var product = await _unitOfWork.Products
                .GetByIdAsync(item.ProductId, cancellationToken);

            if (product == null)
                throw new NotFoundException($"Produit {item.ProductId} non trouvé.");

            // Utilisation de la méthode métier de l'entité
            order.AddItem(product, item.Quantity);
        }

        // Persistance via le repository
        await _unitOfWork.Orders.AddAsync(order, cancellationToken);
        await _unitOfWork.SaveChangesAsync(cancellationToken);

        return order.Id;
    }
}

MediatR implémente le pattern Mediator pour découpler les handlers des controllers. Chaque commande a un unique handler responsable de son traitement.

Application/Orders/Queries/GetOrderByIdQuery.cscsharp
using MediatR;

namespace CleanArchitecture.Application.Orders.Queries;

// Query pour récupérer une commande par ID
public record GetOrderByIdQuery(Guid OrderId) : IRequest<OrderDto?>;

// DTO de réponse pour une commande
public record OrderDto(
    Guid Id,
    string CustomerEmail,
    string Status,
    decimal TotalAmount,
    DateTime CreatedAt,
    DateTime? ShippedAt,
    List<OrderItemResponseDto> Items
);

// DTO pour les items dans la réponse
public record OrderItemResponseDto(
    Guid Id,
    string ProductName,
    decimal UnitPrice,
    int Quantity,
    decimal TotalPrice
);
Application/Orders/Queries/GetOrderByIdQueryHandler.cscsharp
using MediatR;
using CleanArchitecture.Domain.Interfaces;

namespace CleanArchitecture.Application.Orders.Queries;

public class GetOrderByIdQueryHandler : IRequestHandler<GetOrderByIdQuery, OrderDto?>
{
    private readonly IOrderRepository _orderRepository;

    public GetOrderByIdQueryHandler(IOrderRepository orderRepository)
    {
        _orderRepository = orderRepository;
    }

    public async Task<OrderDto?> Handle(
        GetOrderByIdQuery request,
        CancellationToken cancellationToken)
    {
        // Récupération avec items inclus
        var order = await _orderRepository
            .GetByIdWithItemsAsync(request.OrderId, cancellationToken);

        if (order == null)
            return null;

        // Mapping vers DTO (projection manuelle)
        return new OrderDto(
            Id: order.Id,
            CustomerEmail: order.CustomerEmail,
            Status: order.Status.ToString(),
            TotalAmount: order.TotalAmount,
            CreatedAt: order.CreatedAt,
            ShippedAt: order.ShippedAt,
            Items: order.Items.Select(i => new OrderItemResponseDto(
                Id: i.Id,
                ProductName: i.ProductName,
                UnitPrice: i.UnitPrice,
                Quantity: i.Quantity,
                TotalPrice: i.TotalPrice
            )).ToList()
        );
    }
}

La séparation Commands/Queries suit le pattern CQRS (Command Query Responsibility Segregation), optimisant la lecture et l'écriture indépendamment.

Validation avec FluentValidation

La validation des commandes s'effectue dans la couche Application, avant l'exécution du handler.

Application/Orders/Validators/CreateOrderCommandValidator.cscsharp
using FluentValidation;
using CleanArchitecture.Application.Orders.Commands;

namespace CleanArchitecture.Application.Orders.Validators;

public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
    public CreateOrderCommandValidator()
    {
        // Email requis et format valide
        RuleFor(x => x.CustomerEmail)
            .NotEmpty().WithMessage("L'email client est requis.")
            .EmailAddress().WithMessage("Format d'email invalide.");

        // Au moins un item requis
        RuleFor(x => x.Items)
            .NotEmpty().WithMessage("La commande doit contenir au moins un article.");

        // Validation de chaque item
        RuleForEach(x => x.Items).ChildRules(item =>
        {
            item.RuleFor(i => i.ProductId)
                .NotEmpty().WithMessage("L'identifiant produit est requis.");

            item.RuleFor(i => i.Quantity)
                .GreaterThan(0).WithMessage("La quantité doit être positive.")
                .LessThanOrEqualTo(100).WithMessage("Quantité maximale : 100.");
        });
    }
}
Application/Common/Behaviors/ValidationBehavior.cscsharp
using FluentValidation;
using MediatR;

namespace CleanArchitecture.Application.Common.Behaviors;

// Pipeline behavior pour validation automatique
public class ValidationBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : notnull
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

    public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
    {
        _validators = validators;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        // Si aucun validateur, continuer
        if (!_validators.Any())
            return await next();

        // Exécution de tous les validateurs
        var context = new ValidationContext<TRequest>(request);

        var validationResults = await Task.WhenAll(
            _validators.Select(v => v.ValidateAsync(context, cancellationToken)));

        // Agrégation des erreurs
        var failures = validationResults
            .SelectMany(r => r.Errors)
            .Where(f => f != null)
            .ToList();

        // Lancement d'une exception si erreurs
        if (failures.Any())
            throw new ValidationException(failures);

        return await next();
    }
}

Le ValidationBehavior s'exécute automatiquement avant chaque handler, garantissant que seules les commandes valides atteignent la logique métier.

La couche Infrastructure : implémentation technique

La couche Infrastructure fournit les implémentations concrètes des interfaces définies dans le Domain et l'Application.

Infrastructure/Persistence/AppDbContext.cscsharp
using Microsoft.EntityFrameworkCore;
using CleanArchitecture.Domain.Entities;

namespace CleanArchitecture.Infrastructure.Persistence;

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
    {
    }

    public DbSet<Order> Orders => Set<Order>();
    public DbSet<OrderItem> OrderItems => Set<OrderItem>();
    public DbSet<Product> Products => Set<Product>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Application des configurations depuis l'assembly
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
    }
}
Infrastructure/Persistence/Configurations/OrderConfiguration.cscsharp
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using CleanArchitecture.Domain.Entities;

namespace CleanArchitecture.Infrastructure.Persistence.Configurations;

public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        // Table et clé primaire
        builder.ToTable("Orders");
        builder.HasKey(o => o.Id);

        // Propriétés
        builder.Property(o => o.CustomerEmail)
            .IsRequired()
            .HasMaxLength(256);

        builder.Property(o => o.Status)
            .IsRequired()
            .HasConversion<string>();  // Stockage en string lisible

        // Relation avec OrderItems
        builder.HasMany(o => o.Items)
            .WithOne()
            .HasForeignKey(i => i.OrderId)
            .OnDelete(DeleteBehavior.Cascade);

        // Index pour recherche par email
        builder.HasIndex(o => o.CustomerEmail);

        // Accès au champ privé _items
        builder.Navigation(o => o.Items)
            .UsePropertyAccessMode(PropertyAccessMode.Field);
    }
}
Infrastructure/Repositories/OrderRepository.cscsharp
using Microsoft.EntityFrameworkCore;
using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Interfaces;
using CleanArchitecture.Infrastructure.Persistence;

namespace CleanArchitecture.Infrastructure.Repositories;

public class OrderRepository : IOrderRepository
{
    private readonly AppDbContext _context;

    public OrderRepository(AppDbContext context)
    {
        _context = context;
    }

    public async Task<Order?> GetByIdAsync(
        Guid id,
        CancellationToken cancellationToken = default)
    {
        return await _context.Orders
            .FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
    }

    public async Task<Order?> GetByIdWithItemsAsync(
        Guid id,
        CancellationToken cancellationToken = default)
    {
        // Eager loading des items
        return await _context.Orders
            .Include(o => o.Items)
            .FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
    }

    public async Task<IEnumerable<Order>> GetByCustomerEmailAsync(
        string email,
        CancellationToken cancellationToken = default)
    {
        return await _context.Orders
            .Include(o => o.Items)
            .Where(o => o.CustomerEmail == email)
            .OrderByDescending(o => o.CreatedAt)
            .ToListAsync(cancellationToken);
    }

    public async Task AddAsync(
        Order order,
        CancellationToken cancellationToken = default)
    {
        await _context.Orders.AddAsync(order, cancellationToken);
    }

    public Task UpdateAsync(
        Order order,
        CancellationToken cancellationToken = default)
    {
        // EF Core suit automatiquement les changements
        _context.Orders.Update(order);
        return Task.CompletedTask;
    }

    public async Task DeleteAsync(
        Guid id,
        CancellationToken cancellationToken = default)
    {
        await _context.Orders
            .Where(o => o.Id == id)
            .ExecuteDeleteAsync(cancellationToken);
    }
}
Attention aux fuites d'abstraction

Les repositories ne doivent pas exposer IQueryable directement, car cela créerait une dépendance vers EF Core dans les couches supérieures. Préférez des méthodes spécifiques avec des paramètres clairs.

Implémentation du Unit of Work

Infrastructure/Persistence/UnitOfWork.cscsharp
using Microsoft.EntityFrameworkCore.Storage;
using CleanArchitecture.Domain.Interfaces;
using CleanArchitecture.Infrastructure.Repositories;

namespace CleanArchitecture.Infrastructure.Persistence;

public class UnitOfWork : IUnitOfWork
{
    private readonly AppDbContext _context;
    private IDbContextTransaction? _transaction;

    // Lazy loading des repositories
    private IOrderRepository? _orderRepository;
    private IProductRepository? _productRepository;

    public UnitOfWork(AppDbContext context)
    {
        _context = context;
    }

    // Création à la demande (lazy initialization)
    public IOrderRepository Orders =>
        _orderRepository ??= new OrderRepository(_context);

    public IProductRepository Products =>
        _productRepository ??= new ProductRepository(_context);

    public async Task<int> SaveChangesAsync(
        CancellationToken cancellationToken = default)
    {
        return await _context.SaveChangesAsync(cancellationToken);
    }

    public async Task BeginTransactionAsync(
        CancellationToken cancellationToken = default)
    {
        _transaction = await _context.Database
            .BeginTransactionAsync(cancellationToken);
    }

    public async Task CommitTransactionAsync(
        CancellationToken cancellationToken = default)
    {
        if (_transaction == null)
            throw new InvalidOperationException("Aucune transaction active.");

        await _transaction.CommitAsync(cancellationToken);
        await _transaction.DisposeAsync();
        _transaction = null;
    }

    public async Task RollbackTransactionAsync(
        CancellationToken cancellationToken = default)
    {
        if (_transaction == null)
            throw new InvalidOperationException("Aucune transaction active.");

        await _transaction.RollbackAsync(cancellationToken);
        await _transaction.DisposeAsync();
        _transaction = null;
    }

    public void Dispose()
    {
        _transaction?.Dispose();
        _context.Dispose();
    }
}

Configuration de l'injection de dépendances

L'enregistrement des services se fait dans chaque couche via des méthodes d'extension, puis orchestré dans Program.cs.

Infrastructure/DependencyInjection.cscsharp
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using CleanArchitecture.Domain.Interfaces;
using CleanArchitecture.Infrastructure.Persistence;
using CleanArchitecture.Infrastructure.Repositories;

namespace CleanArchitecture.Infrastructure;

public static class DependencyInjection
{
    public static IServiceCollection AddInfrastructure(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        // Configuration Entity Framework
        services.AddDbContext<AppDbContext>(options =>
            options.UseSqlServer(
                configuration.GetConnectionString("DefaultConnection"),
                b => b.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName)));

        // Enregistrement du Unit of Work (Scoped)
        services.AddScoped<IUnitOfWork, UnitOfWork>();

        // Repositories individuels si nécessaire
        services.AddScoped<IOrderRepository, OrderRepository>();
        services.AddScoped<IProductRepository, ProductRepository>();

        return services;
    }
}
Application/DependencyInjection.cscsharp
using FluentValidation;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using CleanArchitecture.Application.Common.Behaviors;

namespace CleanArchitecture.Application;

public static class DependencyInjection
{
    public static IServiceCollection AddApplication(this IServiceCollection services)
    {
        var assembly = typeof(DependencyInjection).Assembly;

        // Enregistrement MediatR avec handlers
        services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly));

        // Enregistrement des validateurs FluentValidation
        services.AddValidatorsFromAssembly(assembly);

        // Pipeline behaviors (ordre d'exécution important)
        services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));

        return services;
    }
}
Api/Program.cscsharp
using CleanArchitecture.Application;
using CleanArchitecture.Infrastructure;

var builder = WebApplication.CreateBuilder(args);

// Enregistrement des couches
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);

// Configuration API
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();

app.Run();

La couche Presentation : Controllers API

Les controllers sont de simples adaptateurs qui délèguent le travail à MediatR.

Api/Controllers/OrdersController.cscsharp
using MediatR;
using Microsoft.AspNetCore.Mvc;
using CleanArchitecture.Application.Orders.Commands;
using CleanArchitecture.Application.Orders.Queries;

namespace CleanArchitecture.Api.Controllers;

[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    private readonly IMediator _mediator;

    public OrdersController(IMediator mediator)
    {
        _mediator = mediator;
    }

    [HttpPost]
    [ProducesResponseType(typeof(Guid), StatusCodes.Status201Created)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    public async Task<IActionResult> Create(
        [FromBody] CreateOrderCommand command,
        CancellationToken cancellationToken)
    {
        // Délégation complète à MediatR
        var orderId = await _mediator.Send(command, cancellationToken);

        return CreatedAtAction(
            nameof(GetById),
            new { id = orderId },
            orderId);
    }

    [HttpGet("{id:guid}")]
    [ProducesResponseType(typeof(OrderDto), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> GetById(
        Guid id,
        CancellationToken cancellationToken)
    {
        var order = await _mediator.Send(
            new GetOrderByIdQuery(id),
            cancellationToken);

        if (order == null)
            return NotFound();

        return Ok(order);
    }

    [HttpPost("{id:guid}/confirm")]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> Confirm(
        Guid id,
        CancellationToken cancellationToken)
    {
        await _mediator.Send(new ConfirmOrderCommand(id), cancellationToken);
        return NoContent();
    }

    [HttpPost("{id:guid}/ship")]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> Ship(
        Guid id,
        CancellationToken cancellationToken)
    {
        await _mediator.Send(new ShipOrderCommand(id), cancellationToken);
        return NoContent();
    }
}

Les controllers restent fins (thin controllers) et ne contiennent aucune logique métier, seulement le mapping HTTP.

Tests unitaires de la couche Application

Clean Architecture facilite les tests unitaires grâce à l'isolation des dépendances.

Tests/Application/CreateOrderCommandHandlerTests.cscsharp
using Moq;
using Xunit;
using CleanArchitecture.Application.Orders.Commands;
using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Interfaces;

namespace CleanArchitecture.Tests.Application;

public class CreateOrderCommandHandlerTests
{
    private readonly Mock<IUnitOfWork> _unitOfWorkMock;
    private readonly CreateOrderCommandHandler _handler;

    public CreateOrderCommandHandlerTests()
    {
        _unitOfWorkMock = new Mock<IUnitOfWork>();
        _handler = new CreateOrderCommandHandler(_unitOfWorkMock.Object);
    }

    [Fact]
    public async Task Handle_ValidCommand_CreatesOrderAndReturnsId()
    {
        // Arrange
        var product = new Product { Id = Guid.NewGuid(), Name = "Test", Price = 100 };

        _unitOfWorkMock.Setup(x => x.Products.GetByIdAsync(
            It.IsAny<Guid>(),
            It.IsAny<CancellationToken>()))
            .ReturnsAsync(product);

        _unitOfWorkMock.Setup(x => x.Orders.AddAsync(
            It.IsAny<Order>(),
            It.IsAny<CancellationToken>()))
            .Returns(Task.CompletedTask);

        _unitOfWorkMock.Setup(x => x.SaveChangesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(1);

        var command = new CreateOrderCommand(
            CustomerEmail: "test@example.com",
            Items: new List<OrderItemDto>
            {
                new(ProductId: product.Id, Quantity: 2)
            });

        // Act
        var result = await _handler.Handle(command, CancellationToken.None);

        // Assert
        Assert.NotEqual(Guid.Empty, result);
        _unitOfWorkMock.Verify(x => x.Orders.AddAsync(
            It.Is<Order>(o => o.CustomerEmail == "test@example.com"),
            It.IsAny<CancellationToken>()), Times.Once);
        _unitOfWorkMock.Verify(x => x.SaveChangesAsync(
            It.IsAny<CancellationToken>()), Times.Once);
    }

    [Fact]
    public async Task Handle_ProductNotFound_ThrowsNotFoundException()
    {
        // Arrange
        _unitOfWorkMock.Setup(x => x.Products.GetByIdAsync(
            It.IsAny<Guid>(),
            It.IsAny<CancellationToken>()))
            .ReturnsAsync((Product?)null);

        var command = new CreateOrderCommand(
            CustomerEmail: "test@example.com",
            Items: new List<OrderItemDto>
            {
                new(ProductId: Guid.NewGuid(), Quantity: 1)
            });

        // Act & Assert
        await Assert.ThrowsAsync<NotFoundException>(
            () => _handler.Handle(command, CancellationToken.None));
    }
}

Les mocks de Moq permettent d'isoler le handler de ses dépendances, testant uniquement la logique d'orchestration.

Conclusion

Clean Architecture avec .NET offre une structure robuste pour les applications d'entreprise. La séparation stricte des responsabilités entre les couches Domain, Application, Infrastructure et Presentation garantit un code maintenable et testable sur le long terme.

Checklist Clean Architecture .NET

  • ✅ Domain isolé sans dépendances externes
  • ✅ Entités avec logique métier encapsulée
  • ✅ Interfaces repositories dans le Domain
  • ✅ Use Cases via Commands/Queries (CQRS)
  • ✅ Validation avec FluentValidation et Pipeline Behaviors
  • ✅ Infrastructure implémente les interfaces du Domain
  • ✅ Controllers fins délégant à MediatR
  • ✅ Tests unitaires isolés avec mocks

Passe à la pratique !

Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.

L'investissement initial dans cette architecture se rentabilise rapidement sur les projets de moyenne à grande taille, où les changements de requirements techniques (migration de base de données, changement de framework) n'impactent que les couches externes, préservant le cœur métier intact.

Tags

#dotnet
#clean architecture
#csharp
#aspnet core
#design patterns

Partager

Articles similaires