Breaking Cycles
Move the shared concept, invert a dependency, introduce an interface — or merge two modules that were never really separate. The last one is the most under-used fix.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
I have a dependency cycle. Which of the available fixes is the right one here?
Order, Pricing and Customer form a cycle. The team has three proposals — an interface, a shared types package, and a mediator — and no way to choose between them beyond preference.
Introduce an interface. Customer defines OrderHistory, Order implements it, and the compile-time edge is gone.
The edge is gone and the coupling is not. If Customer and Order still change together every time the loyalty rule moves, the graph got prettier and the change cost did not move (Dependency Inversion, Critically).
- The edge is gone and the coupling is not. If
CustomerandOrderstill change together every time the loyalty rule moves, the graph got prettier and the change cost did not move (Dependency Inversion, Critically). - The interface has one implementation and exists only to satisfy the graph, which is exactly the shape this domain warns about elsewhere (Speculative Generality).
- It adds a hop for every reader: understanding what actually runs now requires finding the implementation, and there is no local evidence of which one it is (Local Reasoning).
- It is the most-reached-for fix because it is the one that requires no agreement about the domain. The other fixes all force a conversation about who owns what, which is the work being avoided.
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- Two of the three modules belong to different teams, so any fix that changes both is a scheduling problem (Code Ownership).
- There are no tests around the interactions being changed, so the refactor needs a safety net built first (Refactoring Without Tests).
- The fix has to be shippable in pieces; a week-long branch touching three heavily-used modules will not merge cleanly.
- Behaviour does not change. Every fix here is a refactoring, and a fix that alters behaviour has become a rewrite (What Refactoring Actually Is).
- After the fix, each module can be loaded, reasoned about and tested without the others.
- The fix removes the co-change, not merely the compile-time edge. An acyclic graph whose modules still always change together has fixed nothing (Change Amplification).
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- Whoever breaks the cycle owns identifying the concept the cycle is passing around, because every good fix follows from naming it.
- The extracted module, if there is one, owns that concept completely and owns depending on nothing else (Stable Dependencies).
- The teams involved own agreeing the new direction, since a dependency direction between two owned modules is an organisational commitment as much as a technical one.
- The right boundary is around the shared concept, and it is usually smaller than anyone expects — a value type and two functions, not a package.
- If no such concept exists and the modules genuinely share a single reason to change, there is no boundary, and merging is the correct structural answer (Over-Decomposition).
- An interface is a boundary only when the two sides really can vary independently; otherwise it is a hop (Interface Versus Implementation).
The loop, in order
The sequence matters more than the technique. Almost every failed cycle-breaking attempt skipped the first two steps and went straight to choosing a mechanism, which is how teams end up debating interfaces versus events without anyone having said what the shared concept is.
The last step is the one that decides whether it worked, and it is not about the graph. Six weeks later, do the two modules still appear in the same commits? If they do, the cycle was a symptom and you treated the symptom.
- 1Draw the graph
Get the real edges from tooling rather than from memory, including transitive ones.
fails by Arguing about a cycle nobody has actually seen, which is usually larger and differently shaped than assumed.
- 2Name what travels the loop
Write the sentence: which knowledge does each edge carry, and what is it really about?
fails by Skipping to mechanism. Every fix below follows from this sentence, and without it the choice is arbitrary.
- 3Build the safety net
Characterization tests at a boundary that will not move during the refactor.
fails by Refactoring heavily-used modules on the assumption that the existing tests cover the interaction (Refactoring Without Tests).
- 4Choose the fix from the sentence
A third concept means extract; one reason to change means merge; genuinely different rates of change mean invert.
fails by Choosing by habit — which for most teams means an interface, regardless of what the sentence said.
- 5Ship it in slices
Extract or merge, migrate callers in separate merges, then delete the old edges.
fails by One large branch across three heavily-used modules, which will not merge cleanly and will be abandoned (Incremental Migration).
- 6Enforce and re-check
Add the acyclicity check, then look at merge history six weeks later to see whether co-change actually dropped.
fails by Declaring victory at the green build. The graph is the proxy; co-change is the thing (Change Amplification).
Steps two and six are the ones that distinguish a fix from a rearrangement, and both are cheap. Neither involves writing any code.
Four fixes, and when each is right
These are not interchangeable and they do not cost the same. Two of them relocate coupling, one removes a boundary, and one adds an indirection that may or may not remove anything.
The merge option is listed third rather than last on purpose. It is the correct answer more often than it is chosen, and the reason it is rarely chosen has nothing to do with engineering.
What is the knowledge travelling around the loop, and do the two modules have one reason to change or two?
when The edges carry knowledge that belongs to neither module — a tier, an identifier, a policy, a value type. Usually the case.
cost One more module, which becomes a high fan-in dependency and must stay small and stable. If you name the concept wrongly, you have created a shared/ package with a nicer label (Extract Module).
when The two sides genuinely change at different rates and the interface will have a real second implementation, a test double included.
cost A hop on every read and wiring at a composition point. If the co-change does not drop, the fix was cosmetic (Dependency Inversion).
when They always change together, share one reason to change, and were split by folder convention rather than by a difference in the domain.
cost A larger module and one fewer boundary; splitting later costs a migration. Reduces total complexity rather than moving it, which no other option here does (Over-Decomposition).
when The upstream module genuinely must not know who reacts — an audit trail, a notification, a cross-capability reaction that may gain subscribers.
cost The dependency becomes invisible: no static path from cause to effect, harder debugging, and ordering and delivery become your problem. Wrong for a local synchronous call (Observer).
when The cycle is inside one cohesive module between two classes that are obviously one unit.
cost None, if it really is inside a boundary. The moment it crosses a boundary you rely on to contain change, it stops being free (Circular Dependencies).
The fix nobody proposes
Two modules that import each other, always change in the same commit, and are owned by the same person were not two modules. They were one module that somebody split because a style guide said a file should be small, or because the two nouns sounded different.
Merging them is not a retreat. It removes a boundary that was containing nothing, deletes the indirection that existed to cross it, and leaves one thing to understand instead of two things plus their relationship. The honest cost is that you have committed to them being one concept, and if that turns out to be wrong the split has to be paid for again.
// order/Order.ts
import { OrderValidator } from './OrderValidator'
export class Order {
place() { OrderValidator.check(this); /* ... */ }
}
// order/OrderValidator.ts
import { Order } from './Order'
export class OrderValidator {
static check(o: Order) { /* reads six private-ish fields */ }
}
// Every rule change edits both files.
// Neither can be read or tested without the other.
// The split exists because "validation is its own concern".// order/Order.ts
export class Order {
place() {
this.assertPlaceable()
/* ... */
}
private assertPlaceable() {
/* the same six checks, on its own fields */
}
}
// One file. The rules sit next to the state they constrain,
// the cycle is gone, and there is nothing to inject or wire.The two files had one reason to change — the rules for placing an order — and splitting them created a boundary that no change respected, plus a cycle to cross it. Merging removes the cycle, the indirection and one name, and it puts the invariant next to the state it protects, which is where it can actually be enforced (Where Invariants Live). The cost is a larger class and the loss of a seam: if order validation later needs to vary by market or by channel, that seam has to be re-created — and at that point it will be created against two real cases instead of a convention (The Rule of Three).
How to build it
Most important first.
- Characterize the behaviour of the interaction first, so the refactor has a safety net. This is the step that makes the rest safe and the step teams skip (Characterization Tests).
- Name the concept travelling around the loop. Write the sentence out: "pricing needs to know a customer's tier; customers need to know their lifetime spend; both are about the loyalty relationship."
- Prefer moving the concept into its own small module that depends on nothing. It removes several edges at once and leaves no indirection behind (Extract Module).
- Consider merging seriously and early. Two modules with one reason to change, that always appear in the same commits, are one module, and merging is the only fix that reduces the number of things to understand (Module Granularity).
- Invert a dependency when the two sides genuinely change at different rates and the interface will have a real second implementation — a test double counts (Dependency Inversion).
- Ship each step separately: extract, migrate callers, delete the old edges. Three small merges beat one large one, especially across teams (Incremental Migration).
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Extract-the-concept: the next change to that concept costs one small module. Changes to the two former members no longer drag each other in. This is the fix with the best change-cost profile and the highest chance of being wrong about the boundary.
- Merge: the next change to either former module costs one module, and there is one fewer boundary to maintain. The risk is that a genuinely separate concern is now inside, so future splits cost more.
- Invert with an interface: the next change costs about what it did before, unless the interface acquires a real second implementation. This fix buys a compile-time property and, on its own, little change-cost benefit.
- Do nothing: every future change to any member costs all members, and each new member or dependent raises that cost. The cycle is one of the few problems in this domain that reliably gets more expensive with time (Interest: Why Debt Compounds).
- Every fix except merging adds a module or an interface, which means more names, more files and more navigation for the same behaviour (Over-Decomposition).
- Merging reduces the number of things to understand and gives up a boundary you may want back, and getting it back later costs another migration.
- All of these are refactors of working, heavily-used code, so they carry real risk for a benefit that only shows up in later changes.
What can go wrong
- The extracted module becomes a
types/orshared/package that everything ends up importing, which is a new coupling hub with no reason to change of its own (The Common Module). - The interface is added, the cycle disappears from the graph, and the two modules go on changing together — the fix that looks best and does least (Dependency Inversion, Critically).
- The merge is done and the merged module now has two unrelated reasons to change, because the two modules really were separate and the cycle was one bad edge (Divergent Change).
- The mitigation fails on its own terms: an events mechanism is introduced to decouple the two modules, and now the dependency is invisible as well as present — nobody can find what happens when an order is placed (Observer).
- After extraction, both former cycle members depend on the new module, which now has high fan-in and must therefore stay small and stable (Stability and Dependency Direction).
- After a merge, the merged module inherits both dependency sets, which is a real increase in its fan-out and worth checking before committing to it (Fan-in and Fan-out).
- After an inversion, the runtime dependency is unchanged and wiring moves to a composition point, which has to exist somewhere (Wiring and the Composition Root).
- "An interface always breaks a cycle." It always breaks the compile-time edge. Whether it breaks the coupling depends on whether the two sides can now change independently, which is a question about the domain, not the graph (Dependency Inversion, Critically).
- "Merging is giving up." Merging is the only fix that reduces total complexity rather than relocating it, and for two modules with one reason to change it is straightforwardly correct. The reason it is rare is social — undoing someone's decomposition feels like criticism (Over-Decomposition).
- "Extract the shared types into a types package." A package named after a language feature has no reason to change of its own and collects everything, which is the
common/failure with a different label (The Common Module). - "Use events to decouple them." Events remove the static edge and replace a traceable call with an untraceable one. For a genuinely local interaction that is a worse trade, and it is worth reserving for boundaries you actually want to be asynchronous (Observer).
- shotgun-surgery
- divergent-change
Testing it, and how it ages
- Characterization tests over the interaction before the change, asserted at the outermost stable boundary (Characterization Tests).
- After the fix, assert acyclicity in CI so the edge cannot come back with the next hurried feature (What to Automate Out of Review).
- A fast unit test for the extracted concept with no infrastructure. If it needs a database, too much was extracted with it (What a Unit Is).
- Extracted concept modules tend to be right for a long time, because they are small and named after something the business talks about (Ubiquitous Language).
- Merged modules grow, and the merge should be revisited when the merged thing acquires two clearly distinct reasons to change — that is the same evidence, read the other way (Module Granularity).
- Interfaces added purely to break cycles tend to survive indefinitely without ever acquiring a second implementation, which is why the decision deserves a written reason (Decision Records).
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- GENERALThat a cycle is broken by relocating knowledge, reversing a direction, or admitting the two modules are one holds independently of language; only the mechanism for expressing the direction differs.
- PARADIGM-SPECIFICIn OO codebases the reflex is an interface plus injection; in functional ones it is passing a function or a record of operations, which is the same inversion with less ceremony and the same question about whether the coupling actually moved. In languages with strong module systems, moving a type into a leaf module is often trivial, which makes extraction the cheapest option — so the ranking of these fixes shifts with what the language makes easy.
- CONTESTEDThe strongest argument for reaching straight for an interface: it is the least invasive change, it does not require two teams to agree on domain ownership, and it can be shipped in an afternoon — whereas extracting a concept means naming it correctly, which teams frequently get wrong and then live with. Proponents note that a wrong extraction is worse than an unnecessary interface because it is harder to undo. The counter is that an interface that leaves the co-change in place has spent the refactoring budget without changing what the next requirement costs, so the team believes the problem is solved when it is not.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — the safety net makes the difference between a refactor and a rewrite, and how much confidence a characterization suite actually gives you is the question this lesson depends on and does not answer.