Dependency Cycles
A cycle turns three modules into one. You cannot reason about, test, initialize, extract or delete any of them without the others.
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.
Three modules each call the next and the last calls the first. What has that actually cost me?
Orders must send a receipt when payment succeeds. Orders calls Billing to charge; Billing calls Notifications to send the receipt; Notifications calls Orders to look up the line items for the email body. Each call was obvious when it was written.
It is fine. Each call is justified on its own, the language compiles it, and the tests pass. Cycles are a build-system concern, and we have one build.
Reasoning stops working. To answer "what happens when an order is voided" you must hold all three modules in your head at once, because the answer loops. The three modules are one module with three file names (Local Reasoning).
- Reasoning stops working. To answer "what happens when an order is voided" you must hold all three modules in your head at once, because the answer loops. The three modules are one module with three file names (Local Reasoning).
- Testing in isolation becomes impossible: a test for
NotificationsneedsOrders, which needsBilling, which needsNotifications. Everyone reaches for mocks, and the mocks encode the cycle too (Mocking). - Initialization becomes order-dependent. Whichever module loads first gets a partially constructed reference to another, which in most languages is a null field or a half-initialized singleton discovered at runtime (Initialization Races).
- Extraction becomes impossible without breaking the cycle first, so the service split that motivated the work has a prerequisite nobody scheduled (The Strangler Pattern).
- Change amplification: a change to any of the three can affect the other two, and the compiler cannot tell you which, because every module is downstream of every other (Change Amplification).
- And the open incident is related.
Notificationsreads the order at send time rather than being told what to send, so a void between charge and send produces a receipt for an order that no longer has those lines (Shared-State Coupling).
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.
- All three modules are in one deployable, so the language will not stop the cycle — most will happily compile it (Separate Compilation).
- One of the three is about to be extracted into its own service, which is what surfaced the problem (Where the Boundary Goes).
- There is a production incident open about receipts being sent for orders that were later voided, and nobody is sure whether it is related.
- A receipt is sent only for a payment that succeeded against an order that still exists.
- Any module must be loadable, testable and reasonable-about on its own, or the module boundary is decorative.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
Ordersowns the order lifecycle and is the only module that decides an order exists.Billingowns charging and knows about orders only as an identifier and an amount.Notificationsowns delivery and owns nothing about what an order is — it should be *handed* a receipt, not go and assemble one.- Whoever adds the edge that closes a cycle owns breaking it, and that is a review responsibility rather than a build-system one (A Review Checklist Worth Reading).
- The seam that breaks this cycle is a value: a
Receiptdescribing what to send. Passing data instead of a reference removes the reasonNotificationsneedsOrdersat all (Value Objects). - Where a callback genuinely is needed, the boundary is an interface owned by the *caller's* side, implemented by the other — the dependency edge reverses without the call reversing (Dependency Inversion).
- Layer boundaries only help if something enforces them. A cycle between packages is a fact about the code, not about the diagram on the wall (Architecture Boundaries).
A→B→C→A, and each edge was reasonable
This is worth looking at concretely, because a cycle never looks like a cycle from inside any one file. Each of the three calls below is the obvious thing to write, and none of the three authors could see the loop.
The closing edge is the small one. Notifications needs the line items to render the email, Orders has them, so it asks. One line, entirely reasonable, and it turns three modules into one.
- Reasoning: answering "what happens when an order is voided" requires all three modules at once, because the call graph loops back on itself (Local Reasoning).
- Testing: constructing any one module requires constructing the other two, so every test uses mocks and the mocks encode the cycle (Testing as Design Feedback).
- Initialization: whichever module is constructed first holds a reference to a not-yet-finished one — a null field, a half-built singleton, or a lazy proxy that fails on first use (Initialization Races).
- Modularity: none of the three can be extracted, versioned or deployed separately until an edge is removed (Where the Boundary Goes).
- Deletion: none of the three can be deleted or replaced independently, which is the property most people mean by "modular" (Extract Module).
- Correctness, here specifically: the closing edge reads the order at send time, so a void between charging and sending produces a receipt for lines that no longer exist.
Four ways out, in the order to try them
Most cycles are broken by the first move, and teams reach for the third. Passing data rather than a reference is usually both the smallest change and the one that fixes an actual bug, because it removes the late lookup that made the state stale.
Work down the list rather than picking a favourite. Each move costs more than the one before it, and the last two both have well-known ways of making things worse.
- 1Pass data instead of a reference
Billingbuilds aReceiptvalue and hands it toNotifications, which no longer needsOrdersfor anything. Fixes the stale-read bug at the same time.fails by The value grows into a copy of the order and drifts from it, so two shapes of the same thing now exist (Duplicate Knowledge).
- 2Invert the direction
Define the interface in the module the dependency should point away from, and let the other implement it. The call still happens; the compile-time edge reverses (Dependency Inversion).
fails by The interface is placed in the wrong module and the cycle moves one hop rather than disappearing — check with the tool, not by eye.
- 3Extract the shared concept
If both genuinely need the same knowledge, give it its own module that both depend on.
fails by The extracted module is a bucket rather than a concept, and becomes
common— the highest-fan-in module in the codebase within a year (The Common Module). - 4Publish an event
The producer announces that something happened and does not care who listens; the compile-time edge is genuinely gone.
fails by The consumer cannot function unless the producer ran, so the dependency survives with no compiler, no signature and no way to find it (Observer).
- 5Add the build rule
Fail the build on any new cycle. Unambiguous, unlike a dependency count, and it is what stops this recurring (What to Automate Out of Review).
fails by Applied to a legacy codebase with hundreds of existing cycles, it acquires an exemption list that becomes permanent (Deliberate Debt).
The first move is right here because the closing edge existed to fetch data. When the closing edge exists to *trigger* something instead, the second or fourth move is the honest one and the first will not apply.
What the fix looks like
The change is small enough to read in one screen, which is worth noticing: cycles have a reputation for being architectural problems and are usually a fifteen-line problem that has been left for two years.
The important part is not that an import disappeared. It is that Notifications no longer performs a lookup at send time, so the receipt describes the order as it was when it was charged — which is what a receipt is (Value Objects).
// notifications.ts
import { orders } from './orders' // the closing edge
export async function sendReceipt(orderId: OrderId) {
const lines = await orders.getLineItems(orderId) // read at send time
await mail.send(renderReceipt(orderId, lines))
}
// A void between charge and send changes what the receipt says.
// A test for sendReceipt needs Orders, which needs Billing,
// which needs Notifications.// receipt.ts — a value, no behaviour, no dependencies
export interface Receipt {
orderId: OrderId
chargedAt: Instant
lines: readonly ReceiptLine[]
total: Money
}
// notifications.ts
import type { Receipt } from './receipt'
export async function sendReceipt(r: Receipt) {
await mail.send(renderReceipt(r))
}
// billing.ts already has everything the receipt needs
await notifications.sendReceipt(toReceipt(order, charge))Three things change at once and they are the same change. The import disappears, so the graph is acyclic and Notifications can be constructed, tested, extracted and deleted on its own. The lookup disappears, so a void between charge and send can no longer alter what the receipt says — the value was captured at the moment it was true. And the test for sendReceipt now needs one struct instead of two modules, which is the clearest signal available that the boundary is finally in the right place (Testing as Design Feedback).
How to build it
Most important first.
- Break it by passing data.
Billingalready knows the order and the amount; have it construct theReceiptand hand it toNotifications, which then needs nothing fromOrders(Dependency Direction). - If a genuine callback is required, invert it: define the interface where the dependency should point *from*, and let the other module implement it (Dependency Inversion).
- If two modules genuinely share knowledge, extract the shared part into a third that both depend on — but only if it is a coherent thing, not a
commonbucket (The Common Module). - If the coupling is a notification rather than a request, an event breaks the compile-time cycle honestly — provided the publisher truly does not need a result (Observer).
- Whatever you choose, add a build rule that fails on new cycles. It is the one dependency check with an unambiguous reading, unlike a count (What to Automate Out of Review).
- Break the cycle before the extraction, as its own change with its own tests. Doing both at once means an incident cannot be attributed (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.
- With the cycle: any change to any of the three requires reasoning about all three, and the test for any one requires constructing the other two. Extracting one into a service is blocked entirely — the prerequisite is the work in this lesson.
- Without it: a change to
Notificationscannot affectOrders, and the compiler proves it. A change toOrdersaffects downstream modules and the compiler enumerates them. - Breaking the cycle costs: designing the
Receiptvalue, changing two call sites, and a migration window where both paths exist. Days, not weeks — cycles are usually far cheaper to break than their reputation suggests, provided it is done before the extraction. - What did not get cheaper: a change to what a receipt contains now touches
Billing(which builds it),Notifications(which renders it) and theReceipttype. That is three edits where the cycle needed two, and it is the honest price of the acyclic shape.
- Breaking cycles by passing data means constructing values that did not exist before, which is more allocation and more types for the same behaviour (Allocation and Copies).
- Inversion adds an interface with one implementation, which is indirection that a reader pays for and that only repays itself if the direction genuinely matters (How SOLID Gets Misused).
- A strict no-cycles rule occasionally forbids a genuinely convenient shape — mutually recursive domain types, a parser and its AST — and the exemption mechanism for those is where the rule starts to erode (Enforcing Invariants).
What can go wrong
- The cycle is broken with an event bus and reappears semantically:
Notificationsnow subscribes to an order event and still cannot function withoutOrdershaving published it in a particular order. The build is green and the coupling is intact (Kinds of Coupling). - A shared
commonmodule is extracted to hold everything two modules need, and within a year it is the highest-fan-in module in the codebase and depends on all three (The Utility Dumping Ground). - The interface is inverted but placed in the wrong module, so the cycle moves one hop and still exists — visible only if someone runs the check again (Dependency Inversion).
- The mitigation fails on its own terms: a strict no-cycles build rule on a legacy codebase with hundreds of existing cycles gets an exemption list, and the exemption list becomes permanent (What Technical Debt Actually Is).
- Before:
OrderstoBillingtoNotificationstoOrders. Every module both depends on and is depended on by every other, transitively. - After:
OrderstoBillingtoNotifications, andNotificationsdepends on aReceiptvalue that belongs to nobody in particular because it has no behaviour. - The acyclic version has a topological order, which is what makes initialization, testing and extraction all become straightforward at once — they are the same property viewed three ways.
- "Cycles are a build-system problem." They are a reasoning problem first. A single-binary build compiles a cycle happily, and the cost shows up in testing, initialization order and any attempt to extract a module (Local Reasoning).
- "Events remove the cycle." They remove the compile-time edge. If the subscriber cannot work unless the publisher ran, the dependency is intact and is now invisible to every tool (Observer).
- "Extract a shared module." Sometimes right, often the beginning of
common. It is only correct when the extracted thing is a coherent concept that both modules genuinely need, not a bucket of what they happen to share (The Common Module). - "Mutual recursion is a cycle, so it is bad." Mutually recursive functions inside one module are fine — they are one unit that changes together. The problem is a cycle between things you want to reason about, deploy or test separately (Module Granularity).
- god-object
- shotgun-surgery
Testing it, and how it ages
- A build-time cycle check is the test. It is cheap, unambiguous and it fails on the commit that introduces the edge rather than during an extraction two years later (What to Automate Out of Review).
- After breaking it, assert isolation directly: a
Notificationstest that constructs onlyNotificationsand aReceiptis the proof, and it will not compile if the cycle returns. - Test the void-during-send case explicitly — it is the incident, and passing data instead of a reference is what fixes it (Characterization Tests).
- Do not settle for mocks that make the cyclic version testable. A mock that hides a cycle is a test that certifies the design defect (Test Doubles, Precisely).
- Cycles form one reasonable edge at a time, and the closing edge is always the smallest one — a lookup, a helper, a convenience. Nobody adds a cycle; someone adds a call.
- They are cheapest to break immediately and get more expensive roughly with the number of call sites, which grows fast because a cyclic module is easy to call from anywhere.
- The moment they become blocking is always the same: an extraction, a build-time split, or a language migration. Teams therefore discover their cycles at the least convenient moment available (Refactor or Rewrite).
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 collapses several modules into one reasoning unit is a property of the graph, not of any language; what varies is whether the toolchain forbids it, which changes when you find out rather than what it costs.
- LANGUAGE-SPECIFICGo rejects import cycles at compile time, so the design pressure is applied immediately and Go codebases have few of them. Java, C#, Python and TypeScript all permit cycles within a compilation unit, so the cost surfaces at initialization or extraction time instead; C and C++ push it further still, into link order and static-initialization order, where the failure is genuinely undefined behaviour (Symbol Resolution Order).
- SCALE-SPECIFICIn a small codebase with one build target and one team, a cycle between three modules costs a little clarity and almost nothing else. The cost scales with the number of independently buildable, testable or deployable units you want — which is why cycles are discovered during the first attempt to split a monolith rather than during the years of writing it.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Programming Languages & Runtime Internals — whether a cycle is a compile error, a link-order hazard or a runtime null depends entirely on the module system, and static-initialization order is where the C++ version of this becomes genuinely undefined behaviour.
- — System Design — a cycle between services is the same defect with a deployment cost attached: neither can be released without the other, which removes most of the reason they were separated.