Was ein modularer Monolith ist
Ein modularer Monolith ist eine einzelne, deploybare Anwendung mit starken internen Modulgrenzen. Von außen sieht er exakt wie ein Monolith aus: ein Prozess, ein Build, eine Datenbank, ein Deploy. Im Inneren ist er in Module organisiert, die ihre eigenen Daten besitzen und eine schmale öffentliche Schnittstelle bereitstellen – und diese Grenzen werden strikt durchgesetzt, statt nur als Ziel definiert zu werden.
Dies ist kein Kompromiss oder ein Übergangsstadium, mit dem man sich zufrieden gibt. Es ist eine legitime Architektur, die viele Systeme niemals verlassen sollten. Sie bewahrt die Eigenschaften, die die Softwareentwicklung einfach machen – In-Process-Aufrufe, eine einzige Transaktion, eine Pipeline, kostengünstige Refactorings – und fügt die Disziplin hinzu, die verhindert, dass eine wachsende Codebasis in ein unentwirrbares Knäuel ausartet.
Der entscheidende Unterschied liegt zwischen einem deploybaren Artefakt und einem unstrukturierten Blob. Ein traditioneller, schichtbasierter Monolith kennt kein Konzept von Ownership: Jeder Controller kann auf jede Tabelle zugreifen, und eine Änderung wirkt sich auf die gesamte Codebasis aus. Ein modularer Monolith hingegen legt fest, dass das Billing-Modul das einzige ist, das Rechnungen versteht, und alle anderen über eine vom Modul kontrollierte Schnittstelle damit kommunizieren.
Warum dies der vernünftige Standard ist
Die meisten Teams greifen zu Microservices, um Probleme zu lösen, die sie gar nicht haben. Ein modularer Monolith adressiert die Probleme, die sie tatsächlich haben – eine Codebasis, die schwer zu ändern ist, unklare Zuständigkeiten, langsame Auslieferung –, ohne dabei ein Netzwerk zwischen den einzelnen Teilen einzuführen.
Die praktischen Vorteile sind erheblich:
- In-Process-Aufrufe sind kostenlos. Wenn ein Modul ein anderes aufruft, ist dies ein Funktionsaufruf und keine Anfrage mit Timeout, Retry-Policy und Fehlermodus.
- Transaktionen sind echt. Ein Use Case, der ein Modul betrifft, wird atomar committet. Es gibt keine Saga, keine Kompensation und kein Zeitfenster für Inkonsistenzen.
- Refactoring ist ein Commit. Das Verschieben einer Grenze, das Umbenennen eines Konzepts oder das Zusammenführen zweier Module ist normale Arbeit und keine Migration mit Dual-Writes und einem Cutover.
- Der Betrieb bleibt schlank. Ein Build, ein Deploy, ein Satz Logs, ein On-Call-Dienst. Für ein fünfköpfiges Team ist das kein nebensächlicher Punkt.
- Die Domain bekommt Zeit, sich zu festigen. Sie können Entscheidungen über die Grenzen aufschieben, bis Sie das Geschäft wirklich verstehen, anstatt Vermutungen in der Infrastruktur einzufrieren.
Der ehrliche Preis dafür ist, dass Module nicht unabhängig ausfallen oder skalieren und ein Bug in einem Modul die gesamte Anwendung zum Absturz bringen kann. Für die meisten Teams und in den meisten Phasen eines Produkts ist dies ein Kompromiss, den man gerne eingeht.
Module orientieren sich an Business Capabilities
Ein Modul sollte einer Business Capability (Geschäftsfähigkeit) zugeordnet sein, nicht einer technischen Schicht. Abrechnung, Katalog, Versand, Identität und Benachrichtigungen sind Capabilities. controllers, services, repositories und utils hingegen sind Schichten; eine Gruppierung nach diesen führt zum klassischen Layered Monolith, bei dem jede Feature-Änderung Änderungen in vier verschiedenen Verzeichnissen erfordert.
Eine gute Modulgrenze weist dieselben Eigenschaften auf wie eine gute Service-Grenze:
- Sie enthält einen kohärenten Satz an Regeln, die gemeinsam geändert werden.
- Sie verbirgt weitaus mehr, als sie exponiert.
- Sie kann von einem Team verstanden werden, ohne den Rest des Systems lesen zu müssen.
Konkret ist jedes Modul ein Ordner mit einer eigenen internen Struktur:
src/modules/billing/
domain/ # entities, value objects, invariants
invoice.ts
money.ts
application/ # use cases and orchestration
billing-service.ts
order-handlers.ts
infra/ # persistence and external clients
invoice-repository.ts
stripe-client.ts
index.ts # the only public entry point
Die Ordner domain und infra sind privat. index.ts re-exportiert die kleine Menge an Typen und Services, die der Rest der Anwendung nutzen darf. Diese einzelne Datei ist der Vertrag des Moduls und sollte klein genug sein, um in einer Minute gelesen zu werden.
Falls Sie bereits die Dependency Rule der Clean Architecture kennen: Dies ist derselbe Ansatz, angewandt auf Modulebene. Die Domain im Zentrum hängt von nichts Externem ab, und die Infrastructure hängt von der Domain ab – nicht umgekehrt.
Der Shared Kernel
Einige Konzepte gehören tatsächlich zu keinem einzelnen Modul. Währung, ein CustomerId, ein Clock oder ein Result-Typ werden überall verwendet und gehören niemandem. Packen Sie diese in ein kleines, explizites shared-Package und halten Sie dieses bewusst winzig.
src/shared/
money.ts # value object, no dependencies
ids.ts # branded id types
clock.ts # a testable time source
Ein Shared Kernel ist ein Kopplungspunkt, behandeln Sie dessen Wachstum daher als Warnsignal. In dem Moment, in dem shared einen Service, ein Repository oder irgendetwas enthält, das eine Business-Regel kennt, ist es zu einem Modul ohne Eigentümer geworden, von dem nun jedes andere Modul abhängt. Die Faustregel lautet: In shared liegen Typen und reine Funktionen, niemals Orchestrierung oder State.
Jedes Modul besitzt seine Daten
Die Regel, die modularen Monolithen ihre Stärke verleiht, ist das Data Ownership. Jedes Modul ist der einzige Schreibzugriff auf seine Tabellen oder sein Schema, und kein anderes Modul liest diese Tabellen direkt aus. Der Zugriff über Modulgrenzen hinweg erfolgt über die Schnittstelle des besitzenden Moduls oder über ein Event.
In einer einzigen Datenbank gibt es drei praktische Möglichkeiten, Ownership auszudrücken:
- Separate Schemas.
billing.invoices,catalog.products,shipping.shipments. Das deutlichste Signal, das sich zudem sauber auf eine zukünftige Aufteilung der Datenbank übertragen lässt. - Tabellen-Präfixe.
billing_invoices,catalog_products. Einfacher, aber mit derselben Absicht. - Von Anfang an separate Datenbanken. Die stärkste Grenze, allerdings verliert man dadurch den Vorteil von Single-Transactions über Module hinweg, und der betriebliche Aufwand steigt.
Die meisten modularen Monolithen sollten mit einer Datenbank und separaten Schemas starten. Entscheidend ist die Ownership-Regel, nicht die physische Trennung: Nur das Repository des besitzenden Moduls greift auf seine Tabellen zu. Wenn das Versandmodul die Adresse eines Kunden benötigt, fragt es das Kundenmodul und führt kein SELECT von customers aus.
Genau das ermöglicht die spätere Extraktion. Wenn ein Modul seine Daten bereits besitzt und über eine Schnittstelle kommuniziert, ist die Verschiebung in einen eigenen Service eher eine Änderung des Deployments als ein komplettes Redesign.
Grenzen durch Tooling erzwingen
Grenzen, die nur auf Konventionen basieren, weichen mit der Zeit auf. Unter Zeitdruck ist es immer schneller, das Repository eines anderen Moduls zu importieren, als eine Methode zu dessen Interface hinzuzufügen – und aus einer Abkürzung werden schnell zwanzig. Machen Sie die Grenze mechanisch.
Eine Abhängigkeitsregel ist einfach zu formulieren und leicht zu prüfen: Ein Modul darf seine eigenen Dateien und das öffentliche Interface anderer Module importieren, aber sonst nichts. Tools wie eslint-plugin-boundaries und dependency-cruiser können dies abbilden und den Build fehlschlagen lassen.
{
"forbidden": [
{
"name": "no-cross-module-internals",
"from": { "path": "^src/modules/([^/]+)/" },
"to": {
"path": "^src/modules/(?!$1)([^/]+)/(?!index\\.ts).+"
}
},
{
"name": "no-cycles",
"from": {},
"to": { "circular": true }
}
]
}
Zwei Regeln erledigen den Großteil der Arbeit: Keine Importe von Internals anderer Module und keine zirkulären Abhängigkeiten. Fügen Sie pro Modul einen CODEOWNERS-Eintrag hinzu, damit Reviews bei den Personen landen, die den Code besitzen, und behandeln Sie eine Interface-Änderung wie eine kleine API-Änderung – sie verdient einen zweiten Blick.
In-process-Kommunikation ohne Chaos
Das typische Versagensmuster eines Monolithen ist ein Dependency Graph, in dem alles auf alles verweist. Ein modularer Monolith hält diesen Graphen azyklisch und flach. Es gibt zwei Wege, wie ein Modul ein anderes nutzen kann.
Das öffentliche Interface aufrufen. Wenn der Aufrufer sofort eine Antwort benötigt, injizieren Sie den Service des anderen Moduls und rufen diesen auf. Orders ruft catalog.getProduct(sku) auf, um eine Position zu bepreisen. Der Aufruf ist synchron und findet in-process statt, ist also schnell und schlägt als Exception fehl, nicht als Timeout.
export class PlaceOrder {
constructor(
private readonly orders: OrderRepository,
private readonly catalog: CatalogService,
) {}
async execute(input: PlaceOrderInput): Promise<OrderId> {
const order = Order.create(input.customerId, input.lines);
for (const line of order.lines) {
const product = await this.catalog.getProduct(line.sku);
if (!product.isAvailable()) throw new OutOfStock(line.sku);
order.priceLine(line, product.priceCents);
}
await this.orders.save(order);
return order.id;
}
}
Ein Event veröffentlichen. Wenn der Aufrufer keine Antwort benötigt, gibt er eine Tatsache bekannt, auf die andere Module reagieren. Orders veröffentlicht order.placed; billing, analytics und notifications abonnieren dieses Event. Das Order-Modul weiß nicht von ihrer Existenz, was genau die Kopplung aufhebt.
Events durchbrechen zudem Zyklen. Wenn Orders einen Seiteneffekt von shipping benötigt und shipping bereits von orders abhängt, würde ein direkter Aufruf eine Schleife erzeugen. Ein Event ermöglicht es shipping, zu reagieren, ohne dass orders von ihm abhängen muss. Halten Sie die Anzahl der direkten cross-module Aufrufe gering; wenn sich zwei Module ständig gegenseitig aufrufen, sind sie wahrscheinlich eigentlich ein einziges Modul oder ihre Grenze ist an der falschen Stelle gesetzt.
Eine Datenbank, separate Schemas
Eine einzige Datenbank ist ein Feature, kein Kompromiss. Sie bietet Ihnen Transaktionen, Foreign Keys, einen einzigen Connection Pool und eine einheitliche Migrationsstrategie. Was Sie aufgeben, ist die physische Isolation, die Sie stattdessen durch die Ownership-Regel ersetzen.
Bevorzugen Sie innerhalb einer Datenbank ein Schema pro Modul. Das hält die Tabellennamen sauber, macht die Ownership in jeder Query sichtbar und gibt jedem Modul einen eigenen Namespace für Migrationen. Wenn ein Modul später extrahiert wird, zieht sein Schema einfach mit um.
Vermeiden Sie Foreign Keys über Modulgrenzen hinweg. Ein Foreign Key von billing.invoices zu catalog.products koppelt die beiden Module auf Datenbankebene hart aneinander: Der Catalog kann nichts löschen oder umstrukturieren, ohne das Billing zu berücksichtigen, und eine Extraktion erfordert das Löschen des Constraints. Speichern Sie stattdessen die ID und validieren Sie diese über das Interface. Der PostgreSQL-Guide behandelt Schemas und Constraints im Detail.
Migrationen erfordern die gleiche Disziplin. Jedes Modul besitzt seine eigenen Migrationsdateien, und der Anwendungsstart oder ein Migrationsschritt wendet diese in der richtigen Reihenfolge an. Da es nur ein Deployment gibt, können Sie Migrationen und Releases gemeinsam durchführen – ein Luxus, den Microservices nicht haben.
Transaktionen und Konsistenz innerhalb eines Moduls
Die „Superkraft“ der Single-Transaction gilt nur, wenn ein Use Case innerhalb eines einzigen Moduls bleibt. Machen Sie dies zum Standardfall. Ein Use Case, der ein Aggregate lädt, eine Invariante validiert und es wieder zurückschreibt, sollte eine einzige Transaktion sein, die entweder vollständig committet oder sauber zurückgerollt wird.
await db.transaction(async (tx) => {
const order = await orders.getForUpdate(orderId, tx);
order.confirm();
await orders.save(order, tx);
await outbox.add(
{ type: "order.confirmed", orderId: order.id },
tx,
);
});
Zwei Patterns helfen dabei, die konsistente Datenhaltung über Modulgrenzen hinweg zu gewährleisten.
The Outbox. Schreiben Sie das Domain-Event in eine outbox-Tabelle innerhalb derselben Transaktion wie die Zustandsänderung; anschließend veröffentlicht ein Dispatcher dieses Event. Dies garantiert, dass das Event nicht verloren geht, falls der Prozess zwischen dem Commit und dem Publish abstürzt. Es ist dasselbe Pattern, das Sie auch nach einer Extraktion verwenden würden.
Ein Process Manager. Wenn ein Flow über mehrere Module hinweg verläuft, kann ein kleiner Koordinator auf Events hören und den nächsten Command auslösen, wobei Retries und Timeouts explizit gehandhabt werden. Dies ist das In-Process-Äquivalent zu einer Saga und weitaus einfacher als eine distribuierte Saga, da der Koordinationszustand in einer normalen Tabelle gespeichert wird.
Greifen Sie nicht zu distribuierten Transaktionen. Wenn ein Use Case tatsächlich mehrere Module umfasst und atomar sein muss, ist das meist ein Signal dafür, dass diese Module in Wahrheit ein einziges Modul bilden.
Der Weg zur Extraktion
Der Grund, in Boundaries zu investieren, ist die Optionalität. Ein modularer Monolith, dessen Module über Interfaces und Events kommunizieren, kann später Modul für Modul mithilfe des Strangler Fig-Ansatzes zerlegt werden.
- Wählen Sie das Modul mit der klarsten Boundary und dem größten Druck aus. Unabhängige Skalierung, ein separater Team-Rhythmus oder Compliance-Anforderungen sind gute Gründe dafür.
- Stellen Sie sicher, dass es seine eigenen Daten besitzt. Wenn andere Module immer noch direkt auf dessen Tabellen zugreifen, beheben Sie dies zuerst, indem Sie diese Zugriffe über das Interface routen.
- Geben Sie ihm eine eigene Datenbank und Pipeline. Verschieben Sie das Schema, richten Sie das Repository auf den neuen Store aus und halten Sie das Interface stabil.
- Ersetzen Sie In-Process-Aufrufe durch Netzwerkaufrufe oder Events. Da die Aufrufer bereits von einem Interface abhängen, handelt es sich hierbei um eine Änderung des Adapters und nicht um einen kompletten Rewrite.
- Tauschen Sie den Event-Dispatcher gegen einen Broker aus. Dank des Outbox-Patterns ändert sich der Event-Fluss kaum, wenn der Transport zu Kafka oder RabbitMQ wechselt.
Da die Nahtstelle bereits existiert, ist jeder Schritt begrenzt. Dies ist das stärkste Argument für den modularen Monolithen: Er ist kein anderes Ziel als Microservices, sondern die Option, diese bewusst zu erreichen – und zwar nur für die Teile, die es wirklich verdienen.
Module in Isolation testen
Modulgrenzen zahlen sich bei den Tests aus. Da ein Modul eine öffentliche Schnittstelle bereitstellt und seine eigenen Daten verwaltet, können Sie es eigenständig testen, ohne die gesamte Anwendung starten zu müssen.
- Domain-Tests sind rein und schnell. Instanziieren Sie das Aggregate, wenden Sie die Regeln an und prüfen Sie das Ergebnis. Keine Datenbank, kein HTTP.
- Module Interface Tests steuern den öffentlichen Service des Moduls gegen eine Testdatenbank, die auf dessen Schema beschränkt ist. Sie verifizieren den Contract, von dem andere Module abhängen.
- Event Contract Tests stellen sicher, dass das Modul die Events veröffentlicht, die die Consumer erwarten, und zwar mit den Feldern, auf die sie sich verlassen.
- End-to-End-Tests prüfen die HTTP-Schicht für eine kleine Anzahl kritischer Flows. Halten Sie diese gering, da sie langsam sind.
Die Alternative einer Schichtenarchitektur zwingt jeden aussagekräftigen Test durch alle Schichten, weshalb diese Test-Suites oft langsam und instabil werden. Das Testen an der Modulgrenze hält die meisten Tests schnell und schützt gleichzeitig die Schnittstellen, auf die es ankommt.
Modularer Monolith versus geschichteter Monolith
Es lohnt sich, hier präzise zu sein, da beide „ein Monolith“ sind, aber nur einer modular ist.
| Geschichteter Monolith | Modularer Monolith | |
|---|---|---|
| Gruppierung | Nach technischer Schicht | Nach Business Capability |
| Auswirkung von Änderungen | Erstreckt sich über jede Schicht | Bleibt innerhalb eines Moduls |
| Datenzugriff | Jede Schicht greift auf jede Tabelle zu | Modul besitzt seine eigenen Tabellen |
| Ownership | Unklar | Ein Team pro Modul |
| Testbarkeit | End-to-End dominiert | Tests auf Modulebene |
| Extraktion | Ein kompletter Rewrite | Eine abgegrenzte Änderung |
Der geschichtete Monolith ist für eine kleine Anwendung nicht falsch; er ist einfach und vertraut. Er wird jedoch zu einem Hindernis, wenn viele Personen daran arbeiten, da es keine Schnittstellen gibt, an denen man die Arbeit aufteilen kann, und keine Möglichkeit, die Auswirkungen einer Änderung lokal zu beurteilen.
Das Fehlerszenario: Ein „Big Ball of Mud“
Der modulare Monolith scheitert auf eine ganz spezifische Weise: Die Grenzen lösen sich auf. Es beginnt mit einem vertretbaren Shortcut und wird mit der Zeit zur Norm.
Warnsignale:
- Ein Modul importiert den
infra-Ordner eines anderen Moduls. - Zwei Module schreiben in dieselbe Tabelle.
- Ein
utils- odershared-Package wächst zu einer zweiten Anwendung heran. - Die Änderung des Schemas eines Moduls führt zu einem Build-Fehler in einem anderen Modul.
- Der Dependency Graph enthält einen Zyklus, der meist durch einen einzigen „nur dieses eine Mal“-Import entstanden ist.
Die Prävention erfordert dieselbe Disziplin, von der der Rest der Architektur abhängt: Lint-Regeln, die den Build fehlschlagen lassen, Code-Owner pro Modul, eine kleine öffentliche Schnittstelle und eine Review-Kultur, die einen internen Import als Bug behandelt. Grenzen sind günstig zu halten, aber teuer wiederherzustellen – setzen Sie diese daher schon ab dem ersten Modul konsequent durch.
Events innerhalb eines Prozesses
In-Process-Events entkoppeln Module ohne einen Broker. Ein kleiner Dispatcher empfängt ein veröffentlichtes Ereignis (Fact) und ruft die abonnierten Handler auf – alles innerhalb desselben Prozesses und, falls gewünscht, innerhalb derselben Transaktion.
type DomainEvent = { type: string; occurredAt: string };
type Handler = (event: DomainEvent, tx?: Transaction) => Promise<void>;
class EventBus {
private handlers = new Map<string, Handler[]>();
on(type: string, handler: Handler) {
this.handlers.set(type, [...(this.handlers.get(type) ?? []), handler]);
}
async publish(event: DomainEvent, tx?: Transaction) {
for (const handler of this.handlers.get(event.type) ?? []) {
await handler(event, tx);
}
}
}
Zwei Warnhinweise: Wenn Handler innerhalb der Transaktion des Aufrufers ausgeführt werden, verlängert ein langsamer Handler die Transaktion, und jeder Fehler führt zum Rollback des gesamten Use-Case. Wenn sie nach dem Commit ausgeführt werden, kann ein Absturz zwischen diesen beiden Schritten dazu führen, dass das Event verloren geht. Das Outbox-Pattern löst dies, indem das Event in derselben Transaktion in eine outbox-Tabelle geschrieben und von dort aus verteilt wird.
await db.transaction(async (tx) => {
await orders.save(order, tx);
await tx.insert(outbox).values({
type: "order.placed",
payload: order.toEvent(),
});
});
Da das Event zusammen mit dem Zustand committet wird, kann es nicht verloren gehen, und ein Relay kann die Verteilung so lange wiederholen, bis jeder Subscriber es verarbeitet hat. Wenn ein Modul später extrahiert wird, ist das Relay die einzige Komponente, die angepasst werden muss.
Ein Process Manager für modulübergreifende Flows
Wenn ein Use Case mehrere Module umfasst und auf Fehler reagieren muss, koordiniert ein Process Manager diesen explizit, anstatt den Flow in einer Kette von Event-Handlern zu verstecken. Er hört auf Events, verwaltet seinen eigenen State und gibt Commands aus.
class PlaceOrderProcess {
async onOrderPlaced(event: OrderPlaced) {
await this.catalog.reserve(event.orderId, event.lines);
}
async onReservationFailed(event: ReservationFailed) {
await this.orders.cancel(event.orderId, "out_of_stock");
await this.notifications.send(event.customerId, "order_cancelled");
}
async onReservationConfirmed(event: ReservationConfirmed) {
await this.payments.charge(event.orderId, event.totalCents);
}
}
Der Process Manager ist das In-Process-Äquivalent einer Saga. Er macht den Happy Path und den Compensation Path an einer zentralen Stelle sichtbar – genau das, was Choreografie normalerweise verbirgt. Speichern Sie den Prozess-State in einer normalen Tabelle, damit ein Neustart genau dort fortgesetzt werden kann, wo er unterbrochen wurde.
Versionierung der Modul-Schnittstelle
Das index.ts eines Moduls ist eine interne API und verdient die gleiche Sorgfalt wie eine öffentliche. Andere Module kompilieren gegen diese Schnittstelle, sodass eine unbedachte Änderung deren Builds zerstören kann.
- Erweitern, nicht brechen. Füge optionale Parameter und neue Methoden hinzu; vermeide es, eine bestehende Signatur zu ändern.
- Die Oberfläche klein halten. Jeder Export ist ein Versprechen. Wenn ein Typ das Modul nicht verlassen muss, exportiere ihn nicht.
- Deprecation in Etappen. Markiere die alte Methode, migriere die Aufrufer in separaten Commits und entferne sie erst dann. Da es sich um eine einzige Codebase handelt, kannst du jeden Aufrufer einfach suchen.
- Den Vertrag testen. Tests der Modul-Schnittstelle schützen das Versprechen, das du anderen Modulen gegeben hast.
Dies ist kostengünstiger als die Versionierung einer Netzwerk-API, da es keine Deployment-Reihenfolge zu beachten gibt. Es erfordert jedoch die gleiche Disziplin und verhindert, dass eine zukünftige Extraktion zu einem kompletten Rewrite wird.
Migrationen pro Modul
Da ein modularer Monolith nur eine einzige Datenbank besitzt, ist es verlockend, einen einzigen riesigen Satz an Migrationsdateien zu führen. Das würde jedoch genau die Kopplung wiederherstellen, die die Architektur eigentlich vermeiden will. Stattdessen sollte jedes Modul seine eigenen Migrationen besitzen, die auf sein Schema oder sein Tabellen-Präfix beschränkt sind.
migrations/
catalog/ 20260901_add_product_status.sql
orders/ 20260903_add_order_confirmed_at.sql
billing/ 20260905_add_invoice_paid_at.sql
Eine Migration, die Tabellen eines anderen Moduls verändert, ist im Grunde eine Verletzung der Modulgrenzen und sollte im Review abgelehnt werden. Wenn Migrationen modular bleiben, gilt die Ownership-Regel bis hinunter zum Schema. Zudem wird das Verschieben der Tabellen eines Moduls in eine eigene Datenbank so zu einer einfachen Angelegenheit: Man muss lediglich den Inhalt eines Ordners erneut ausführen.
Wann Module wieder zusammengeführt werden sollten
Nicht jede Grenze ist beim ersten Mal richtig gesetzt. Wenn zwei Module immer gemeinsam geändert werden, eine gemeinsame Transaktion teilen und sich gegenseitig in beide Richtungen aufrufen, handelt es sich eigentlich um ein einziges Modul mit einer künstlichen Nahtstelle. Sie wieder zusammenzuführen, ist eine legitime und gesunde Entscheidung.
Die Anzeichen sind konkret: Ein Pull Request betrifft routinemäßig beide Module, deren Interface ändert sich bei jedem neuen Feature und der Dependency Graph weist einen Zyklus auf, um den man ständig herummanövrieren muss. In einem Monolithen ist das Zusammenführen kostengünstig – Code verschieben, Interface auflösen, Imports aktualisieren – und es eliminiert einen Koordinationsaufwand, der sonst nur zunehmen würde. Das Ziel sind klare Grenzen, nicht eine bestimmte Anzahl davon.
Die Schnittstellen gesund halten
Grenzen bauen sich schleichend ab. Prüfen Sie diese daher regelmäßig, anstatt auf einen kompletten Rewrite zu warten. Einige automatisierte Fitness-Funktionen erkennen diese Drift, solange die Behebung noch kostengünstig ist.
- CI fehlschlagen lassen, wenn ein Modul die Internals eines anderen Moduls importiert.
- CI fehlschlagen lassen, wenn der Dependency Graph einen Zyklus enthält.
- CI fehlschlagen lassen, wenn eine Migration auf eine Tabelle verweist, die zu einem anderen Modul gehört.
- Die Anzahl der Dateien und öffentlichen Exports pro Modul als Trend auswerten; ein Modul, das kontinuierlich wächst, deutet auf eine Grenze hin, die möglicherweise an der falschen Stelle liegt.
- Ein
CODEOWNERSReview für Änderungen an der öffentlichen Schnittstelle eines Moduls vorschreiben.
Keiner dieser Punkte erfordert eine neue Infrastruktur. Es handelt sich um gewöhnliche Tests und Lint-Regeln, die gemeinsam die bloße Vereinbarung „wir halten uns an die Grenzen“ in eine vom Build erzwungene Vorgabe verwandeln.
Best Practices
- Definieren Sie Module nach Business-Capabilities, niemals nach technischen Layern.
- Geben Sie jedem Modul eine einzige öffentliche
index-Datei und halten Sie diese klein. - Sorgen Sie dafür, dass jedes Modul der einzige Schreiber für seine eigenen Tabellen oder sein Schema ist.
- Verbieten Sie cross-module Imports von Internals sowie zyklische Abhängigkeiten mittels Lint-Regeln in der CI.
- Bevorzugen Sie einen direkten Interface-Aufruf, wenn Sie eine Antwort benötigen, und ein Event, wenn dies nicht der Fall ist.
- Halten Sie den Dependency Graph azyklisch und flach; wenn sich zwei Module gegenseitig aufrufen, führen Sie diese zusammen oder definieren Sie die Grenzen neu.
- Nutzen Sie eine Datenbank mit einem Schema pro Modul und vermeiden Sie cross-module Foreign Keys.
- Halten Sie einen Use Case innerhalb eines einzigen Moduls, damit er in eine einzige Transaction passt.
- Verwenden Sie eine Outbox für die Konsistenz zwischen Modulen anstelle von verteilten Transaktionen.
- Testen Sie Module über ihr öffentliches Interface, ergänzt durch einige End-to-End-Tests an den Schnittstellen.
- Gestalten Sie Interfaces “event-ready”, sodass ein Modul extrahiert werden kann, ohne es komplett neu schreiben zu müssen.
Häufige Fehler
- Es als modularen Monolithen bezeichnen, aber Tabellen teilen und Internals importieren.
- Ordner nach Layern organisieren und glauben, dass die Module dadurch „echt“ sind.
- Lint-Regeln ignorieren, weil „jeder die Konvention kennt“.
- Ein Paket für Shared Utilities zu einem versteckten Coupling-Punkt werden lassen.
- Einen modulübergreifenden Flow als verteilte Transaktion ausführen, obwohl die Module eigentlich eins sein sollten.
- Zirkuläre Abhängigkeiten einführen und diese mit dynamischen Imports kaschieren.
- Einen Service extrahieren, bevor das Modul ein sauberes Interface hat oder seine eigenen Daten besitzt.
- Business-Logik in Controller schreiben, sodass Module nicht isoliert getestet werden können.
- Den Style nur als vorübergehende Lösung betrachten und die Boundaries niemals konsequent durchsetzen.
- Davon ausgehen, dass ein einzelnes Deployment nur eine einzige Failure Domain bedeutet, und grundlegende Resilience-Arbeiten auslassen.
Wie geht es weiter?
Falls ein konkreter Druck später eine Aufteilung rechtfertigt, erklärt der Guide zu Microservices, welche Vorteile dies bringt, welche Kosten entstehen und wie man einzelne Module schrittweise extrahiert. Um den Code innerhalb jedes Moduls zu organisieren, lesen Sie Clean Architecture, und um Module über Fakten statt über Aufrufe zu entkoppeln, lesen Sie Event-Driven Architecture. Wenn Sie die Storage-Patterns hinter dem Modul-Ownership benötigen, behandelt PostgreSQL Schemas, Transactions und Constraints.