# 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. - Published: 2026-02-13 - Updated: 2026-03-28 - Author: SharpSkill - Tags: dotnet, clean architecture, csharp, aspnet core, design patterns - Reading time: 16 min --- 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. ``` ┌─────────────────────────────────────────────────────────┐ │ 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. ```csharp // Domain/Entities/Order.cs 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 _items = new(); public IReadOnlyCollection 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. ```csharp // Domain/Entities/OrderItem.cs 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; } } ``` ```csharp // Domain/Enums/OrderStatus.cs 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. ```csharp // Domain/Interfaces/IOrderRepository.cs namespace CleanArchitecture.Domain.Interfaces; // Interface du repository Order public interface IOrderRepository { // Récupération par identifiant Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); // Récupération avec items inclus Task GetByIdWithItemsAsync(Guid id, CancellationToken cancellationToken = default); // Récupération par email client Task> 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); } ``` ```csharp // Domain/Interfaces/IUnitOfWork.cs 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 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. ## 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. ```csharp // Application/Orders/Commands/CreateOrderCommand.cs using MediatR; namespace CleanArchitecture.Application.Orders.Commands; // Command représentant l'intention de créer une commande public record CreateOrderCommand( string CustomerEmail, List Items ) : IRequest; // DTO pour les items de la commande public record OrderItemDto( Guid ProductId, int Quantity ); ``` ```csharp // Application/Orders/Commands/CreateOrderCommandHandler.cs 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 { private readonly IUnitOfWork _unitOfWork; public CreateOrderCommandHandler(IUnitOfWork unitOfWork) { _unitOfWork = unitOfWork; } public async Task 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. ```csharp // Application/Orders/Queries/GetOrderByIdQuery.cs using MediatR; namespace CleanArchitecture.Application.Orders.Queries; // Query pour récupérer une commande par ID public record GetOrderByIdQuery(Guid OrderId) : IRequest; // DTO de réponse pour une commande public record OrderDto( Guid Id, string CustomerEmail, string Status, decimal TotalAmount, DateTime CreatedAt, DateTime? ShippedAt, List Items ); // DTO pour les items dans la réponse public record OrderItemResponseDto( Guid Id, string ProductName, decimal UnitPrice, int Quantity, decimal TotalPrice ); ``` ```csharp // Application/Orders/Queries/GetOrderByIdQueryHandler.cs using MediatR; using CleanArchitecture.Domain.Interfaces; namespace CleanArchitecture.Application.Orders.Queries; public class GetOrderByIdQueryHandler : IRequestHandler { private readonly IOrderRepository _orderRepository; public GetOrderByIdQueryHandler(IOrderRepository orderRepository) { _orderRepository = orderRepository; } public async Task 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. ```csharp // Application/Orders/Validators/CreateOrderCommandValidator.cs using FluentValidation; using CleanArchitecture.Application.Orders.Commands; namespace CleanArchitecture.Application.Orders.Validators; public class CreateOrderCommandValidator : AbstractValidator { 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."); }); } } ``` ```csharp // Application/Common/Behaviors/ValidationBehavior.cs using FluentValidation; using MediatR; namespace CleanArchitecture.Application.Common.Behaviors; // Pipeline behavior pour validation automatique public class ValidationBehavior : IPipelineBehavior where TRequest : notnull { private readonly IEnumerable> _validators; public ValidationBehavior(IEnumerable> validators) { _validators = validators; } public async Task Handle( TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) { // Si aucun validateur, continuer if (!_validators.Any()) return await next(); // Exécution de tous les validateurs var context = new ValidationContext(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. ```csharp // Infrastructure/Persistence/AppDbContext.cs using Microsoft.EntityFrameworkCore; using CleanArchitecture.Domain.Entities; namespace CleanArchitecture.Infrastructure.Persistence; public class AppDbContext : DbContext { public AppDbContext(DbContextOptions options) : base(options) { } public DbSet Orders => Set(); public DbSet OrderItems => Set(); public DbSet Products => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { // Application des configurations depuis l'assembly modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); } } ``` ```csharp // Infrastructure/Persistence/Configurations/OrderConfiguration.cs using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; using CleanArchitecture.Domain.Entities; namespace CleanArchitecture.Infrastructure.Persistence.Configurations; public class OrderConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder 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(); // 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); } } ``` ```csharp // Infrastructure/Repositories/OrderRepository.cs 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 GetByIdAsync( Guid id, CancellationToken cancellationToken = default) { return await _context.Orders .FirstOrDefaultAsync(o => o.Id == id, cancellationToken); } public async Task 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> 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 ```csharp // Infrastructure/Persistence/UnitOfWork.cs 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 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. ```csharp // Infrastructure/DependencyInjection.cs 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(options => options.UseSqlServer( configuration.GetConnectionString("DefaultConnection"), b => b.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName))); // Enregistrement du Unit of Work (Scoped) services.AddScoped(); // Repositories individuels si nécessaire services.AddScoped(); services.AddScoped(); return services; } } ``` ```csharp // Application/DependencyInjection.cs 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; } } ``` ```csharp // Api/Program.cs 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. ```csharp // Api/Controllers/OrdersController.cs 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 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 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 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 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. ```csharp // Tests/Application/CreateOrderCommandHandlerTests.cs 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 _unitOfWorkMock; private readonly CreateOrderCommandHandler _handler; public CreateOrderCommandHandlerTests() { _unitOfWorkMock = new Mock(); _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(), It.IsAny())) .ReturnsAsync(product); _unitOfWorkMock.Setup(x => x.Orders.AddAsync( It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); _unitOfWorkMock.Setup(x => x.SaveChangesAsync(It.IsAny())) .ReturnsAsync(1); var command = new CreateOrderCommand( CustomerEmail: "test@example.com", Items: new List { 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(o => o.CustomerEmail == "test@example.com"), It.IsAny()), Times.Once); _unitOfWorkMock.Verify(x => x.SaveChangesAsync( It.IsAny()), Times.Once); } [Fact] public async Task Handle_ProductNotFound_ThrowsNotFoundException() { // Arrange _unitOfWorkMock.Setup(x => x.Products.GetByIdAsync( It.IsAny(), It.IsAny())) .ReturnsAsync((Product?)null); var command = new CreateOrderCommand( CustomerEmail: "test@example.com", Items: new List { new(ProductId: Guid.NewGuid(), Quantity: 1) }); // Act & Assert await Assert.ThrowsAsync( () => _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 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. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/fr/blog/dotnet/clean-architecture-dotnet-guide-pratique