skip to content

In a Spring Modulith-based application, how should the `billing` module get data it needs from the `orders` module, and why is calling `orders`'s JPA repository directly still a boundary violation even though it would compile fine?

level: middleimportance: must knowfreq 68%

answer

  1. public interface/API + DTOs, not repository
  2. async: publish event, module listener reacts
  3. verify() enforces it, not the compiler
  4. sync call = tight coupling, event = decoupled in time
  5. event registry persists/redelivers on failure

basics

~20 s

Billing should call a small public interface (or listen to an event) that orders exposes on purpose - not reach into orders' database code - because reaching into internals means any change inside orders can silently break billing.

solid answer

~40 s

Cross-module access should go through one of two channels: a synchronous call to a module's public API - an interface plus DTOs the owning module explicitly exports - or an asynchronous domain event the owning module publishes and the consuming module listens for (e.g., orders publishes OrderPlaced, billing reacts to it). Calling orders's repository or entity classes directly compiles fine because Java/Kotlin visibility doesn't know about 'modules', but it violates the architectural contract a module verification tool (Spring Modulith's verify(), or an equivalent ArchUnit rule) is designed to catch. The API/DTO layer exists precisely so orders can change its persistence model, entity shape, or internal service structure without breaking every consumer; bypass it, and every internal refactor inside orders becomes a cross-module breaking change, exactly the coupling problem a modular monolith exists to prevent.

go deeper

for a junior

Should understand that modules should ask each other for things through a defined 'front door' rather than reaching into each other's data access code, even in simple terms.

for a middle

Should name both channels concretely - a public API interface/DTOs for synchronous calls, and domain events for asynchronous ones - and explain that the compiler doesn't enforce this, a separate verification step does.

for a senior

Should reason about when to pick synchronous versus event-based communication based on the nature of the interaction, and the coupling/latency/consistency trade-offs each implies.

for a principal

Should discuss second-order risks - DTOs that leak entity structure, exemption creep in verification rules, idempotency requirements under event redelivery - and how to keep the boundary meaningful as the system and team scale.

## The two channels Two channels exist for one module to reach another in a Spring Modulith application, and picking between them is a real design decision, not a formality. 1. **The first is a synchronous, in-process call** through the target module's declared public API: a Java/Kotlin interface (sometimes marked as a named interface so the framework knows it's an intentional export) plus a small set of DTOs that carry data across the boundary. `billing` calls `OrdersApi.findOrderSummary(orderId)`, gets back an `OrderSummary` record it doesn't own, and never sees an `Order` JPA entity or an `OrderRepository`. 2. **The second channel is asynchronous**: `orders` publishes a domain event — `OrderPlaced`, say — onto Spring's `ApplicationEventPublisher`, and `billing` supplies a listener that reacts to it. Spring Modulith adds an event publication registry on top of this so that if `billing`'s listener throws or the app crashes mid-processing, the event isn't silently lost — it's persisted and can be re-delivered, giving in-process events a reliability property normally associated with message brokers, without needing one. ## Why the repository shortcut is a violation The reason the repository shortcut is a violation even though `OrderRepository` is a perfectly normal, compilable interface is that Java/Kotlin's visibility system has no concept of 'module' — only 'package' and public/internal/private. A modular monolith layers a stricter rule on top: - Only classes in a module's designated API sub-package (or classes explicitly marked as a named interface) may be referenced from outside that module. - Everything else — repositories, entities, internal mapper/service classes — is treated as private even if the language keyword says public. A verification test, run as part of the build, is what actually catches the violation; without running that test, the shortcut is invisible until it causes damage. ## What the API/DTO boundary buys The API/DTO boundary exists to buy `orders` the freedom to change its internals without that change becoming, by definition, a breaking change for every other module. If `orders` decides to rename a column, split the `Order` entity into `Order` and `OrderLine`, switch persistence providers, or introduce a caching layer, none of that should require touching `billing`, `shipping`, or `notifications` — as long as those modules only ever depended on `OrdersApi` and `OrderSummary`. If `billing` instead imported `OrderRepository` directly, every one of those refactors inside `orders` risks breaking `billing`'s compile or runtime behavior, and the two modules are now coupled exactly as tightly as if there were no module split at all. ## Choosing between them The trade-off between the two channels matters in practice. | Channel | When it fits | What it gives up | |---|---|---| | **A synchronous API call** | Simple to reason about, gives an immediate return value or exception, and is the right choice when `billing` genuinely needs an answer right now to proceed, such as validating an order exists before creating an invoice. | But it also creates a request-time dependency: if `orders` is briefly slow or blocked in-process, `billing`'s request blocks too. | | **An event** | The right choice when the interaction is naturally 'something happened, react eventually' rather than 'give me an answer now' — it decouples the modules in time as well as in code, so `orders` doesn't need to know or care who's listening, and new listeners can be added later with zero changes to `orders`. | The cost is complexity: eventual consistency (billing's view is briefly behind orders' true state), and the need to handle redelivery/idempotency in the listener, since Spring Modulith's event registry can redeliver an event after a crash. | ## How it goes wrong in production - **Hidden coupling.** A concrete production failure mode of getting this wrong shows up as 'hidden' coupling that the verification test would have caught but didn't, because someone added a suppression or exemption to unblock a deadline, or because the repository call was routed through a shared 'util' class that was itself allowed to see both modules' internals — defeating the boundary indirectly. - **A synchronous API call that should have been an event.** Another common failure is using the synchronous API for something that should have been an event, creating a tight, request-time dependency chain across three or four modules; if any one of them is slow, the whole chain is slow, recreating a distributed-monolith-style latency problem without the modules even being separately deployed yet.

  • When would you choose an event over a direct API call between two modules?
    When the interaction is naturally 'something happened, react eventually' rather than 'I need an answer right now to proceed' - for example, orders publishing OrderPlaced so billing can create an invoice asynchronously, rather than orders blocking on a synchronous call into billing. Events also let you add new listeners later without touching the publishing module, which a synchronous API contract doesn't give you for free.
  • What happens if the billing module's event listener throws an exception halfway through processing an OrderPlaced event?
    With a persisted event publication registry, the event is recorded as not-yet-completed rather than lost, so it can be retried or replayed after the failure is fixed, instead of silently disappearing the way a plain in-memory publisher call would. The listener still needs to be idempotent, since redelivery means it may run more than once for the same event.
  • If two modules are only ever coupled through a shared `OrderSummary` DTO, is that enough to guarantee low coupling?
    It's necessary but not sufficient - the DTO shape itself can still leak internal structure, such as mirroring the entity's exact fields, which means changes inside orders still ripple outward even though the code path is 'correct'. Keeping the DTO intentionally minimal and stable, distinct from the entity, is what actually buys the decoupling.

Like calling a company's front desk or reception (the public API) instead of walking straight into an employee's back office and rifling through their filing cabinet (the repository/entities) - the front desk can change how it organizes its files internally without you ever noticing, as long as you only ever went through reception.

saying these in an interview costs you the question

  • thinks any public class is automatically fine to call from another module, because 'public' compiles
  • can't distinguish a synchronous API call from an event-based interaction, or when to use which
  • believes a build passing (no compile errors) proves the module boundary is respected
  • unaware that a verification tool has to actually run for the rule to mean anything
  • assumes in-process events are always reliable with no need to think about redelivery/idempotency

context