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.

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.
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.
# 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.InfrastructureCette 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.
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.
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;
}
}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
}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.
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);
}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.
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
);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.
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
);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.
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.");
});
}
}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.
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);
}
}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);
}
}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);
}
}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
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.
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;
}
}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;
}
}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.
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.
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
Partager
Articles similaires

Questions d'entretien C# et .NET : Guide complet 2026
Les 25 questions d'entretien C# et .NET les plus fréquentes. LINQ, async/await, dependency injection, Entity Framework et bonnes pratiques avec réponses détaillées.

.NET 8 : Créer une API avec ASP.NET Core
Guide complet pour créer une API REST professionnelle avec .NET 8 et ASP.NET Core. Controllers, Entity Framework Core, validation et bonnes pratiques expliqués.

Entity Framework Core : Optimisation des Performances et Bonnes Pratiques en 2026
Guide complet sur l'optimisation des performances Entity Framework Core 10 avec .NET 10. AsNoTracking, requêtes compilées, mises à jour par lot, split queries et LeftJoin.