J'ai travaillé sur les deux. Des microservices distribués sur Azure Service Bus avec Docker, Kubernetes et tout le cortège d'outils qui va avec. Et des monolithes modulaires .NET que j'ai conçus ou hérités. Après cinq projets conséquents, mon opinion a évolué — parfois à rebours de ce que l'industrie préconise. Voici un retour honnête, sans vendre de méthodologie.
La fausse promesse des microservices
En 2019-2020, les microservices étaient la réponse à tout. Le monolithe était devenu une insulte dans les conversations d'architectes. J'ai embarqué dans cette vague sur un projet de plateforme RH pour un grand groupe retail. Architecture cible : 12 microservices, un API Gateway, Azure Service Bus pour le messaging, Kubernetes pour l'orchestration.
Le résultat deux ans plus tard ? Un système qui fonctionnait — mais dont le coût opérationnel dépassait tout ce qu'on avait estimé. Chaque déploiement impliquait de gérer les versions d'API entre services. Chaque bug de production nécessitait du tracing distribué pour comprendre dans quel service la requête avait commencé à dérailler. L'équipe de 6 développeurs passait 30% de son temps à maintenir l'infrastructure plutôt qu'à livrer de la valeur métier.
"Les microservices résolvent des problèmes d'organisation et de scalabilité différenciée. Si vous n'avez pas ces problèmes, vous héritez des coûts sans les bénéfices."
Ce n'est pas que les microservices sont mauvais. C'est qu'on les appliquait à un contexte qui n'en avait pas besoin. L'équipe était trop petite pour justifier l'autonomie de déploiement par service. Les domaines n'avaient pas des exigences de scalabilité radicalement différentes. On avait pris une solution à un problème d'échelle Netflix pour un système dont le pic de charge était raisonnable.
Le vrai coût des systèmes distribués
Avant de choisir une architecture, il faut comptabiliser les coûts réels des systèmes distribués. Ceux-là sont systématiquement sous-estimés dans les phases d'architecture.
La cohérence éventuelle
Dès que vos données traversent deux services, vous ne pouvez plus garantir la cohérence immédiate. Un client peut voir son solde mis à jour dans le service "Compte" alors que le service "Facturation" n'a pas encore traité l'événement correspondant. Gérer ces cas — avec des sagas, des compensating transactions, des états intermédiaires visibles — est un travail de fond considérable que les architectures distribuées importent par défaut.
Le réseau comme point de défaillance
Dans un monolithe, un appel de méthode ne peut pas échouer pour des raisons réseau. Dans un système distribué, chaque appel inter-services peut subir un timeout, une latence accrue, une indisponibilité temporaire. Il faut des patterns de résilience (retry, circuit breaker, bulkhead) sur chaque point de communication. C'est du code défensif permanent.
Le coût de déploiement et d'observabilité
12 services = 12 pipelines CI/CD à maintenir, 12 dashboards de monitoring, 12 configurations de logs à agréger. Sans un investissement sérieux dans l'outillage DevOps (et les compétences associées), l'équipe se retrouve à naviguer à l'aveugle en production.
Quand le monolithe modulaire est la meilleure réponse
Sur un projet de plateforme e-learning pour une ESN partenaire, j'ai proposé et implémenté un monolithe modulaire en .NET 8. L'équipe était de 4 développeurs, le domaine était bien délimité (catalogue, formation, utilisateurs, facturation), et les exigences de scalabilité étaient uniformes.
Un an et demi plus tard, le bilan est net :
- Zéro problème de cohérence des données — tout est dans la même transaction SQL si nécessaire
- Déploiement en 8 minutes — un seul artefact, un seul pipeline
- Debugging trivial — la stack trace complète est dans un seul processus
- La séparation des modules est aussi stricte qu'avec des microservices — les assemblies y veillent
Comment structurer un monolithe modulaire en .NET
La clé d'un monolithe modulaire réussi est de reproduire les boundaries des microservices au niveau du code — sans la complexité de la distribution. En pratique, chaque module devient sa propre assembly .NET.
// Structure d'assemblies par module — boundaries aussi stricts que des microservices
// Elearning.sln
// ├── src/
// │ ├── Elearning.Catalog/ ← module Catalogue (assembly séparée)
// │ │ ├── Elearning.Catalog.Domain
// │ │ ├── Elearning.Catalog.Application
// │ │ └── Elearning.Catalog.Infrastructure
// │ ├── Elearning.Training/ ← module Formation
// │ │ ├── Elearning.Training.Domain
// │ │ ├── Elearning.Training.Application
// │ │ └── Elearning.Training.Infrastructure
// │ ├── Elearning.Billing/ ← module Facturation
// │ └── Elearning.Host/ ← point d'entrée unique (ASP.NET Core)
// └── tests/
// Règle absolue : Elearning.Training ne référence PAS Elearning.Catalog directement
// La communication inter-modules passe UNIQUEMENT par des contrats partagés
La règle non-négociable : aucune référence directe entre modules. Le module Training ne peut pas instancier une classe du module Catalog. Les dépendances directes créent exactement le couplage qu'on veut éviter.
Communication inter-modules via MediatR
Pour la communication entre modules dans le même processus, j'utilise MediatR avec des contrats définis dans des assemblies partagées. Cela simule l'asynchronisme des messages sans le coût du réseau.
// Contrat partagé dans Elearning.Contracts (assembly commune, sans logique)
public sealed record CoursePublishedEvent(Guid CourseId, string Title, int DurationMinutes)
: INotification;
// Handler dans le module Training — il ne connaît pas le module Catalog
public sealed class EnrollmentOpenedOnCoursePublished
: INotificationHandler<CoursePublishedEvent>
{
private readonly ITrainingRepository _repository;
public EnrollmentOpenedOnCoursePublished(ITrainingRepository repository)
{
_repository = repository;
}
public async Task Handle(CoursePublishedEvent notification, CancellationToken ct)
{
var enrollment = TrainingEnrollment.OpenFor(notification.CourseId, notification.Title);
await _repository.AddAsync(enrollment, ct);
}
}
// Le module Catalog publie l'événement — il ne sait pas qui l'écoute
public sealed class PublishCourseCommandHandler
: IRequestHandler<PublishCourseCommand, PublishCourseResult>
{
private readonly ICourseRepository _repository;
private readonly IPublisher _publisher;
public PublishCourseCommandHandler(ICourseRepository repository, IPublisher publisher)
{
_repository = repository;
_publisher = publisher;
}
public async Task<PublishCourseResult> Handle(PublishCourseCommand cmd, CancellationToken ct)
{
var course = await _repository.GetByIdAsync(cmd.CourseId, ct);
course.Publish();
await _repository.UpdateAsync(course, ct);
// Notification in-process — pas de réseau, pas de cohérence éventuelle
await _publisher.Publish(
new CoursePublishedEvent(course.Id, course.Title, course.DurationMinutes), ct);
return new PublishCourseResult(course.Id);
}
}
Ce pattern reproduit exactement la sémantique d'un événement de domaine entre microservices — mais en in-process. La migration vers un vrai message broker (Azure Service Bus, RabbitMQ) devient alors une décision d'infrastructure, pas une réécriture architecturale.
Le chemin de migration : du monolithe vers les microservices
Un monolithe bien modulaire prépare la migration future. Quand le module Billing doit être extrait en service indépendant parce que l'entreprise a acquis une solution de facturation tierce, l'opération est chirurgicale :
- Le contrat d'interface (
IBillingService) existe déjà dans les assemblies partagées - On remplace l'implémentation in-process par un appel HTTP ou un message broker
- Les autres modules ne changent pas — ils interagissent toujours avec le même contrat
- On déploie le nouveau service, on redirige les appels, on valide en production
Sur un monolithe non-modulaire (le vrai "big ball of mud"), cette extraction prend des mois. Sur un monolithe modulaire, elle prend des semaines. C'est une assurance architecturale à bas coût.
Mon framework de décision
Après cinq projets, voici les questions que je pose systématiquement avant de recommander une architecture :
- Combien d'équipes produit indépendantes ? En dessous de 5-6, le monolithe modulaire gagne presque toujours.
- Les domaines ont-ils des exigences de scalabilité radicalement différentes ? Si le catalogue peut tenir sur 2 instances et le moteur de recherche en nécessite 20, les microservices ont du sens.
- L'équipe a-t-elle les compétences DevOps pour gérer la complexité opérationnelle ? Les microservices sans culture DevOps solide finissent en cauchemar de maintenance.
- Le domaine est-il stable ou en exploration ? Dans un domaine en exploration, les boundaries changent souvent. Un monolithe modulaire permet de les affiner sans douleur.
- Quel est l'horizon du projet ? Pour un MVP ou un projet à 18 mois, la complexité microservices est rarement rentabilisée.
"Commencer par un monolithe modulaire bien structuré n'est pas une décision par défaut — c'est souvent la décision la plus sophistiquée qu'un architecte puisse prendre."
La sophistication ne réside pas dans le nombre de services déployés. Elle est dans la clarté des boundaries, la qualité du découplage, et la capacité du système à évoluer sans douleur. Un monolithe modulaire .NET, avec des assemblies séparées par module et des contrats MediatR pour la communication, donne exactement ça — sans les coûts CQRS distribués tant qu'ils ne sont pas nécessaires.
Pour aller plus loin sur la structuration du code, la Clean Architecture en .NET est le complément naturel de cette approche : les mêmes principes de séparation des couches s'appliquent à l'intérieur de chaque module.
Vous concevez une nouvelle architecture .NET ?
J'interviens en mission pour auditer, concevoir ou faire évoluer des architectures
.NET complexes — monolithes modulaires, microservices, ou migration entre les deux.