Modèles de développement .NET
Modèles C# et .NET idiomatiques pour créer des applications robustes, performantes et maintenables.
Quand activer
- Écrire du nouveau code C#
- Relire du code C#
- Refactorer des applications .NET existantes
- Concevoir des architectures de services avec ASP.NET Core
Principes fondamentaux
1. Privilégier l'immutabilité
Utilisez les record et les propriétés en init pour les modèles de données. La mutabilité doit être un choix explicite et justifié.
// Bien : Objet valeur immuable
public sealed record Money(decimal Amount, string Currency);
// Bien : DTO immuable avec des setters init
public sealed class CreateOrderRequest
{
public required string CustomerId { get; init; }
public required IReadOnlyList<OrderItem> Items { get; init; }
}
// Mal : Modèle mutable avec des setters publics
public class Order
{
public string CustomerId { get; set; }
public List<OrderItem> Items { get; set; }
}
2. Explicite plutôt qu'implicite
Soyez clair sur la nullabilité, les modificateurs d'accès et l'intention.
// Bien : Modificateurs d'accès et nullabilité explicites
public sealed class UserService
{
private readonly IUserRepository _repository;
private readonly ILogger<UserService> _logger;
public UserService(IUserRepository repository, ILogger<UserService> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<User?> FindByIdAsync(Guid id, CancellationToken cancellationToken)
{
return await _repository.FindByIdAsync(id, cancellationToken);
}
}
3. Dépendre des abstractions
Utilisez des interfaces pour les limites de service. Enregistrez-les via le conteneur DI.
// Bien : Dépendance basée sur une interface
public interface IOrderRepository
{
Task<Order?> FindByIdAsync(Guid id, CancellationToken cancellationToken);
Task<IReadOnlyList<Order>> FindByCustomerAsync(string customerId, CancellationToken cancellationToken);
Task AddAsync(Order order, CancellationToken cancellationToken);
}
// Enregistrement
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
Modèles Async/Await
Utilisation correcte d'Async
// Bien : Async jusqu'au bout, avec CancellationToken
public async Task<OrderSummary> GetOrderSummaryAsync(
Guid orderId,
CancellationToken cancellationToken)
{
var order = await _repository.FindByIdAsync(orderId, cancellationToken)
?? throw new NotFoundException($"Commande {orderId} introuvable");
var customer = await _customerService.GetAsync(order.CustomerId, cancellationToken);
return new OrderSummary(order, customer);
}
// Mal : Blocage sur async
public OrderSummary GetOrderSummary(Guid orderId)
{
var order = _repository.FindByIdAsync(orderId, CancellationToken.None).Result; // Risque de blocage
return new OrderSummary(order);
}
Opérations async parallèles
// Bien : Opérations concurrentes indépendantes
public async Task<DashboardData> LoadDashboardAsync(CancellationToken cancellationToken)
{
var ordersTask = _orderService.GetRecentAsync(cancellationToken);
var metricsTask = _metricsService.GetCurrentAsync(cancellationToken);
var alertsTask = _alertService.GetActiveAsync(cancellationToken);
await Task.WhenAll(ordersTask, metricsTask, alertsTask);
return new DashboardData(
Commandes : await ordersTask,
Métriques : await metricsTask,
Alertes : await alertsTask);
}
Options Pattern
Liez les sections de configuration à des objets fortement typés.
public sealed class SmtpOptions
{
public const string SectionName = "Smtp";
public required string Host { get; init; }
public required int Port { get; init; }
public required string Username { get; init; }
public bool UseSsl { get; init; } = true;
}
// Enregistrement
builder.Services.Configure<SmtpOptions>(
builder.Configuration.GetSection(SmtpOptions.SectionName));
// Utilisation via injection
public class EmailService(IOptions<SmtpOptions> options)
{
private readonly SmtpOptions _smtp = options.Value;
}
Result Pattern
Retournez un succès/échec explicite au lieu de lever des exceptions pour les échecs attendus.
public sealed record Result<T>
{
public bool IsSuccess { get; }
public T? Value { get; }
public string? Error { get; }
private Result(T value) { IsSuccess = true; Value = value; }
private Result(string error) { IsSuccess = false; Error = error; }
public static Result<T> Success(T value) => new(value);
public static Result<T> Failure(string error) => new(error);
}
// Utilisation
public async Task<Result<Order>> PlaceOrderAsync(CreateOrderRequest request)
{
if (request.Items.Count == 0)
return Result<Order>.Failure("La commande doit contenir au moins un article");
var order = Order.Create(request);
await _repository.AddAsync(order, CancellationToken.None);
return Result<Order>.Success(order);
}
Repository Pattern avec EF Core
public sealed class SqlOrderRepository : IOrderRepository
{
private readonly AppDbContext _db;
public SqlOrderRepository(AppDbContext db) => _db = db;
public async Task<Order?> FindByIdAsync(Guid id, CancellationToken cancellationToken)
{
return await _db.Orders
.Include(o => o.Items)
.AsNoTracking()
.FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
}
public async Task<IReadOnlyList<Order>> FindByCustomerAsync(
string customerId,
CancellationToken cancellationToken)
{
return await _db.Orders
.Where(o => o.CustomerId == customerId)
.OrderByDescending(o => o.CreatedAt)
.AsNoTracking()
.ToListAsync(cancellationToken);
}
public async Task AddAsync(Order order, CancellationToken cancellationToken)
{
_db.Orders.Add(order);
await _db.SaveChangesAsync(cancellationToken);
}
}
Middleware et Pipeline
// Middleware personnalisé
public sealed class RequestTimingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestTimingMiddleware> _logger;
public RequestTimingMiddleware(RequestDelegate next, ILogger<RequestTimingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var stopwatch = Stopwatch.StartNew();
try
{
await _next(context);
}
finally
{
stopwatch.Stop();
_logger.LogInformation(
"Requête {Method} {Path} terminée en {ElapsedMs}ms avec le statut {StatusCode}",
context.Request.Method,
context.Request.Path,
stopwatch.ElapsedMilliseconds,
context.Response.StatusCode);
}
}
}
Modèles Minimal API
// Organisé avec des groupes de routes
var orders = app.MapGroup("/api/orders")
.RequireAuthorization()
.WithTags("Orders");
orders.MapGet("/{id:guid}", async (
Guid id,
IOrderRepository repository,
CancellationToken cancellationToken) =>
{
var order = await repository.FindByIdAsync(id, cancellationToken);
return order is not null
? TypedResults.Ok(order)
: TypedResults.NotFound();
});
orders.MapPost("/", async (
CreateOrderRequest request,
IOrderService service,
CancellationToken cancellationToken) =>
{
var result = await service.PlaceOrderAsync(request, cancellationToken);
return result.IsSuccess
? TypedResults.Created($"/api/orders/{result.Value!.Id}", result.Value)
: TypedResults.BadRequest(result.Error);
});
Guard Clauses
// Bien : Retours précoces avec validation claire
public async Task<ProcessResult> ProcessPaymentAsync(
PaymentRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
if (request.Amount <= 0)
throw new ArgumentOutOfRangeException(nameof(request.Amount), "Le montant doit être positif");
if (string.IsNullOrWhiteSpace(request.Currency))
throw new ArgumentException("La devise est requise", nameof(request.Currency));
// Le chemin heureux continue ici sans imbrication
var gateway = _gatewayFactory.Create(request.Currency);
return await gateway.ChargeAsync(request, cancellationToken);
}
Anti-modèles à éviter
| Anti-modèle | Correction |
|---|---|
Méthodes async void | Retourner Task (sauf gestionnaires d'événements) |
.Result ou .Wait() | Utiliser await |
catch (Exception) { } | Gérer ou relancer avec contexte |
new Service() dans les constructeurs | Utiliser l'injection de constructeur |
Champs public | Utiliser des propriétés avec les accesseurs appropriés |
dynamic dans la logique métier | Utiliser des génériques ou des types explicites |
État static mutable | Utiliser un scopage DI ou un ConcurrentDictionary |
string.Format dans les boucles | Utiliser StringBuilder ou des gestionnaires de chaînes interpolées |