Architecture

Modularer Monolith

Ein modularer Monolith ist eine einzige deploybare Einheit mit strikten internen Grenzen. Man behält In-Process-Aufrufe, eine einzige Transaktion und eine Pipeline bei, während jedes Geschäftsmodul seine eigenen Daten besitzt und eine kleine öffentliche Schnittstelle bereitstellt – sodass es später in einen eigenen Service überführt werden kann, falls dies jemals notwendig wird.

intermediate14 min readUpdated 16. Sept. 2026
src/modules/billing/index.ts
ts
// src/modules/billing/index.ts
export type { Invoice, InvoiceId } from "./domain/invoice.js";
export { BillingService } from "./application/billing-service.js";
export { onOrderPlaced } from "./application/order-handlers.js";

// Everything under ./domain and ./infra stays private.
// Other modules import from this file and nothing deeper.
Deployment
Eine deploybare Einheit
Grenzen
Module nach Geschäftsfunktion
Daten
Eine Datenbank, ein Besitzer pro Tabelle
Kommunikation
In-Process-Schnittstellen und Events
Superkraft
ACID-Transaktionen
Fehlermodus
Big Ball of Mud

Warum es wichtig ist

Warum der modulare Monolith der vernünftige Standard ist

Ein Deploy, eine Pipeline

Ein einziger Build, eine Testsuite und ein einziger Rollback. Es gibt keine Orchestrierung, kein Service Discovery und kein Netzwerk zwischen den Modulen.

Starke interne Trennungen

Jedes Geschäftsmodul besitzt seine eigene Domain und seine Daten und stellt eine schmale öffentliche Schnittstelle bereit. Grenzen werden durch Tooling erzwungen, nicht durch gute Vorsätze.

Ein Weg zur Extraktion

Da Module über Schnittstellen und Events kommunizieren, kann jedes von ihnen später zu einem eigenen Service werden, ohne dass die anderen umgeschrieben werden müssen.

Das Gesamtbild

Die drei Regeln, die einen Monolithen modular halten

Module folgen Geschäftsfunktionen, jedes besitzt seine eigenen Daten und sie kommunizieren nur über eine öffentliche Schnittstelle oder ein Event.

Modul

Funktionalität

Ein Modul pro Geschäftsfunktion, das seinen eigenen Domain-, Application- und Infrastructure-Code hinter einer öffentlichen Oberfläche enthält.

Datenbesitz

Isolation

Eine Datenbank, aber jedes Modul schreibt nur in seine eigenen Tabellen oder sein eigenes Schema. Niemand führt Joins über Modulgrenzen hinweg aus.

Schnittstelle

Kontrakt

Module rufen eine veröffentlichte Schnittstelle auf oder publizieren ein Event. Interna sind privat, was die Trennung erst real macht.

HTML5 auf einen Blick

Wie gute Modulgrenzen aussehen

Module

Ordner wie billing, catalog und shipping, jeweils in sich abgeschlossen.

Öffentliche Schnittstelle

Eine einzige Index-Datei ist das Einzige, was andere Module importieren dürfen.

Eigene Tabellen

Ein Modul ist der einzige Schreiber für seine Tabellen oder sein Schema.

Domain-Events

Ein Modul kündigt Fakten an, sodass andere reagieren können, ohne einen direkten Aufruf zu tätigen.

Azyklischer Graph

Abhängigkeiten zeigen in eine Richtung und führen niemals in einer Schleife zurück.

Erzwungene Grenzen

Lint-Regeln lassen den Build fehlschlagen, wenn ein Modul in ein anderes hineingreift.

Ablauf

Ein Request durch die Module

Alles passiert in einem Prozess, daher ist der Regelfall eine einzige Transaktion und ein direkter Funktionsaufruf anstelle eines Netzwerk-Hops.

  1. 1

    Der HTTP-Layer mappt den Request

    Ein Controller validiert den Input und übersetzt ihn in einen Aufruf an das zuständige Modul. Er enthält keine eigenen Geschäftsregeln.

  2. 2

    Das zuständige Modul verarbeitet den Use Case

    Sein Application Service lädt das Aggregate, setzt die Regeln durch und entscheidet, welche Änderungen vorgenommen werden. Hier lebt die Domain.

  3. 3

    Das Modul nutzt seine eigenen Daten

    Es liest und schreibt über sein eigenes Repository und greift nur auf die Tabellen zu, die es besitzt. Tabellen anderer Module sind nicht involviert.

  4. 4

    Es ruft die Schnittstelle eines anderen Moduls auf

    Wenn es einen Fakt oder eine Aktion einer anderen Geschäftsfunktion benötigt, ruft es den öffentlichen Service dieses Moduls auf oder emittiert ein Domain-Event.

  5. 5

    Die Transaktion wird committet

    Bleibt die Arbeit innerhalb eines Moduls, ist es eine einzige ACID-Transaktion. Konsistenz über Modulgrenzen hinweg wird über Events und ein Outbox-Pattern gelöst.

  6. 6

    Die Antwort wird zurückgegeben

    Der Controller mappt das Ergebnis zurück auf HTTP. Keine Serialisierungsgrenzen, keine Timeouts, keine teilweisen Ausfälle zwischen Modulen.

Der vollständige Leitfaden

Modularer Monolith: Alles was Sie wissen müssen

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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- oder shared-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 CODEOWNERS Review 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.

In der Praxis

Modul, Aufruf, Grenze, Event

Die vier Bausteine, die einen Monolithen modular statt schichtbasiert machen.

src/modules/catalog/index.ts
// Public surface of the catalog module.
export type { Product, ProductId, Sku } from "./domain/product.js";
export { CatalogService } from "./application/catalog-service.js";
export { onStockChanged } from "./application/stock-handlers.js";

// Private by convention and by lint rule:
// ./domain/**  entities, value objects, invariants
// ./infra/**   repositories, ORM mappings, clients

Öffentliche Schnittstelle vs. Interna-Zugriff

Ein Modul, das das Repository eines anderen Moduls importiert, ist an dessen Schema und Interna gekoppelt. Ein Modul, das die Schnittstelle importiert, kann ersetzt oder extrahiert werden.

Bevorzugt
import { CatalogService } from "../../catalog/index.js";

// The order module knows only what catalog promises.
const product = await catalog.getProduct(sku);
Vermeiden
import { ProductRepository } from "../../catalog/infra/product-repository.js";

// Now orders depends on catalog's tables and ORM mappings.
// A schema change in catalog breaks orders.
const product = await productRepository.findBySku(sku);

Modularer Monolith vs. Microservices für kleine Teams

Ein kleines Team erhält die gleichen internen Grenzen bei weitaus geringeren operationalen Kosten. Extrahieren Sie einen Service erst, wenn ein konkreter Grund auftritt.

Bevorzugt
// One process, one transaction, one deploy.
await this.orders.save(order);
await this.catalog.reserve(order.lines);

// Refactor and rename across modules in one commit.
Vermeiden
// Three services and a saga for the same use case.
const order = await orders.create(input);
await inventory.reserve(order.id);   // network
await payments.charge(order.id);     // network
// Any of the above can fail after the others succeeded.

Abwägungen

Warum mit einem modularen Monolithen starten?

Dieser Stil bewahrt einen Großteil der Einfachheit eines Monolithen, während er die Option bietet, später zu distribuieren. Der Haken ist, dass Grenzen nur existieren, wenn man sie erzwingt.

Strengths

  • Transaktionen bleiben einfach

    Ein Use Case, der ein Modul berührt, ist eine einzige ACID-Transaktion mit echtem Rollback. Keine Sagas, keine kompensierenden Aktionen, keine Eventual Consistency, die erklärt werden muss.

  • Refactoring ist günstig

    Das Umbenennen einer Schnittstelle, das Verschieben einer Klasse oder das Aufteilen eines Moduls ist ein normaler Commit. Es gibt keinen versionierten Kontrakt oder ein Migrationsfenster, das ausgehandelt werden muss.

  • Operations bleiben schlank

    Ein Build, ein Deploy, ein Dashboard, eine On-Call-Rotation. Ein kleines Team kann dies ohne eine Platform-Gruppe betreiben.

  • Die Trennung ist bereits vorhanden

    Da Module über Schnittstellen und Events kommunizieren, ist die spätere Extraktion eines Moduls eine abgegrenzte Änderung statt eines archäologischen Projekts.

Trade-offs

  • Disziplin ist alles

    Nichts in der Runtime verhindert, dass ein Modul das Repository eines anderen importiert. Ohne Lint-Regeln und Reviews erodieren die Grenzen innerhalb von Wochen.

  • Ein Deploy bedeutet einen gemeinsamen Blast Radius

    Ein Memory Leak oder ein Absturz in einem Modul kann die gesamte Anwendung mitreißen. Module fallen nicht unabhängig aus, so wie es bei Services der Fall ist.

  • Skalierung ist alles oder nichts

    Wenn ein Modul weitaus mehr CPU benötigt als der Rest, skalieren Sie die gesamte Anwendung, bis Sie dieses Modul extrahieren.

  • Shared Memory ist eine Versuchung

    In-Process-Globals und gemeinsame Caches machen es einfach, Module unsichtbar zu koppeln. Behandeln Sie Shared State als Grenzverletzung.

Häufig gestellte Fragen

Häufig gestellte Fragen

Keep learning

Related topics from the roadmap.

$ Lernen Sie jetzt

Bereit, Modular Monolith zu lernen?

Unser interaktives Tutorial führt Sie Schritt für Schritt durch Modular Monolith — mit Quizzen und echtem Code, den Sie im Browser ausführen können.