Développer une API .NET robuste, maintenable et performante requiert bien plus qu’une simple mise en place d’un projet ASP.NET Core. Cet article rassemble les pratiques essentielles, de la conception à la mise en production.
1. Concevoir une API RESTful cohérente
Nommage des routes
Les routes doivent décrire des ressources (noms, jamais des verbes), en minuscules et avec des tirets :
GET /api/orders → liste des commandes
GET /api/orders/{id} → une commande
POST /api/orders → créer une commande
PUT /api/orders/{id} → remplacer une commande
PATCH /api/orders/{id} → modifier partiellement
DELETE /api/orders/{id} → supprimer une commande
Évitez les routes du type /api/getOrder ou /api/createOrder.
Codes HTTP corrects
| Situation | Code |
|---|---|
| Ressource retournée | 200 OK |
| Ressource créée | 201 Created + header Location |
| Traitement sans contenu | 204 No Content |
| Données invalides | 400 Bad Request |
| Non authentifié | 401 Unauthorized |
| Accès interdit | 403 Forbidden |
| Introuvable | 404 Not Found |
| Erreur serveur | 500 Internal Server Error |
2. Versionner son API
Le versioning permet de faire évoluer son API sans casser les clients existants.
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
});
Plusieurs stratégies existent : par URL (/api/v1/orders), par header (api-version: 1.0) ou par query string (?api-version=1.0). La stratégie par URL est la plus lisible et la plus répandue.
3. Séparer les modèles internes des contrats API
Ne jamais exposer directement les entités de domaine ou de base de données. Utiliser des DTO (Data Transfer Objects) dédiés :
// Entité interne — ne pas exposer
public class Order
{
public Guid Id { get; set; }
public Guid CustomerId { get; set; }
public List<OrderLine> Lines { get; set; } = [];
public decimal Total { get; set; }
public OrderStatus Status { get; set; }
public DateTime CreatedAt { get; set; }
}
// DTO de réponse
public record OrderResponse(
Guid Id,
decimal Total,
string Status,
DateTime CreatedAt
);
// DTO de création
public record CreateOrderRequest(
Guid CustomerId,
List<OrderLineRequest> Lines
);
Le mapping peut se faire manuellement ou via Mapperly (générateur de source, sans réflexion) :
[Mapper]
public partial class OrderMapper
{
public partial OrderResponse ToResponse(Order order);
}
4. Valider les entrées
DataAnnotations (simple)
public record CreateOrderRequest(
[Required] Guid CustomerId,
[MinLength(1)] List<OrderLineRequest> Lines
);
Activer la validation automatique avec [ApiController] sur le contrôleur.
FluentValidation (recommandé pour les cas complexes)
public class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>
{
public CreateOrderRequestValidator()
{
RuleFor(x => x.CustomerId).NotEmpty();
RuleFor(x => x.Lines)
.NotEmpty()
.WithMessage("Une commande doit contenir au moins une ligne.");
RuleForEach(x => x.Lines).SetValidator(new OrderLineRequestValidator());
}
}
builder.Services.AddValidatorsFromAssemblyContaining<CreateOrderRequestValidator>();
builder.Services.AddFluentValidationAutoValidation();
5. Gérer les erreurs de manière uniforme
ProblemDetails (RFC 9457)
ASP.NET Core expose nativement le format ProblemDetails, qui est la norme pour les réponses d’erreur :
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
"title": "Not Found",
"status": 404,
"detail": "La commande 'abc-123' est introuvable.",
"instance": "/api/orders/abc-123"
}
Activez le retour automatique de ProblemDetails :
builder.Services.AddProblemDetails();
Middleware de gestion des exceptions
Centralisez la gestion des exceptions non attrapées pour éviter de laisser fuir des stacktraces en production :
app.UseExceptionHandler(exceptionHandlerApp =>
{
exceptionHandlerApp.Run(async context =>
{
var exceptionHandlerFeature = context.Features.Get<IExceptionHandlerFeature>();
var exception = exceptionHandlerFeature?.Error;
context.Response.StatusCode = exception switch
{
NotFoundException => StatusCodes.Status404NotFound,
ValidationException => StatusCodes.Status400BadRequest,
UnauthorizedException => StatusCodes.Status403Forbidden,
_ => StatusCodes.Status500InternalServerError
};
await Results.Problem(
detail: exception?.Message,
statusCode: context.Response.StatusCode
).ExecuteAsync(context);
});
});
6. Authentification et autorisation
JWT
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!))
};
});
Politiques d’autorisation
Préférez les politiques aux rôles bruts :
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdminOnly", policy => policy.RequireRole("admin"));
options.AddPolicy("OwnerOrAdmin", policy =>
policy.RequireAssertion(ctx =>
ctx.User.IsInRole("admin") || ctx.User.HasClaim("owner", "true")));
});
[Authorize(Policy = "AdminOnly")]
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(Guid id) { ... }
7. Limiter le débit (Rate Limiting)
Depuis .NET 7, le rate limiting est intégré :
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("fixed", limiterOptions =>
{
limiterOptions.PermitLimit = 100;
limiterOptions.Window = TimeSpan.FromMinutes(1);
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
limiterOptions.QueueLimit = 10;
});
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});
app.UseRateLimiter();
[EnableRateLimiting("fixed")]
[HttpGet]
public async Task<IActionResult> GetOrders() { ... }
8. Logging et observabilité
Logging structuré
Utilisez ILogger<T> avec des propriétés nommées plutôt que de l’interpolation de chaîne :
// À éviter
_logger.LogInformation($"Commande {orderId} créée par {userId}");
// Correct — permet l'indexation dans les outils de log (Seq, Loki, etc.)
_logger.LogInformation("Commande {OrderId} créée par {UserId}", orderId, userId);
OpenTelemetry
Intégrez OpenTelemetry pour les traces distribuées, métriques et logs :
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddOtlpExporter())
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddOtlpExporter());
9. Asynchronisme systématique
Chaque opération I/O (base de données, appels HTTP, fichiers) doit être asynchrone :
// À éviter
public IActionResult GetOrder(Guid id)
{
var order = _repository.GetById(id); // bloque un thread
return Ok(order);
}
// Correct
public async Task<IActionResult> GetOrder(Guid id)
{
var order = await _repository.GetByIdAsync(id);
return order is null ? NotFound() : Ok(order);
}
Passez également les CancellationToken du contrôleur jusqu’aux appels les plus profonds :
public async Task<IActionResult> GetOrder(Guid id, CancellationToken ct)
{
var order = await _repository.GetByIdAsync(id, ct);
return order is null ? NotFound() : Ok(order);
}
10. Caching
Cache en mémoire
builder.Services.AddMemoryCache();
// Dans le service
public async Task<OrderResponse?> GetOrderAsync(Guid id, CancellationToken ct)
{
return await _cache.GetOrCreateAsync($"order:{id}", async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
var order = await _repository.GetByIdAsync(id, ct);
return order is null ? null : _mapper.ToResponse(order);
});
}
Cache de réponse HTTP
builder.Services.AddOutputCache();
app.UseOutputCache();
[OutputCache(Duration = 60)]
[HttpGet("{id}")]
public async Task<IActionResult> GetOrder(Guid id, CancellationToken ct) { ... }
11. Documentation avec OpenAPI
Depuis .NET 9, le support natif d’OpenAPI est amélioré :
builder.Services.AddOpenApi();
app.MapOpenApi(); // expose /openapi/v1.json
Enrichissez la documentation avec des attributs :
/// <summary>Récupère une commande par son identifiant.</summary>
/// <param name="id">L'identifiant de la commande.</param>
/// <response code="200">La commande demandée.</response>
/// <response code="404">Commande introuvable.</response>
[HttpGet("{id}")]
[ProducesResponseType<OrderResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetOrder(Guid id, CancellationToken ct) { ... }
12. Tests
Tests unitaires
Testez les services et la logique métier en isolation avec xUnit et NSubstitute :
public class OrderServiceTests
{
private readonly IOrderRepository _repository = Substitute.For<IOrderRepository>();
private readonly OrderService _sut;
public OrderServiceTests() => _sut = new OrderService(_repository);
[Fact]
public async Task GetOrder_ReturnsNull_WhenNotFound()
{
_repository.GetByIdAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns((Order?)null);
var result = await _sut.GetOrderAsync(Guid.NewGuid(), default);
result.Should().BeNull();
}
}
Tests d’intégration
Utilisez WebApplicationFactory<TProgram> pour tester les endpoints de bout en bout :
public class OrdersControllerTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public OrdersControllerTests(WebApplicationFactory<Program> factory)
=> _client = factory.CreateClient();
[Fact]
public async Task GetOrder_Returns404_WhenNotFound()
{
var response = await _client.GetAsync($"/api/orders/{Guid.NewGuid()}");
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
}
Récapitulatif
| Pratique | Outil / Approche |
|---|---|
| Routes RESTful | Conventions de nommage + verbes HTTP |
| Versioning | Asp.Versioning.Http |
| Modèles d’échange | DTO + Mapperly |
| Validation | FluentValidation |
| Erreurs uniformes | ProblemDetails + middleware |
| Authentification | JWT Bearer |
| Rate limiting | RateLimiter intégré (.NET 7+) |
| Observabilité | OpenTelemetry + logging structuré |
| Performance | async/await + CancellationToken |
| Caching | IMemoryCache ou OutputCache |
| Documentation | OpenAPI natif (.NET 9+) |
| Tests | xUnit + NSubstitute + WebApplicationFactory |
Une API bien conçue ne s’arrête pas à faire fonctionner des endpoints : elle doit être prévisible, sécurisée, observable et testable dès le départ.