Structurez un projet ASP.NET Core lisible, testable et maintenable avec des frontières claires, même lorsque l’équipe, les fonctionnalités et le code évoluent.

Structurer un projet ASP.NET Core maintenable
Un projet ASP.NET Core devient rarement ingérable du jour au lendemain. Il se dégrade progressivement: contrôleurs trop gros, services qui font tout, dépendances circulaires, conventions incohérentes et logique métier dispersée.
La maintenabilité n'est donc pas une qualité abstraite. C'est le résultat d'une structure explicite qui aide les développeurs à savoir où mettre le code et où chercher quand quelque chose casse.
Le problème n'est pas la taille, c'est la confusion
Beaucoup de bases de code supportent très bien des centaines de fichiers tant que la structure reste cohérente. L'inverse est aussi vrai: une petite application peut devenir pénible à faire évoluer si tout mélange transport HTTP, logique métier, accès aux données et intégrations externes.
Les symptômes sont connus:
- contrôleurs de 400 lignes
- services nommes
Helper,Manager,Processor - dépôt générique utilisé partout
- duplication de validation
- logique métier cachée dans des handlers HTTP
Une structure de base qui marche bien
Une structure maintenable doit séparer l'intention métier des détails techniques.
src/
Project.Api/
Endpoints/
Contracts/
Middleware/
DependencyInjection/
Project.Application/
Commands/
Queries/
Validators/
Services/
Project.Domain/
Entities/
ValueObjects/
Enums/
Events/
Project.Infrastructure/
Persistence/
Messaging/
ExternalServices/
Cette organisation n'est pas magique, mais elle donne des frontières utiles.
Poser les conventions de configuration dès le départ
La structure des dossiers ne suffit pas. Un projet devient également difficile à maintenir lorsque chaque composant lit directement une variable d'environnement ou une clé de configuration écrite en dur.
ASP.NET Core fournit déjà une chaîne de configuration adaptée aux différents environnements :
appsettings.jsoncontient les valeurs communes et non sensibles ;appsettings.Development.jsonadapte le poste de développement ;- les variables d'environnement remplacent les valeurs au déploiement ;
dotnet user-secretsconserve les secrets locaux hors du dépôt Git ;- le gestionnaire de secrets de la plateforme héberge les secrets de production.
Le code applicatif ne devrait pas connaître les noms de clés dispersés dans la configuration. Des options typées rendent le contrat visible et vérifiable au démarrage :
public sealed class PaymentOptions
{
public const string SectionName = "Payments";
public required Uri BaseUrl { get; init; }
public required string ApiKey { get; init; }
public int TimeoutSeconds { get; init; } = 10;
}
builder.Services
.AddOptions<PaymentOptions>()
.BindConfiguration(PaymentOptions.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart();
Cette validation évite de découvrir une configuration manquante lors de la première requête en production. Elle fait échouer le démarrage avec une cause identifiable.
Une convention simple aide à garder le système lisible : la couche Api compose la configuration et les dépendances, tandis que les couches Application et Domain reçoivent des contrats typés sans dépendre du fournisseur de configuration.
Règle 1: les contrôleurs et endpoints doivent rester minces
Un endpoint ASP.NET Core ne devrait pas prendre de décisions métier. Il traduit une requête HTTP en action applicative.
app.MapPost("/orders", async (
CreateOrderRequest request,
CreateOrderHandler handler,
CancellationToken ct) =>
{
var result = await handler.HandleAsync(new CreateOrderCommand(
request.CustomerId,
request.Items), ct);
return Results.Created($"/orders/{result.OrderId}", result);
});
Si tu vois de la logique de pricing, de validation complexe, de transactions ou d'appels externes ici, la structure s'écarte déjà de la maintenabilité.
Règle 2: les noms doivent décrire une responsabilité
Le mot "service" n'explique rien. Une base maintenable préfère des noms orientés action ou domaine:
CreateOrderHandlerInvoiceNumberGeneratorPaymentRetryPolicyCustomerEligibilityService
Ces noms disent ce que fait le composant. Cela réduit le besoin de documentation implicite.
Règle 3: un cas d'usage = une entrée claire
Pour rester lisible, chaque besoin métier important devrait correspondre à une commande ou une query clairement identifiée.
public sealed record PublishInvoiceCommand(Guid InvoiceId);
public sealed class PublishInvoiceHandler
{
public async Task HandleAsync(PublishInvoiceCommand command, CancellationToken ct)
{
// orchestration du cas d'usage
}
}
Cette approche rend les flux applicatifs plus évidents, facilite les tests et évite les gros services fourre-tout.
Règle 4: centraliser les conventions de validation et d'erreur
Une application ASP.NET Core vieillit mal quand chaque endpoint gère ses erreurs différemment. La maintenabilité passe par des conventions:
- middleware global de gestion des exceptions
- objets de réponse cohérents
- validation centralisée
- journalisation standardisée
Si chaque contrôleur invente sa propre façon de renvoyer une erreur, la dette augmente vite.
Règle 5: isoler l'infrastructure sans la rendre invisible
L'infrastructure doit être claire dans la solution. Base de données, cache, file de messages, fournisseurs externes: tout cela doit avoir une place évidente.
Exemple de dossier Infrastructure/Persistence:
Persistence/
AppDbContext.cs
Configurations/
Repositories/
Migrations/
Exemple de dossier Infrastructure/ExternalServices:
ExternalServices/
OpenAiClient.cs
StripeClient.cs
StorageClient.cs
Le but est simple: un nouveau développeur doit comprendre où vivent les détails techniques en quelques minutes.
Règle 6: éviter les projets "utilitaires" sans frontière claire
Les projets Common, Shared, Utils deviennent souvent des zones de contamination. Du code de tous types finit par y vivre, sans responsabilité claire.
Il vaut mieux:
- garder un petit noyau de primitives techniques bien délimitées
- placer le code partagé dans le domaine ou l'application quand il a un contexte métier
- dupliquer légèrement si cela evite un coupling artificiel
La duplication locale est parfois moins coûteuse qu'une mauvaise abstraction globale.
Règle 7: structurer aussi les tests
Une application maintainable ne se limite pas au code de production. Les tests doivent épouser la structure du projet.
Par exemple:
- tests de domaine pour les invariants
- tests d'application pour les cas d'usage
- tests d'intégration pour la persistance et les API externes
- tests d'API pour les contrats HTTP
Cette séparation evite les suites de tests lentes et peu fiables.
Exemple de dossier par fonctionnalité
Pour certains projets, une structure par fonctionnalité fonctionne encore mieux:
Application/
Orders/
Create/
CreateOrderCommand.cs
CreateOrderHandler.cs
CreateOrderValidator.cs
Cancel/
CancelOrderCommand.cs
CancelOrderHandler.cs
Cette approche est très efficace quand le produit grossit et que les fonctionnalités ont des comportements riches.
Les signaux d'alerte
Tu peux anticiper les problèmes en observant ces signaux:
- tu dois ouvrir cinq dossiers pour comprendre un cas d'usage simple
- une modification HTTP casse du code métier
- des services ont trop de dépendances injectées
- des objets EF Core circulent partout dans l'application
- les nouveaux développeurs hésitent constamment sur l'emplacement du code
Ce ne sont pas des détails. Ce sont des symptômes structurels.
Une checklist simple de maintenabilité
Avant d'ajouter une nouvelle fonctionnalité, vérifie:
- où vit le cas d'usage ?
- où vivent les règles métier ?
- où vivent les détails d'infrastructure ?
- le nom des classes est-il explicite ?
- le test correspondant est-il simple à écrire ?
Si la réponse est floue, la structure doit être clarifiée avant d'ajouter plus de code.
Conclusion
Structurer un projet ASP.NET Core pour rester maintenable n'est pas une question de mode. C'est une discipline de conception.
Une bonne structure rend les décisions visibles, réduit les effets de bord et permet à l'équipe de faire évoluer l'application sans multiplier les régressions. C'est exactement ce qu'on attend d'un projet qui doit durer.
