Two feature modules, `orders` and `inventory`, are each internally well-layered — inside each, controllers depend on services depend on domain, all pointing inward. But `orders`' domain package imports a class from `inventory`'s domain package to check stock, and separately `inventory`'s domain package imports a class from `orders`' domain package to look up order history. What problem does this create, and how would you fix it while keeping both modules internally clean?
answer
- cycle across module boundary, not inside a module
- internal layering doesn't prevent cross-module cycles
- pick one stable direction or use events
- breaks independent deployability
- module-graph verification tools catch it
basics
~20 sEven though each module looks fine inside itself, the two modules now depend on each other in both directions, so you can't understand, build, or test one without the other. Fix: pick which module is allowed to know about the other, or use an event so neither core has to import the other's internals directly.
solid answer
~40 sCorrect intra-module layering doesn't protect you from a cycle at the module level: if `orders`→`inventory` and `inventory`→`orders` both exist, the two 'modules' are really one undeclared module wearing two folders, and the acyclic, inward-pointing property that makes each module independently testable/deployable disappears at the boundary that matters most for team scaling. The fix is to apply the same DIP move one level up: decide which module is the stable, upstream one and let only the other depend on it (e.g., `orders` defines an `InventoryPort` interface it needs and `inventory` implements it, never both directions), or decouple further by having them communicate through domain events so neither module's core imports the other's domain package directly.
go deeper
Can recognize, when shown both import statements side by side, that this looks like two things depending on each other, without needing the word 'cycle.'
Can articulate why a cycle is a problem beyond 'it feels messy' — specifically, testing and understanding one module in isolation breaks down.
Proposes concrete fixes (one-directional port or event-based decoupling), and reasons about which direction should be authoritative for a given pair of modules.
Treats this as an organizational/process problem, proposes automated module-graph verification in CI so the cycle can't reappear, and weighs event-driven decoupling's eventual-consistency cost against the coupling cost.
## The rule one level up Dependency direction is usually taught at the layer level — controller → service → domain — but the same rule applies one level up, at the boundary between modules or features, and it's easy to satisfy it inside each module while violating it between modules. In the scenario, `orders` and `inventory` are each **internally clean**: - within `orders`, the controller depends on the service depends on the domain, and nothing in that chain points backward - the same is true inside `inventory` But `orders`' domain package importing an `inventory` domain class, while `inventory`'s domain package separately imports an `orders` domain class, creates a **cycle** that lives entirely outside either module's internal layering — and internal cleanliness gives you no protection against it, because the two properties are checked at different levels of the package graph. ## What the cycle costs The problem this creates is concrete and shows up well before runtime. 1. **First**, you can no longer compile, test, or reason about either module without the other — `orders` isn't really an independent unit anymore, it's one half of a two-piece module that happens to live in two folders and pretend otherwise. 2. **Second**, if either module later needs to become an independently deployable service — a common reason teams organize code into modules in the first place — a cycle between their domains makes that literally impossible without first breaking the cycle: you cannot deploy `orders` and `inventory` as separate services if each one's core logic calls directly into the other's in-process classes. 3. **Third**, cycles make change-impact analysis much harder for the team: a change to `inventory`'s domain can now, via the cycle, ripple back into `orders`' domain and from there back out again, and reasoning about 'what could this change affect' no longer terminates cleanly at a module boundary. ## Picking a direction The fix mirrors what you'd do at the layer level, just applied to modules. The first move is to ask which module should be the stable, upstream one — not always obvious, but usually one direction of the coupling represents the 'true' dependency (checking whether stock exists is naturally something `orders` needs from `inventory`, while 'look up an order's history' from within `inventory` is usually a sign that the wrong module owns that logic, or that it shouldn't be a direct call at all). Once you pick a direction, you apply the same DIP move: `orders` defines an `InventoryPort` interface expressing only what it needs (`isInStock(sku): Boolean`), and `inventory` implements it — **never both directions**. ## Or decouple through events If both modules genuinely need something from each other, a cleaner answer is often to decouple them entirely from direct calls and let them communicate through domain events: `inventory` publishes a `StockReserved` or `StockDepleted` event; `orders` reacts to it through its own application layer without either module's domain package importing the other's. This trades a synchronous, tightly coupled cycle for an asynchronous, one-directional (or fully decoupled) relationship, at the cost of eventual consistency and more moving parts that the team now has to manage. ## Catching it automatically In practice, teams enforce this the same way they enforce layer direction: **with an automated check on the module graph** rather than trusting reviewers to notice. Spring Modulith, for instance, lets you declare allowed dependencies per module and run a verification step as part of the test suite; a genuine cycle like the one described here fails that verification with a clear error naming both offending imports, the same way a broken unit test fails the build. Without such a check, the cycle can persist for a long time precisely because nothing local looks wrong — each individual pull request that added one of the two imports passed code review and looked like a small, reasonable convenience, and only the aggregate, module-graph view reveals the mutual coupling.
- How would you decide which of the two modules should be allowed to depend on the other, when it's not obvious which one is 'more stable'?Look at which concept is more foundational to the domain and changes less often — inventory (what stock exists) is usually more foundational than orders (what someone chose to buy), so orders depending on an inventory port is often the more natural direction; when it's genuinely ambiguous, that's usually a signal the two modules should communicate via events rather than a direct call in either direction.
- What's a symptom that would tell you a cross-module cycle exists even before running an explicit check?The two modules can no longer be built, tested, or deployed independently — you find yourself needing to change both modules' test setups together for a change conceptually scoped to one, or an attempt to extract either module into its own build artifact fails with unresolved references to the other.
- Does replacing the direct import with an event fully remove the coupling between the two modules?It removes the compile-time/source coupling and the synchronous runtime coupling, but a logical coupling remains — the consuming module still depends on the event's shape (its schema) and on the publishing module actually emitting it reliably; that's a much weaker, more evolvable coupling than a direct domain-to-domain import, but it's not zero coupling.
Two neighboring shops that each look tidy inside, but one has a door straight into the other's stockroom and vice versa — you can't renovate, sell, or even audit either shop without the other, no matter how organized each one's own shelves are.
saying these in an interview costs you the question
- Says internal layering being clean means the module structure overall is fine
- Doesn't recognize a cycle even when shown imports going both directions between the same two packages
- Proposes fixing it by moving the shared class into whichever module currently needs it, without considering which module should own it
- Thinks events eliminate coupling entirely rather than changing its shape