Microservices

Breaking Service Dependency Cycles

Service dependency cycles make microservices impossible to deploy, test, or reason about in isolation. How to detect them and four ways to break them.

Part of Microservice Service Design: Boundaries That Hold
Breaking service dependency cycles, shown as nodes in a closed loop with one amber link being cut

Service dependency cycles are one of the clearest signs that a microservice architecture has gone wrong. When A calls B, B calls C, and C calls back to A, you no longer have independent services, you have one distributed unit that happens to run in separate processes. The whole benefit of splitting services, deploying and reasoning about them independently, is gone, and the cure is to break the cycle deliberately rather than route around it.

A cycle is insidious because each individual dependency looked reasonable when it was added. Nobody sets out to build a loop; it accretes one sensible-seeming call at a time until the day you cannot deploy one service without the other two.

Why dependency cycles defeat the point of microservices

The reason to split a system into services is independence: each can be deployed, scaled, tested, and owned on its own. A dependency cycle removes exactly that property. If A, B, and C form a loop, none of them is independent of the others, so you have paid the full operational cost of three services and kept the coupling of a monolith.

This is the distributed monolith failure in its purest form, and it is why dependency direction is an architectural concern, not an implementation detail. This post is part of the Service design series.

Why are circular dependencies between services bad?

Circular dependencies couple services that are supposed to be independent, and the damage shows up across the whole lifecycle. You cannot deploy one service in the cycle without the others being compatible, startup ordering becomes ambiguous (each waits on another), a change in one can ripple around the ring, and a failure in any member can cascade to all of them.

The concrete symptoms:

  • Lockstep deploys. You cannot release A without coordinating B and C.
  • Ambiguous startup. Each service depends on another being up, so cold-start ordering is undefined or deadlocks.
  • Cascading failure. A slowdown in C backs up B, which backs up A, which (via the cycle) worsens C.
  • Untestable in isolation. You cannot stand up one service for a test without standing up the loop.
  • Reasoning breaks down. “What depends on what” has no answer, because everything depends on everything.

Each symptom traces back to the same root: a graph that should be acyclic has a loop in it.

How do you detect service dependency cycles?

Build a directed graph of which service calls which, then run cycle detection on that graph. You can construct the graph from static analysis of clients, from your service mesh’s observed traffic, or from a dependency manifest. The high-value move is to turn this into a CI check that fails the build when a new dependency would introduce a cycle, so the graph stays acyclic by policy instead of degrading silently.

Service mesh telemetry makes detection easier in a running system: the mesh already knows the real call graph, so you can periodically extract it and check for cycles you did not design. Pair that observed graph with the CI guard, and you catch both the cycles already present and the ones someone is about to add.

How do you break a circular dependency between services?

There are four standard fixes, and the right one depends on why the cycle exists. The most common production fix is to invert the back-edge with an event, so the service that closed the loop no longer makes a synchronous call.

FixWhen to use itTradeoff
Invert with eventsC needs to notify A, not query itAsync; eventual consistency
Extract a shared serviceA and C both need shared logic/dataOne more service to own
Merge the servicesA and C are really one bounded contextLarger service, less independence
Introduce an interfaceThe dependency can point one wayIndirection; still synchronous

Invert with events. If the back-edge exists because C needs to tell A something happened, replace the synchronous C → A call with C emitting an event that A consumes. The call graph becomes acyclic because C no longer depends on A; it just publishes. This is the workhorse fix, and it connects directly to Kafka replay and idempotent consumers.

Extract a shared service. If A and C both depend on each other because they share some logic or data, pull that shared concern into a new service D that both depend on. The cycle A ↔ C becomes A → D ← C, which is acyclic.

Merge the services. Sometimes the cycle is telling you the truth: A and C are not actually two services, they are one bounded context that was split incorrectly. Merging them removes the cycle and is the right call when they always change together.

Introduce an interface. If the dependency genuinely needs to be synchronous but only points one way conceptually, an interface or dependency inversion can make the concrete dependency point the acyclic direction. This is the least common fix at the service level and the most common within a single service’s code.

Are dependency cycles ever acceptable?

Almost never between services, and only narrowly within a single service’s code. At the service boundary a cycle reliably means lost independence, so it should be treated as a defect to remove, not a tradeoff to accept. The rare exception is a tightly-scoped, in-process cycle inside one service where the components genuinely co-evolve and ship together anyway.

The reason the bar is so high at the service level is that the cost of a cycle there is structural: it removes the ability to deploy, scale, and reason independently, which is the entire justification for having separate services. There is no clever pattern that makes a cross-service cycle safe; the patterns all aim to remove it. If you find yourself arguing that a particular service cycle is fine, that is usually a sign the two services should be one.

Within a single service, small cycles between closely-related modules are sometimes pragmatic, because everything in that service deploys together regardless, so the independence argument does not apply. Even there, an acyclic design is usually cleaner and easier to test, but it is a code-quality preference rather than the architectural hazard a cross-service cycle represents. Keep the hard line at the service boundary, where it matters most.

A dependency-cycle playbook

When you find a cycle, work through it in this order:

  • Confirm the cycle from the real call graph (mesh telemetry or static analysis), not from memory.
  • Identify the back-edge, the dependency that closes the loop.
  • Ask whether that back-edge is a notification (then use events) or a query (then extract or invert).
  • If the two services always change and deploy together, consider that they should be merged.
  • Add a CI acyclicity check so the cycle cannot reappear once removed.
  • Re-check the graph after the fix to confirm it is now acyclic.

What are the standard techniques for breaking a cycle?

Cycles are broken by one of a small number of moves, and naming them makes the choice a decision rather than an improvisation.

TechniqueWhat you doBest when
Invert with eventsThe downstream call becomes an event the other service subscribes toThe call was a notification, not a query — the most common fix
Extract the shared concernThe mutually-needed logic or data moves to a third service both callBoth services genuinely need the same thing
Move the dataWhichever service needs the data more takes ownership of itThe cycle exists only to read a couple of fields
Merge the servicesCombine them into oneThey change together anyway; the boundary was wrong
Duplicate a little dataThe caller caches what it needs, updated by eventsThe data is small, slow-changing, and tolerates staleness

Event inversion is the fix that applies most often, because most cycles are created by a synchronous call that only needed to say “this happened.” A calls B to do work, and B calls A back to report completion. If B emits an event instead and A subscribes, the runtime cycle is gone — A no longer depends on B at all, and both become simpler to test.

The merge option is under-used and deserves less stigma. Two services locked in a cycle, changing together on every feature, are one service that was split incorrectly. Merging is not a retreat; it is correcting a boundary error, and it removes a network hop, a failure mode, and a coordination cost all at once.

The technique to be most careful with is duplicating data. It works and it introduces an eventual-consistency window plus a second source of truth to keep correct. Reserve it for data that is small, changes rarely, and where staleness is genuinely tolerable — a display name, a tier, a flag. Duplicating something that changes constantly trades a structural problem for a correctness one.

Whichever you pick, the general rule holds: decide which service is upstream, and make the dependency flow one way. A cycle is fundamentally an unresolved question about which of two things is more fundamental, and answering it explicitly is what makes the system reasonable again.

What does a cycle actually cost you?

It is worth being concrete about why this matters, because “circular dependencies are bad” is easy to nod at and easy to defer.

Deployment order becomes undefined. With A → B → A, there is no order in which you can deploy a change spanning both without a window where one side is running against an incompatible peer. Teams handle this with feature flags and multi-stage rollouts, which is real recurring work created entirely by the cycle.

Startup can deadlock. If each service checks its dependency’s health at boot, neither becomes ready. This tends to appear the first time the whole system is started from cold — a disaster-recovery rehearsal, or a new environment — which is the worst moment to discover it.

Failures propagate in both directions. In an acyclic graph, a failure flows downstream and you can reason about the blast radius. In a cycle, A’s degradation slows B, which slows A further. Cycles are where cascading failures are born, because the feedback loop is structural rather than incidental.

Testing requires the whole loop. Neither service can be tested in isolation without mocking the other, and the mocks encode assumptions that drift from reality.

Reasoning becomes impossible. “What happens if B is slow” has no bounded answer when B’s slowness affects A, which affects B. This is the cost that compounds most, because it makes every future change riskier to evaluate.

Taken together: a cycle removes the main benefit of splitting services in the first place — independent deployment, isolated failure, and local reasoning. A system full of cycles has the operational cost of microservices with the coupling of a monolith, which is the worst available combination.

How do you find cycles you do not know about?

Most cycles are not designed; they accumulate one reasonable call at a time, and nobody has the whole graph in their head. Four detection methods, in increasing order of reliability.

Static analysis of client usage. Search each service for imports of other services’ generated clients and build the edge list. Cheap and it misses dynamic calls, HTTP calls by URL, and anything going through a gateway.

Configuration and manifests. Service definitions, environment variables holding peer URLs, and mesh configuration all encode the graph. More complete than imports, still declarative rather than observed.

Distributed traces. The authoritative source, because it shows what actually calls what in production rather than what someone thinks does. Aggregating service-to-service edges from traces over a week produces the real graph, including the calls nobody documented. If you have tracing, this is the method to use.

Network telemetry. Service mesh or CNI-level flow data, which catches even calls that bypass your instrumentation.

Whichever you use, the important step is to run it continuously and fail on new cycles, not to run it once during a cleanup. A cycle detected in CI when the offending call is added takes minutes to reason about; the same cycle found a year later is embedded in the design and expensive to remove.

Two refinements worth adding. Distinguish runtime from build-time cycles — a shared library creating a build dependency between services is a different problem from a synchronous call loop, and it has different fixes. And distinguish synchronous from asynchronous edges: A calling B synchronously while B emits an event A consumes is technically a cycle in the graph and is not the dangerous kind, because the event is decoupled in time and cannot deadlock or cascade the same way. Treating both identically produces a lot of false alarms and teaches people to ignore the check.

The practical output worth maintaining is a layered view of the graph: which services are foundational, which are mid-tier, which are edge. Once the layering is explicit, a cycle is simply an edge pointing the wrong way — visible at a glance, and easy to argue about concretely.

When is a cycle acceptable?

Absolute rules get ignored when they meet a real constraint, so it is worth naming the cases where a cycle is a reasonable position rather than a defect.

Asynchronous edges. A calls B synchronously; B publishes an event A consumes. This appears in the graph as a cycle and does not carry the dangerous properties — no startup deadlock, no synchronous cascade, no deployment ordering problem, since the event is decoupled in time. Detect it, label it, and move on.

A cycle entirely within one team and one deployment unit. If both services are owned by the same team and always released together, the coordination cost that makes cycles expensive largely disappears. It is still worth asking whether they should be one service, but it is not urgent.

A deliberate, documented exception during a migration. Extractions sometimes pass through a temporary cycle on the way to a clean split. Acceptable with an owner and an end date attached; a “temporary” cycle with neither is simply a cycle.

Read-only reference lookups. A calling B for a rarely-changing reference value, with a cache and a sensible fallback when B is unavailable, is a weak edge. Not free, but far from the failure mode that matters — and often cheaper than the alternative of duplicating the data.

The unifying question is not “is there a cycle” but “can either service fail, deploy, or start independently of the other?” If yes for both, the cycle is nominal. If no for either, it is real and it will cost you at the worst possible moment.

That reframing is also what makes the rule enforceable. A blanket ban on cycles generates arguments and exceptions; a check that flags synchronous cycles, with asynchronous edges labelled and excluded, flags only the ones that matter — and a rule that fires only on real problems is one people keep listening to.

Finally, treat the dependency graph as an artefact worth curating rather than a byproduct. Reviewing it quarterly — which edges are new, which are synchronous, which cross team boundaries — takes an hour and surfaces architectural drift long before it shows up as an incident. Systems rarely degrade through one bad decision; they degrade through fifty reasonable calls added by people who could not see the shape of the whole.

The review only works if someone owns it. An unowned graph review happens twice and then stops, which is how the drift it was meant to catch resumes unnoticed.

Assign it to whoever owns the architecture review, or to the platform team if one exists; the specific owner matters less than that the calendar invite exists and outlives whoever created it.

What you are protecting is the ability to reason about the system at all, which is the quiet prerequisite for every other operational practice.

Everything else in operations assumes you can predict what a change will affect.

What I’d do differently

The mistake that creates cycles is treating each new inter-service call as a local decision. It is not; every call adds an edge to a global graph, and the graph’s shape is an architectural property. By the time a cycle is painful, it has usually been there for months, hidden behind individually-reasonable calls.

If I were running a microservice system from the start, I would make the service dependency graph a first-class, monitored artifact with an acyclicity check in CI, the same way teams guard against Protobuf schema breakage. Keeping the graph acyclic by policy is far cheaper than untangling a loop after it has fused three services into one. The direction of your dependencies is something you should decide on purpose, not discover during an incident.

Sources

Frequently asked questions

What is a service dependency cycle?

A service dependency cycle is when service A depends on B, B depends on C, and C depends back on A, forming a loop. The services can no longer be deployed, tested, or reasoned about independently, which defeats the main reason for splitting them apart in the first place.

Why are circular dependencies between services bad?

They couple services that should be independent. A cycle means you cannot deploy one service without the others, a change in one can break the loop, startup ordering becomes ambiguous, and a failure in any member can cascade around the ring. The services behave like one tangled unit.

How do you detect service dependency cycles?

Build a dependency graph from your service call map or service mesh telemetry and run cycle detection on it. Many systems add a CI check that fails the build if a new dependency introduces a cycle, so the graph stays acyclic over time rather than degrading silently.

How do you break a circular dependency between services?

Common fixes are inverting the dependency with events so the caller no longer calls back synchronously, extracting the shared concern into a third service both depend on, merging two services that are truly one, or introducing an interface so the dependency points one way. Events are the most common production fix.

How do you break a circular dependency between services?

Invert it with events where the call was really a notification, extract the shared concern into a third service, move the data to whichever service needs it more, merge the two services if they always change together, or duplicate small slow-changing data. Event inversion fixes the most cases.

What does a service dependency cycle actually cost?

Undefined deployment order, possible startup deadlock when each service health-checks the other, failures that propagate in both directions and cascade, inability to test either service in isolation, and no bounded answer to "what happens if this one is slow."

How do you detect service dependency cycles?

Distributed traces are the most reliable source since they show what actually calls what in production. Static analysis of generated-client imports and service manifests are cheaper but miss dynamic calls. Run detection continuously and fail CI on new cycles rather than auditing once.

Are all service dependency cycles equally bad?

No. A synchronous call loop can deadlock at startup and cascade under load. A cycle where one edge is an asynchronous event is decoupled in time and far less dangerous. Build-time cycles from shared libraries are a third category with different fixes. Treating them identically produces false alarms.

When is a service dependency cycle acceptable?

When one edge is asynchronous, when both services are owned by one team and always deployed together, during a documented migration with an end date, or for cached read-only reference lookups with a fallback. The real test is whether each service can fail, deploy, and start independently of the other.