Kommunikation zwischen Modulen
Wie Module reden, ohne zum Knäuel zu werden: eine schmale öffentliche API für synchrone Aufrufe, Domain Events zur Entkopplung - und warum eine gemeinsame Datenbank der Grenzen-Killer ist.
Deutsche Übersetzung in Arbeit
Diese Lektion ist noch nicht ins Deutsche übersetzt und wird daher auf Englisch angezeigt. Der Rest der Seite ist vollständig lokalisiert.
Auf dieser Seite
Modules with sealed boundaries still need to work together - orders must trigger billing, billing must
know a shipment shipped. How they talk determines whether your modular monolith stays modular or slowly fuses
back into a tangle. There are two healthy channels - a narrow public API for synchronous calls and domain
events for decoupling - and one boundary-killer to avoid at all costs: the shared database.
Synchronous: call the public API
When one module needs an answer right now from another, it calls that module's public API - never its internals (the previous lesson's rule):
// In the orders module - depends only on billing's PUBLIC api
class PlaceOrderService {
private final BillingApi billing; // the front door, an interface
void placeOrder(Order order) {
// ... orders' own logic ...
Invoice invoice = billing.createInvoice(order.id(), order.total()); // ask billing
}
}orders depends on the BillingApi interface and its DTOs, nothing more. This is fine for genuine
request/response needs, but notice it creates a compile-time dependency from orders to billing. Keep
these dependencies acyclic and few - a web of synchronous cross-module calls recreates the coupling you
modularized to escape. A tell that you're overusing sync calls: circular dependencies (orders → billing →
orders), which are a design smell begging for an event instead.
Asynchronous: publish a domain event
When a module needs to announce that something happened - and doesn't need an answer - it publishes a domain event (the DDD/Observer pattern, now between modules). The publisher doesn't know or care who listens:
// billing publishes a fact - it has no compile-time dependency on who reacts
class InvoiceService {
private final ApplicationEventPublisher events;
void finalizeInvoice(Invoice invoice) {
// ... billing's logic ...
events.publish(new InvoicePaid(invoice.id(), invoice.orderId())); // announce
}
}
// A DIFFERENT module reacts, with NO dependency arrow from billing to it
@Component
class OrderFulfillment {
@ApplicationModuleListener // Spring Modulith's module event listener
void on(InvoicePaid event) { markOrderReadyToShip(event.orderId()); }
}This inverts and removes the dependency: billing doesn't import orders; orders listens for billing's
event. Events are the primary tool for keeping modules decoupled - use them whenever a reaction is a side effect
in another module rather than a value the caller needs back. They also make each module's boundary a clean list of
"events I publish" and "events I consume."
The boundary-killer: a shared database
Here's the rule that most often gets broken: modules must not reach into each other's data. If the reporting
module runs SELECT * FROM invoices against the billing module's tables, you've created the tightest possible
coupling behind everyone's back - billing can't change its schema without silently breaking reporting, and
no API or event boundary can save you. The database becomes the shared global state that unravels modularity.
Instead, each module owns its data, and others access it only through the module's API or events:
- Give each module its own tables (and ideally its own schema), and forbid cross-module SQL - enforceable with the same ArchUnit-style checks.
- Need billing data in a report? Call
BillingApi, or havereportingbuild its own read model frombilling's events - don't query billing's tables. - This data ownership is precisely what makes a module extractable into a service later: a service must own its data, so a module that already does is halfway there.
The database is where modularity goes to die
You can have immaculate package boundaries, a pristine public API, and beautiful events - and destroy all of it with one cross-module SQL join. Shared tables are an invisible dependency that no code-level boundary check catches unless you specifically forbid cross-module data access. Treat another module's database tables as private as its internal classes: off-limits. If two modules 'need' to share tables, that's a signal your boundaries are wrong, not that you should share.
Two shops in a mall can cooperate two healthy ways. One walks to the other's front counter and asks for something (a synchronous API call), or posts a notice on a shared board - 'delivery arrived' - that whoever cares reads on their own time (an event). What they must never do is sneak into each other's back stockroom and rummage through the shelves (a shared database). The moment one shop rearranges its stockroom, the intruder's plans collapse - and neither shop can operate independently anymore. Front counter and notice board keep the shops cooperating yet autonomous; the shared stockroom secretly welds them into one.
Three interactions in the modular ledger: (1) placing an order must immediately obtain an invoice total from billing to show the customer; (2) when an invoice is paid, the shipping module should begin fulfillment; (3) the reporting module needs invoice data to build monthly reports and currently runs SELECT queries against billing's invoices table. For each, pick the right mechanism and explain what's wrong with the current reporting approach.
Why must modules in a modular monolith not share database tables?
Key takeaways
- Modules communicate two healthy ways: a narrow public API for synchronous request/response, and domain events for decoupled announcements.
- Synchronous API calls create compile-time dependencies - keep them few and acyclic; circular cross-module calls are a smell that wants an event instead.
- Domain events invert and remove dependencies: the publisher announces a fact without knowing who reacts, keeping modules decoupled.
- Never share database tables across modules - cross-module SQL is an invisible tight coupling to another module's schema that unravels modularity.
- Each module owns its own data (own tables/schema), accessed only via its API or events - which is also what makes a module extractable into a service later.