CouplingGENERALLANGUAGE-SPECIFICSCALE-SPECIFIC

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.

The question

Three modules each call the next and the last calls the first. What has that actually cost me?

The requirement

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.

The obvious build

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.

Why it breaks

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).

How it breaks as requirements change
  • 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 Notifications needs Orders, which needs Billing, which needs Notifications. 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. Notifications reads 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).
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

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.

Constraints
  • 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.
Invariants
  • 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.

Responsibilities
  • Orders owns the order lifecycle and is the only module that decides an order exists.
  • Billing owns charging and knows about orders only as an identifier and an amount.
  • Notifications owns 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).
Boundaries
  • The seam that breaks this cycle is a value: a Receipt describing what to send. Passing data instead of a reference removes the reason Notifications needs Orders at 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.
Three reasonable calls, one cycle, and what each thing costs
charge(orderId, amount)sendReceipt(orderId)getLineItems(orderId) — the closing edgeOrders — owns the order lifecycleBilling — charges the customerExtraction blocked until this is brokenNotifications — sends the receiptNo module can be constructed aloneLoad order now decides who sees a null reference
UserLLMAgentToolDataDecisionHumanGuardrail

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.

Breaking a cycle, cheapest move first
  1. 1
    Pass data instead of a reference

    Billing builds a Receipt value and hands it to Notifications, which no longer needs Orders for 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).

  2. 2
    Invert 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.

  3. 3
    Extract 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).

  4. 4
    Publish 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).

  5. 5
    Add 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).

The closing edge, and its absence
Notifications asks Orders
// 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.
Billing hands Notifications a receipt
// 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. Billing already knows the order and the amount; have it construct the Receipt and hand it to Notifications, which then needs nothing from Orders (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 common bucket (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.

Cost of the next change
  • 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 Notifications cannot affect Orders, and the compiler proves it. A change to Orders affects downstream modules and the compiler enumerates them.
  • Breaking the cycle costs: designing the Receipt value, 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 the Receipt type. That is three edits where the cycle needed two, and it is the honest price of the acyclic shape.
What the recommended approach costs
  • 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

Failure modes
  • The cycle is broken with an event bus and reappears semantically: Notifications now subscribes to an order event and still cannot function without Orders having published it in a particular order. The build is green and the coupling is intact (Kinds of Coupling).
  • A shared common module 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).
Dependencies, and their direction
  • Before: Orders to Billing to Notifications to Orders. Every module both depends on and is depended on by every other, transitively.
  • After: Orders to Billing to Notifications, and Notifications depends on a Receipt value 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.
Misreads
  • "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).
Smells this explains
  • god-object
  • shotgun-surgery

Testing it, and how it ages

What to test, and at which boundary
  • 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 Notifications test that constructs only Notifications and a Receipt is 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).
How this design ages
  • 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.

Architecturemodular-monolith
Domains that do not exist yet
  • 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.