Warum du (wahrscheinlich) keine Microservices brauchst: Ein Plädoyer für den Modular Monolith

Julian | Dec 25, 2025 min read

Der Trugschluss der “Skalierbarkeit ab Tag 1”

Stell dir vor, du startest ein neues Projekt. Das Team ist motiviert, das Whiteboard ist frisch gewischt, und die erste Frage, die im Raum steht, lautet: “Welche Microservices brauchen wir?”

Wir alle kennen diese Situation. Wir schauen auf Giganten wie Netflix oder Uber und denken: “Wenn die so bauen, muss das der ‘richtige’ Weg sein.” Doch was oft übersehen wird: Netflix hat Netflix-Probleme. Dein Startup oder dein mittelständisches Projekt hat wahrscheinlich (noch) ganz andere Sorgen.

Oft treibt uns das sogenannte “Resume Driven Development” in diese Richtung. Wir wollen Technologien einsetzen, die sich gut im Lebenslauf machen. Das ist menschlich, aber architektonisch gefährlich. Denn der Preis, den wir für Microservices zahlen, ist nicht Geld, sondern Komplexität.

Sobald wir eine Anwendung in kleine, über das Netzwerk kommunizierende Teile zerschlagen, betreten wir die Welt der verteilten Systeme. Und hier greifen die “Fallacies of Distributed Computing” (die Irrtümer verteilter Systeme). Wir nehmen fälschlicherweise an, dass das Netzwerk zuverlässig ist, dass Latenz null ist und Bandbreite unendlich. Die Realität sieht anders aus: Netzwerke fallen aus, Timeouts passieren, und Services sind unerreichbar.

Das Ergebnis? Der Cognitive Load – also die mentale Kapazität, die dein Team aufbringen muss, um das System überhaupt zu verstehen und zu betreiben – explodiert. Statt Features zu bauen, verbringt ihr Zeit damit, Race Conditions zwischen Services zu debuggen oder Distributed Tracing zu konfigurieren.

Aber es gibt einen Mittelweg. Eine Architektur, die die Übersichtlichkeit eines Monolithen mit der Struktur von Microservices verbindet, ohne den operativen Wahnsinn: Der Modular Monolith.

In diesem Artikel zeige ich dir, warum das für 90% der Projekte die bessere Wahl ist und wie du Modularität erreichst, ohne dein System in tausend Teile zu sprengen.

Die versteckten Kosten von Microservices

Viele Teams unterschätzen drastisch, was es bedeutet, eine monolithische Funktion durch einen Netzwerkaufruf zu ersetzen. Es ist nicht nur ein Austausch von Code, es ist ein Paradigmenwechsel. Schauen wir uns die drei teuersten Posten auf dieser Rechnung an.

1. Latenz und Zuverlässigkeit: Physik lässt sich nicht wegoptimieren

In einem Monolithen ist die Kommunikation zwischen zwei Modulen (sagen wir, dem UserService und dem BillingService) ein einfacher Methodenaufruf im Arbeitsspeicher. Das kostet Nanosekunden und gelingt – solange der Prozess läuft – immer.

In einer Microservices-Architektur wird daraus ein Netzwerkaufruf (oft via HTTP/REST oder gRPC). Plötzlich sprechen wir über Millisekunden. Das klingt wenig, summiert sich aber bei komplexen Aufrufketten (“Fan-out”) schnell zu einer spürbaren Latenz. Viel kritischer ist jedoch die Zuverlässigkeit: Netzwerke haben Schluckauf. Pakete gehen verloren. Router fallen aus. Dein Code muss nun jedes Mal damit rechnen, dass die Gegenseite nicht antwortet. Du brauchst Retries, Circuit Breaker und Fallback-Strategien für triviale Operationen.

2. Der Abschied von ACID: Datenkonsistenz ist hart

Das vielleicht schmerzhafteste Opfer beim Wechsel zu Microservices ist der Verlust der klassischen Datenbank-Transaktion.

Im Monolithen profitierst du von ACID (Atomicity, Consistency, Isolation, Durability). Ein Beispiel: Ein neuer Nutzer registriert sich, und wir legen gleichzeitig einen Eintrag in der users-Tabelle und einen in der wallets-Tabelle an. Geht eines von beiden schief, macht die Datenbank ein ROLLBACK. Alles ist sauber, der Zustand ist konsistent.

Bei Microservices hat (idealerweise) jeder Service seine eigene Datenbank. Eine Transaktion über zwei Services hinweg gibt es so nicht “out of the box”. Du landest in der Welt der Eventual Consistency. Das bedeutet, das System ist irgendwann konsistent, aber nicht jetzt sofort. Um Konsistenz zu gewährleisten, musst du komplexe Muster wie Sagas implementieren: Wenn Schritt B fehlschlägt, muss Service A eine “Kompensations-Transaktion” ausführen, um Schritt A rückgängig zu machen. Das ist extrem fehleranfällig und schwer zu debuggen.

3. Operational Overhead: Wer betreibt das Monster?

Ein Monolith ist ein Artefakt. Ein Deployment, ein Log-File, eine Datenbank. Microservices bedeuten: 20 Artefakte, 20 Deployments, 20 Datenbanken. Du brauchst plötzlich eine Infrastruktur, die so komplex ist wie deine Software selbst. Container-Orchestrierung (Kubernetes), Service Meshes (Istio, Linkerd), zentralisiertes Logging und Distributed Tracing (Jaeger, Zipkin) werden zur Pflicht, nicht zur Kür. Die Komplexität verschwindet nicht, sie verschiebt sich nur: Vom Applikations-Code in die Infrastruktur (“Ops”).

Enter the Modular Monolith

Wenn wir “Monolith” sagen, zucken viele Entwickler zusammen. Sie denken an Legacy-Code, an Klassen mit 5000 Zeilen und an das gefürchtete “Spaghetti-Code”-Monster, bei dem eine Änderung im Warenkorb unerklärlicherweise die PDF-Rechnungserstellung zerschießt.

In der Fachsprache nennen wir diesen Zustand “Big Ball of Mud”. Das ist ein Anti-Pattern, keine Architektur.

Der Modular Monolith ist das genaue Gegenteil. Es ist ein System, das als eine Einheit deployt wird (single deployment unit), aber im Inneren so strikt strukturiert ist wie Microservices.

Was zeichnet ihn aus?

1. Bounded Contexts (Grenzen setzen)

Wir bedienen uns hier beim Domain-Driven Design (DDD). Ein “Bounded Context” ist ein abgegrenzter Bereich innerhalb deiner Domäne, in dem bestimmte Begriffe und Regeln gelten. Im Modular Monolith bilden wir diese Kontexte direkt als Module im Code ab. Ein Modul “Inventory” kümmert sich um Lagerbestände. Ein Modul “Billing” kümmert sich um Rechnungen. Das Wichtigste: Datenhoheit. Das Billing-Modul darf niemals direkt SQL-Queries auf die Tabellen des Inventory-Moduls ausführen. Es muss die öffentliche API (ein Interface oder eine Service-Klasse) des anderen Moduls nutzen.

2. Strikte Kapselung (Encapsulation)

Jedes Modul ist eine Black Box. Es hat:

  • Eine öffentliche API: Das, was andere Module aufrufen dürfen.
  • Interne Implementierung: Klassen, Hilfsmethoden und Datenbank-Logik, die von außen unsichtbar sind. In Sprachen wie Java (via Packages/Modules), .NET (via Projects) oder TypeScript (via index.ts Exports und ESLint-Regeln) können wir diese Sichtbarkeiten physisch erzwingen. Wenn deine IDE dir keine Autovervollständigung für eine interne Klasse eines anderen Moduls anbietet, hast du alles richtig gemacht.

3. Der Todfeind: Zyklische Abhängigkeiten

In einem schlechten Monolithen ruft Modul A das Modul B auf, und B ruft A auf. Das führt zu einer engen Kopplung, die Refactoring fast unmöglich macht. Ein Modular Monolith erzwingt einen azyklischen Graphen. Das bedeutet: Wenn Sales das Product-Modul braucht, darf Product nichts von Sales wissen. Müssen sie doch kommunizieren, nutzen wir Domain Events (z.B. “OrderPlacedEvent”), auf die andere Module reagieren können. Das entkoppelt den direkten Aufruf, bleibt aber im selben Prozess.

Zusammengefasst: Wir bauen die logischen Grenzen von Microservices, behalten aber die physische Einfachheit (ein Prozess, eine DB-Verbindung) des Monolithen.

Perfekt. Jetzt wird es konkret. Theorie ist nett, aber am Ende müssen wir Code committen.

Implementation: Wie man Modularität erzwingt

Wie sieht das nun in der IDE aus? Der häufigste Fehler ist es, bei der klassischen “Layered Architecture” zu bleiben (Ordner für Controllers, Services, Models). Das ist Gift für Modularität, weil es funktional zusammengehörige Dinge trennt.

Wir wechseln stattdessen zu “Package by Feature” (oder Component).

1. Die Projektstruktur

Dein Projekt-Root sollte nicht technische Layer widerspiegeln, sondern deine Business-Domänen. Hier ist ein Beispiel für eine saubere Struktur (z.B. in TypeScript/Node.js oder Go), die Modularität atmet:

src/
  ├── modules/
  │   ├── billing/          # Bounded Context: Billing
  │   │   ├── api/          # PUBLIC: Das einzige, was andere sehen dürfen
  │   │   │     ├── BillingService.interface.ts
  │   │   │     └── events.ts
  │   │   ├── internal/     # PRIVATE: Hier liegt die Logik
  │   │   │     ├── domain/
  │   │   │     ├── database/
  │   │   │     └── BillingServiceImpl.ts
  │   │   └── index.ts      # Exportiert NUR Inhalte aus /api
  │   │
  │   ├── inventory/        # Bounded Context: Inventory
  │   │   ├── ...
  │   └── users/            # Bounded Context: Users
  │       ├── ...
  └── shared/               # Der "Kernel": Logging, Utils, Base Classes

Der Trick ist die index.ts (oder package-info.java / .csproj Referenzen): Sie fungiert als Pförtner. Alles, was im internal Ordner liegt, wird nicht exportiert. Wenn das Inventory-Modul versucht, BillingServiceImpl direkt zu importieren, muss das scheitern.

2. Architektur als Code: Die automatisierte Polizei

Wir sind alle nur Menschen. Unter Zeitdruck importiert man schnell mal “nur kurz” die Datenbank-Klasse des anderen Moduls, um ein Problem zu lösen. Zack, Kopplung erzeugt.

Vertrauen ist gut, CI-Pipeline ist besser. Wir nutzen Tools, um diese Regeln bei jedem Build zu prüfen.

  • Java: ArchUnit ist der Goldstandard. Du schreibst Unit-Tests, die deine Architektur prüfen:
classes().that().resideInAPackage("..billing..")
    .should().onlyBeAccessed().byAnyPackage("..billing..", "..orders..");
  • TypeScript/JavaScript: Hier hilft ESLint (z.B. mit eslint-plugin-boundaries oder dependency-cruiser). Du kannst Regeln definieren wie: “Importe aus modules/billing/internal sind überall verboten, außer in modules/billing selbst.”
  • C#/.NET: NetArchTest bietet ähnliche Funktionen wie ArchUnit.

Wenn du diese Tests einrichtest, bricht der Build, sobald jemand die Modulgrenzen verletzt. Das zwingt das Team dazu, über die API zu gehen oder die Architektur bewusst anzupassen, statt sie versehentlich zu untergraben.

3. Entkopplung durch Events

Manchmal muss Modul A etwas tun, wenn in Modul B etwas passiert, ohne dass B von A weiß. Beispiel: Wenn ein neuer User registriert wird (Users Modul), soll eine Willkommens-Email gesendet werden (Notification Modul). Statt dass Users den NotificationService aufruft, publiziert es ein Event: UserRegisteredEvent. Das Notification-Modul hört darauf. Das passiert alles in-process (im Arbeitsspeicher), ist also extrem schnell und braucht keinen Message Broker wie Kafka (obwohl man später leicht darauf umstellen könnte).

Der Ausweg – Vom Modular Monolith zu Microservices (wenn nötig)

Das stärkste Argument gegen den Monolithen ist oft: “Aber was, wenn wir so erfolgreich werden, dass wir skalieren müssen?”

Genau hier spielt der Modular Monolith sein Ass aus. Er ist keine Sackgasse, sondern eine Startrampe.

Stell dir vor, dein Startup explodiert. Dein ImageProcessing-Modul bringt den Server zum Glühen, während der Rest der Anwendung sich langweilt. In einem “Big Ball of Mud” wärst du jetzt verloren. Alles ist verwoben, nichts lässt sich isolieren.

In deinem Modular Monolith hingegen hast du bereits Bounded Contexts definiert. Du hast klare APIs. Du hast keine zyklischen Abhängigkeiten. Um das ImageProcessing-Modul in einen echten Microservice zu verwandeln, musst du:

  1. Den Code-Ordner in ein neues Repository verschieben.
  2. Die interne ImageProcessingService-Schnittstelle durch einen gRPC- oder REST-Client ersetzen.
  3. Fertig.

Der Rest deiner Anwendung merkt den Unterschied kaum, weil die logischen Grenzen schon immer da waren. Du skalierst also nicht basierend auf Hype, sondern basierend auf echten Metriken und Schmerzen. Du extrahierst nur das, was wirklich unabhängig sein muss.

Fazit: Build for problems you actually have.

Software-Architektur ist die Kunst, Entscheidungen so lange wie möglich hinauszuzögern, ohne den Fortschritt zu blockieren. Microservices ab Tag 1 zu bauen, ist eine vorzeitige Optimierung. Ein Modular Monolith gibt dir die Geschwindigkeit am Anfang und die Flexibilität für später.

Sei pragmatisch. Sei faul (im besten Sinne). Und bau erst dann wie Netflix, wenn du Netflix bist.