Modularer Monolith: Das Architekturmuster, das Unternehmen vor Microservices in Betracht ziehen sollten
Warum ein modularer Monolith die Bounded-Context-Vorteile von Microservices bietet, ohne die Distributed-System-Kosten — und wann man Services extrahieren sollte.
Die Branche hat ein Jahrzehnt damit verbracht, Microservices zu evangelisieren. Das Pendel schwingt zurück — nicht zu den Monolithen von 2010, sondern zu etwas Pragmatischerem: dem modularen Monolithen.
Ein modularer Monolith bietet Bounded Contexts, klare Ownership und unabhängige Weiterentwicklung — ohne die Distributed-System-Kosten von Netzwerkaufrufen, Eventual Consistency und 47 Kubernetes-Manifesten pro Service.
Dieser Beitrag erklärt, was ein modularer Monolith tatsächlich ist, wie man Modulgrenzen in .NET durchsetzt und wann (nicht ob) man Module in Services extrahieren sollte.
Warum Microservices in den meisten Unternehmen scheitern
Seien wir ehrlich, warum Microservices enttäuschen:
Vorzeitige Zerlegung — Teams teilen ihre Anwendung in Services auf, bevor sie ihre Domänengrenzen verstehen. Das Ergebnis ist ein verteilter Monolith: Services, die nicht unabhängig deployed werden können, weil sie eine Datenbank teilen oder sich synchron für jede Operation aufrufen.
Operativer Overhead — Jeder Service braucht CI/CD, Monitoring, Logging, Health Checks, Service Discovery und eine On-Call-Rotation. Für ein Team von 8 Ingenieuren, das 20 Services verwaltet, verbraucht die Infrastruktur-Steuer mehr Zeit als Feature-Entwicklung.
Datenkonsistenz-Alpträume — Verteilte Transaktionen, Saga-Patterns und Eventual Consistency sind wirklich schwer. Die meisten Teams unterschätzen diese Komplexität, bis sie um 2 Uhr morgens einen Produktionsvorfall debuggen, bei dem drei Services sich über den Status einer Bestellung uneinig sind.
Netzwerk als Zuverlässigkeitsgrenze — Jeder Service-Aufruf ist ein potenzieller Fehlerpunkt. Retry-Logik, Circuit Breaker, Timeouts und Fallbacks fügen Code hinzu, den ein Monolith schlicht nicht braucht.
Nichts davon bedeutet, dass Microservices falsch sind. Sie sind richtig — für spezifische Probleme bei spezifischen Skalen. Der Fehler ist, sie als Standard-Startarchitektur zu übernehmen.
Was ein modularer Monolith tatsächlich ist
Ein modularer Monolith ist eine einzelne Deploy-Einheit, deren interne Struktur das spiegelt, was man mit Microservices bauen würde — ohne die Netzwerkgrenze.
Kernprinzipien
- Jedes Modul besitzt einen Bounded Context — Users, Orders, Payments, Inventory sind separate Module mit eigenen Domänenmodellen
- Keine geteilten Datenbanktabellen — Jedes Modul hat sein eigenes Schema
- Explizite öffentliche API pro Modul — Module kommunizieren über definierte Interfaces, nicht durch Zugriff auf interne Details
- Durchgesetzte Grenzen — Der Compiler verhindert Verletzungen, nicht nur Konvention
Die Struktur in .NET
src/
├── Modules/
│ ├── Users/
│ │ ├── Users.Domain/
│ │ ├── Users.Application/
│ │ ├── Users.Infrastructure/
│ │ └── Users.Contracts/
│ ├── Orders/
│ │ ├── Orders.Domain/
│ │ ├── Orders.Application/
│ │ ├── Orders.Infrastructure/
│ │ └── Orders.Contracts/
│ └── Payments/
│ └── Payments.Contracts/
├── Host/
└── Shared/Modulgrenzen in .NET durchsetzen
Konventionsbasierte Grenzen brechen innerhalb von Wochen. Entwickler unter Zeitdruck nehmen Abkürzungen — es sei denn, der Compiler verhindert es.
Strategie 1: Assembly-Level Access Control
Jedes Modul verwendet standardmäßig internal. Nur das Contracts-Projekt exponiert öffentliche Typen:
// Orders.Domain - standardmäßig internal
internal sealed class Order
{
internal OrderId Id { get; private set; }
internal CustomerId CustomerId { get; private set; }
internal OrderStatus Status { get; private set; }
internal Money TotalAmount { get; private set; }
internal static Order Create(CustomerId customerId, IReadOnlyList<OrderLine> lines)
{
var order = new Order
{
Id = OrderId.New(),
CustomerId = customerId,
Status = OrderStatus.Created,
TotalAmount = lines.Sum(l => l.Amount)
};
order.AddDomainEvent(new OrderCreatedEvent(order.Id, customerId));
return order;
}
}// Orders.Contracts - die einzige öffentliche Oberfläche
public interface IOrderService
{
Task<OrderDto> GetOrderAsync(Guid orderId);
Task<Guid> PlaceOrderAsync(PlaceOrderRequest request);
}
public sealed record OrderDto(Guid Id, Guid CustomerId, string Status, decimal Total);Der Schlüssel: Andere Module können nur Orders.Contracts referenzieren. Sie können Orders.Domain, Orders.Application oder Orders.Infrastructure nicht sehen.
Strategie 2: ArchUnit-Tests
[Fact]
public void Orders_Should_Not_Depend_On_Users_Infrastructure()
{
var result = Types.InAssembly(typeof(Order).Assembly)
.Should()
.NotHaveDependencyOn("Users.Infrastructure")
.GetResult();
result.IsSuccessful.Should().BeTrue();
}Strategie 3: Separate DbContexts pro Modul
internal sealed class OrdersDbContext : DbContext
{
public DbSet<Order> Orders => Set<Order>();
public DbSet<OrderLine> OrderLines => Set<OrderLine>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("orders");
modelBuilder.ApplyConfigurationsFromAssembly(typeof(OrdersDbContext).Assembly);
}
}Modulgrenz-Architektur
Extraktions-Entscheidungsfluss
Inter-Modul-Kommunikation
Synchron: Interface-basierte Aufrufe
public sealed class PlaceOrderHandler : ICommandHandler<PlaceOrderCommand, Guid>
{
private readonly IUserService _userService;
public async Task<Guid> Handle(PlaceOrderCommand command, CancellationToken ct)
{
var user = await _userService.GetUserAsync(command.CustomerId);
if (user is null) throw new UserNotFoundException(command.CustomerId);
// ... Order erstellen
}
}Asynchron: Domain Events
public sealed record OrderPlacedEvent(Guid OrderId, Guid CustomerId, decimal Amount) : IDomainEvent;
internal sealed class OrderPlacedHandler : IDomainEventHandler<OrderPlacedEvent>
{
public async Task Handle(OrderPlacedEvent notification, CancellationToken ct)
{
// Payment Intent erstellen, Mittel reservieren, etc.
}
}Im Monolith werden diese Events in-process via MediatR dispatched. Bei späterer Extraktion tauscht man den In-Process-Dispatcher gegen einen Message Broker — der Handler-Code ändert sich kaum.
Wann ein Modul in einen Microservice extrahieren
Extrahieren wenn:
- Ein Modul unabhängige Skalierung braucht (z.B. Bildverarbeitung mit GPU)
- Ein Modul radikal andere Deploy-Kadenz hat (z.B. ML-Modell-Updates täglich)
- Ein Modul eine andere Technologie erfordert (z.B. Python für ML)
- Ein Team groß genug gewachsen ist (8+ Ingenieure auf einem Modul)
Nicht extrahieren wenn:
- „Es fühlt sich an wie ein Service" — Gefühl ist kein Grund
- „Wir wollen Team-Autonomie" — Modulgrenzen bieten das bereits
- „Microservices sind Best Practice" — sie sind ein Trade-off
- Das Modul hohe Kopplung zu anderen Modulen hat
Der Migrationspfad
- Das
Contracts-Assembly wird ein NuGet-Paket oder gRPC-Proto-Definition - Der In-Process-Event-Dispatcher wird ein Service-Bus-Publisher
- Der DbContext des Moduls hat bereits sein eigenes Schema — in separate Datenbank aufteilen
- Die DI-Registrierung des Moduls wird die
Program.cseines separaten Service
Das dauert Tage, nicht Monate — weil die harte Domänenzerlegung bereits erledigt ist.
Praxisempfehlung
Wir empfehlen den modularen Monolith als Standard-Architektur für Enterprise-Anwendungen mit:
- Weniger als 50 Ingenieuren am System
- Keinen extremen Skalierungsunterschieden zwischen Modulen
- Einer Domäne, die noch erkundet wird
- Einem Zeitrahmen, der keine Distributed-System-Infrastruktur-Investition erlaubt
Starten Sie modular. Extrahieren Sie chirurgisch. Lassen Sie Produktions-Traffic-Muster — nicht Architekturdiagramme — Ihnen sagen, wann Distribution notwendig ist.
Möchten Sie eine modulare Architektur für Ihre Enterprise-Anwendung entwerfen? Kontaktieren Sie uns — wir helfen Teams, die richtigen architektonischen Grenzen zu finden, ohne Over-Engineering.
Themen