-
Maximum decoupling — no compile-time dependency from consumer to producer
-
Trivially extensible: new consumers can be added without touching the producer
-
Spring Modulith Event Publication Registry provides at-least-once delivery guarantees
ADR 011: Cross-Module Communication Strategy
Context
As the backend grows beyond the initial household module, multiple modules (tasks, expenses, usersettings) will need to interact with each other.
Typical scenarios include:
-
A module needs to verify that a referenced aggregate (e.g. a household) exists before accepting a write operation.
-
A module needs to react to a lifecycle event in another module (e.g. clean up tasks when a household is deleted).
-
The
expensesmodule needs to expose financial aggregations to the statistics endpoint inhousehold.
The modular monolith architecture (ADR 004) mandates that module boundaries are enforced. Modules must not import Spring beans or repositories from other modules directly. Each module owns its own database schema; there are no foreign-key constraints across schema boundaries. A coherent strategy for cross-module communication prevents ad-hoc coupling and makes module boundaries explicit and verifiable by Spring Modulith.
Rejected Approach: Event-Carried State Transfer (local data copies per module)
A DDD-purist approach was considered: each module maintains a local copy of the foreign data it needs.
For example, the tasks module would keep its own tasks.household table containing only the fields it uses (id, name).
When a household is created or updated, an event is published and each interested module updates its local copy.
This is the correct approach in a microservices architecture with separate databases and deployments, because it eliminates runtime dependencies between services. In a modular monolith, however, the trade-offs are unfavourable:
-
Self-inflicted eventual consistency: Between the moment a household is created and the moment the event is processed by
tasks, the data is temporarily inconsistent within the same process and the same database. This complexity is inherent to distributed systems; introducing it here provides no benefit. -
Disproportionate synchronisation overhead: Every change to a shared aggregate (
HouseholdCreated,HouseholdUpdated,HouseholdDeleted,MemberAdded,MemberUpdated,MemberRemoved) must be published, consumed, and reconciled in every interested module. This is a large amount of infrastructure for data that is simple and rarely changes. -
YAGNI: The only real justification for local copies is future extraction to microservices (ADR 004). If that point is ever reached, the extraction itself is the right moment to introduce local copies — with full knowledge of actual load patterns. Building the synchronisation machinery today means buying complexity in advance.
The decision is therefore to accept the tighter coupling that comes with synchronous cross-module queries in exchange for significantly lower implementation complexity.
Considered Options
-
Option A: Domain Events only (reactive)
-
Option B: Named Interfaces only (synchronous)
-
Option C: Hybrid — Named Interfaces for synchronous queries, Domain Events for reactive lifecycle notifications
Decision Outcome
The project uses a hybrid approach (Option C):
-
Synchronous queries between modules are handled via Named Interfaces (Spring Modulith public packages). A module that needs to read data from another module calls a service defined in the target module’s public package. The depending module declares this dependency explicitly via
@ApplicationModule(allowedDependencies = "targetModule")in itspackage-info.java. This coupling is explicit, minimal (interface only, not implementation), compile-time verifiable by Spring Modulith, and accepted as appropriate for a modular monolith. -
Reactive lifecycle notifications (side effects that should not block the producing transaction) are handled via Spring Modulith Domain Events. The producing module publishes an event using
ApplicationEventPublisherinside a@Transactionalmethod. The consuming module listens with@ApplicationModuleListener, which runs asynchronously in a new transaction after the producing transaction has committed. Events are used here because the producer must not need to know which other modules are interested in the change. -
Intra-module communication (e.g. between the expense and reimbursement services within the
expensesmodule) uses direct Spring bean injection without any restrictions.
Pros and Cons of the Options
| Pros | Cons |
|---|---|
Option A: Domain Events only |
|
|
|
Option B: Named Interfaces only |
|
|
|
Option C: Hybrid (chosen) |
|
|
|
Decision Rules
The following rules remove ambiguity about which pattern to use:
| Scenario | Pattern | Example |
|---|---|---|
Query: "Does X exist / is it valid?" |
Named Interface |
|
Query: "Give me aggregate data from module X" |
Named Interface |
|
Notification: "X was deleted" |
Domain Event |
|
Notification: "Status of X changed" |
Domain Event |
|
Implementation Guidelines
Named Interface (synchronous)
The target module defines a public service interface in its root package. Spring Modulith treats the root package of a module as its public API; anything placed there is accessible from other modules.
// eu.wiegandt.librehousehold.household (root package = public)
public interface HouseholdQueryService {
boolean exists(UUID householdId);
}
The depending module declares the allowed dependency in its package-info.java:
@ApplicationModule(allowedDependencies = "household")
package eu.wiegandt.librehousehold.tasks;
Domain Event (reactive)
The producing module defines the event record in its root package and publishes it within a @Transactional method.
The event record is the only shared type; the consumer has no compile-time dependency on the producer’s implementation.
// eu.wiegandt.librehousehold.household (root package = public)
public record HouseholdDeleted(UUID householdId) {}
// Inside a @Transactional service method:
events.publishEvent(new HouseholdDeleted(householdId));
The consuming module listens without needing to declare a dependency on the producing module, because the event type is defined in the producer’s public package which the consumer may reference:
@ApplicationModuleListener
void on(HouseholdDeleted event) {
taskRepository.deleteByHouseholdId(event.householdId());
}
Database Schema Isolation
Each module owns a dedicated PostgreSQL schema (household, tasks, expenses).
Foreign-key constraints across schema boundaries are not used.
Referential integrity for cross-module references is ensured by Named Interface queries on write and by @ApplicationModuleListener cleanup on delete.
Consequences
Positive:
-
Module boundaries are enforced and verified at test time by Spring Modulith.
-
Lifecycle reactions are fully decoupled; a new module can listen to existing events without touching the producer.
-
The accepted coupling through Named Interfaces is intentional, explicit, and minimal.
-
When (if) modules are ever extracted to microservices, Named Interface calls become the clear seams to replace with HTTP/gRPC, and Domain Events become the seams to replace with a message broker — the business logic itself does not change.
Negative:
-
tasksandexpenseshave a compile-time dependency on public interfaces inhousehold. This is a conscious trade-off (see Rejected Approach above). -
Eventual consistency:
@ApplicationModuleListenerruns after the producing transaction commits. In the window between commit and listener execution, data may be transiently inconsistent across module schemas. This is acceptable for lifecycle cleanup but must not be used where immediate consistency is required. -
The Event Publication Registry requires a dedicated table (
event_publication) in the database. This table must be created via a Flyway migration to keep schema management consistent.
Feedback
Was this page helpful?
Glad to hear it! Please tell us how we can improve.
Sorry to hear that. Please tell us how we can improve.