What service dependency management covers: the five kinds of dependency
Service dependency management is the practice of knowing which services call which, and what breaks when any one of them changes. With five services, people can keep this in their heads. With dozens, owned by several teams and deployed independently, nobody holds the full picture. A small change to a response field or a config key can then cause failures several services away.
Each kind of dependency fails differently, so each needs to be tracked:
| Dependency type | Example | Typical failure |
|---|---|---|
| Synchronous calls | HTTP or gRPC request to another service | Timeouts and cascading latency; breaking contract changes fail immediately |
| Asynchronous messaging | Topics, queues, event schemas | Consumers silently drop or misparse messages after a schema change; failures surface hours later |
| Shared data stores | Two services reading one database table | A migration in one service breaks queries in another that nobody knew existed |
| Shared libraries and SDKs | Internal client library, serialization package | A version bump changes behaviour in every service that rebuilds |
| Infrastructure and config | DNS names, feature flags, secrets, network policy | A renamed key or rotated credential breaks services with no code change |
Many tracing and service-mesh tools show only the first row, because those edges appear in request traffic. The other four are often the ones that cause surprises.
A hand-drawn architecture diagram can't fill the gap. It shows what someone believed was true on the day they drew it. It records no schema versions or library pins. Nothing alerts anyone when it drifts from production.
How it works: building a dependency graph from declared and observed edges
A useful dependency graph is built from two independent sources and then compared. Neither source is reliable alone.
Declared edges are what teams say their service depends on. They come from per-repo manifests listing upstream services, from API client registrations such as generated clients or gateway consumer entries, and from topic subscriptions defined in configuration. Declared data captures intent and covers code paths that rarely run. It goes stale as soon as someone adds a call and forgets to update the YAML.
Observed edges are what production actually does. They come from distributed traces, service-mesh or proxy telemetry, and broker consumer-group metadata. Observed data stays current, but it only records what ran during the observation window. Sampled traces can miss rare calls. Low-traffic endpoints, nightly batch jobs and disaster-recovery paths may not appear for weeks, or ever.
Reconciling the two sources produces three classes of edge:
- Declared and observed: confirmed. These edges need no action.
- Declared but never observed: probably stale. Before deleting one, check whether it belongs to a batch or failover path.
- Observed but never declared: a hidden coupling. These edges deserve the most attention, because no owner has acknowledged them and no contract governs them.
Attach ownership and contracts directly to the graph. Each node carries its owning team and on-call route. Each edge carries the contract it depends on: the API schema, the event schema and the SLO. With those attributes in place, a query for "who breaks if this changes" returns names and pager routes along with service identifiers.
# Reconcile declared edges (from repo manifests) with observed edges (from traces/mesh).
# Edges are (caller, callee) pairs; node metadata carries ownership.
declared = {
("checkout", "payments"),
("checkout", "inventory"),
("billing-batch", "ledger"), # nightly job, rarely seen in traces
}
observed = {
("checkout", "payments"),
("checkout", "inventory"),
("checkout", "user-profile"), # nobody declared this call
}
owners = {
"checkout": ("team-storefront", "pager:storefront-oncall"),
"payments": ("team-payments", "pager:payments-oncall"),
"inventory": ("team-fulfilment", "pager:fulfilment-oncall"),
"user-profile": ("team-identity", "pager:identity-oncall"),
"billing-batch": ("team-finance", "pager:finance-oncall"),
"ledger": ("team-finance", "pager:finance-oncall"),
}
# Known non-request paths that observation will under-report.
batch_or_failover = {"billing-batch"}
confirmed = declared & observed
stale = declared - observed
hidden = observed - declared
for caller, callee in sorted(hidden):
# Highest priority: no owner has acknowledged this coupling or its contract.
team, route = owners.get(callee, ("UNOWNED", "none"))
print(f"HIDDEN {caller} -> {callee} notify {team} via {route}")
for caller, callee in sorted(stale):
# Don't auto-delete edges that only run on batch or DR paths.
note = "verify batch/DR path" if caller in batch_or_failover else "candidate for removal"
print(f"STALE {caller} -> {callee} ({note})")
print(f"CONFIRMED {len(confirmed)} edges")
A minimal reconciliation of declared and observed edge sets that flags hidden couplings with their owner's pager route and treats unobserved batch-path edges as needing verification rather than deletion.
Using the graph for change impact analysis before deploy
Change impact analysis starts when a pull request touches an API definition or event schema. CI resolves the changed artifact to its node in the graph and walks outbound edges to list every consumer, with the specific operations or fields each one uses. That last detail matters. A consumer that only reads order_id does not care that discount_code was renamed.
Next, CI diffs the old and new schema against a compatibility rule. Removing a field, narrowing a type or making an optional field required counts as breaking. Adding an optional field usually does not. This check is cheap and catches structural breaks. It cannot catch semantic ones, like a field that keeps its type but changes meaning. Consumer-driven contract tests cover that gap. Each consumer publishes the interactions it relies on, and the provider's build replays them.
The gate should block only when a breaking change hits an edge that observed traffic or a published contract shows is in use. Everything else gets a warning. The PR comment should list the affected consumers, the fields they use, their owning teams and the result of each contract test. That way the author knows who to talk to without opening a ticket.
These gates have costs. Contract suites add CI minutes for every consumer, and a red build can stall a team waiting on another team's fix. If the gate is too strict, engineers learn to bypass it or batch changes to avoid it. If it is too loose, it only flags breakage after production traffic has already exposed it.
# check_impact.py: block a PR only when a breaking schema change hits a field a consumer uses.
# Usage: python check_impact.py old_schema.json new_schema.json consumers.json
# Schemas are flat maps: {"order_id": {"type": "string", "required": true}, ...}
# consumers.json comes from the dependency graph (observed traffic + published contracts):
# [{"service": "billing", "team": "payments", "fields": ["order_id", "total"]}, ...]
import json, sys
def breaking_changes(old, new):
"""Structural breaks only. Semantic changes are left to consumer contract tests."""
breaks = {}
for name, spec in old.items():
if name not in new:
breaks[name] = "removed"
elif new[name]["type"] != spec["type"]:
breaks[name] = f"type {spec['type']} -> {new[name]['type']}"
elif new[name].get("required") and not spec.get("required"):
breaks[name] = "optional -> required"
return breaks
old, new, consumers = (json.load(open(p)) for p in sys.argv[1:4])
breaks = breaking_changes(old, new)
lines, blocking = ["| Consumer | Team | Fields used | Status |", "|---|---|---|---|"], False
for c in consumers:
hits = [f"{f} ({breaks[f]})" for f in c["fields"] if f in breaks]
blocking |= bool(hits)
status = "BLOCKING: " + ", ".join(hits) if hits else "ok"
lines.append(f"| {c['service']} | {c['team']} | {', '.join(c['fields'])} | {status} |")
# Breaks on fields no consumer uses are reported but do not fail the build.
unused = sorted(set(breaks) - {f for c in consumers for f in c["fields"]})
if unused:
lines.append(f"\nWarning: breaking changes on unused fields: {', '.join(unused)}")
print("\n".join(lines)) # CI posts this output as the PR comment
sys.exit(1 if blocking else 0)
A minimal CI gate that diffs two schemas, matches breaking changes against the fields each consumer in the dependency graph actually uses, prints a PR-comment table of impacted consumers and owning teams, and fails only when a used field breaks.
Detecting unhealthy service coupling with graph queries
Impact analysis looks at one change at a time. The same graph can also show where the architecture is coupled in ways that will cause problems later. With dozens of services and hundreds of edges, a full diagram is too dense to read. A query can be repeated, run in CI, and returns a short list that a team can act on.
| Warning sign | Why it hurts | Query that finds it |
|---|---|---|
| Long synchronous call chains on the request path | Latency adds up at each hop and availability multiplies. Illustrative estimate: five services at 99.9% each give about 99.5% end to end | Longest path over synchronous edges, starting from each entry point |
| Cycles | Services in a cycle have no safe deploy order, and retries can go round the loop | Cycle detection (strongly connected components) |
| High fan-out per request | The slowest of many downstream calls sets tail latency | Out-degree per service, counting only request-path edges |
| Several services writing to one database | A schema change needs every writing team to agree | In-degree on datastore nodes, counting only write edges |
| Services that always deploy together | Separate services that behave like one monolith | Co-change: join deploy history to edges and list pairs that ship in the same release most of the time |
Treat the results as prompts for review, not as rule violations. Some coupling is correct. An API gateway is supposed to have high fan-out. A database with one writer and many readers is usually fine. Record the exceptions you accept so the same results don't come back every week. If two services always change together and nothing else depends on the boundary between them, merging them is often cheaper than decoupling them.
import sys
import networkx as nx
# Edges exported from the service catalog: (caller, callee, kind)
EDGES = [
("gateway", "orders", "sync"),
("gateway", "search", "sync"),
("orders", "pricing", "sync"),
("pricing", "inventory", "sync"),
("inventory", "orders", "sync"), # closes a cycle
("orders", "billing", "async"),
("orders", "orders-db", "write"),
("billing", "orders-db", "write"),
("reports", "orders-db", "read"),
]
FANOUT_LIMIT = 1
ACCEPTED = {("fanout", "gateway")} # reviewed exceptions, kept in the repo
g = nx.DiGraph()
for src, dst, kind in EDGES:
g.add_edge(src, dst, kind=kind)
# Only synchronous calls sit on the request path
sync = g.edge_subgraph([(u, v) for u, v, k in g.edges(data="kind") if k == "sync"])
findings = []
# Cycles: any strongly connected component with more than one service
for scc in nx.strongly_connected_components(sync):
if len(scc) > 1:
findings.append(("cycle", ", ".join(sorted(scc))))
# Longest chain is only well defined once the sync graph has no cycles
if nx.is_directed_acyclic_graph(sync):
findings.append(("longest sync chain", " -> ".join(nx.dag_longest_path(sync))))
# Fan-out hotspots, skipping exceptions the team has accepted
for node, deg in sync.out_degree():
if deg > FANOUT_LIMIT and ("fanout", node) not in ACCEPTED:
findings.append(("fanout", f"{node}: {deg} sync calls"))
# Shared databases: more than one writer on a datastore node
for node in g.nodes:
writers = [u for u, _, k in g.in_edges(node, data="kind") if k == "write"]
if len(writers) > 1:
findings.append(("shared writes", f"{node}: {', '.join(sorted(writers))}"))
for kind, detail in findings:
print(f"{kind}: {detail}")
sys.exit(1 if findings else 0) # non-zero exit lets CI flag new findings
A networkx script that runs over an exported dependency graph and reports sync cycles, the longest synchronous chain, fan-out hotspots that aren't on the accepted list, and datastores with more than one writer; it exits non-zero so CI can fail on new findings.
Reducing coupling: events, owned data, expand/contract and resilience
When merging isn't right, each technique below swaps one kind of coupling for a cost you can manage.
| Technique | Coupling it removes | Cost you take on |
|---|---|---|
| Events instead of synchronous chains | Runtime availability and latency of downstream callers | Duplicate and out-of-order delivery; edges invisible in call traces |
| Owned data store per service | Shared schema and lock contention | Duplicated data, sync pipelines, harder cross-service reporting |
| Expand/contract API changes | Lockstep deploys | Temporary dual code paths and cleanup work |
| Timeouts, retry budgets, circuit breakers | Cascading failure | Tuning effort and degraded-mode behaviour to design |
Use events only where eventual consistency is acceptable. Consumers must be idempotent and tolerate reordering, and because no request path shows the edge, declare producer and consumer in the graph explicitly.
With owned data, other services read through an API rather than querying your tables. Your schema then becomes private and can change freely. Reporting moves to a separate pipeline.
Expand/contract adds the new field or endpoint, migrates callers, then removes the old one. Version schemas additively so older consumers keep working. On critical edges, cap retries with a budget so a struggling dependency isn't flooded by retries.
Shared libraries couple services at build time. Keep internal SDKs thin, limited to transport, auth and serialization. Keep business logic out of them, because otherwise every consumer has to redeploy when a rule changes. Record which services pin which versions so a security fix can be tracked to completion across the fleet.
Deprecating and removing endpoints and topics safely
Removal is where the graph gets tested against reality, so do it in stages.
Confirm no callers are left
Start with observed traffic over a long window. A quiet week tells you little about an endpoint that only a quarterly billing reconciliation calls, or a topic that only gets consumed when a region fails over. Pick a window at least as long as your slowest known business cycle. If you find no traces, treat the result as "unknown", not "unused", because trace sampling can miss rare calls completely.
Then compare that against the declared data. If a service declares the dependency but shows no traffic, it may have a dormant path, so ask its owner before going further. If traffic shows up with no matching declaration, there is an undocumented caller, and that edge needs to go into the graph first.
Never remove based on declarations alone
Declarations only list what teams remembered to write down. Rare code paths are the ones people forget: error handlers, year-end jobs, admin tools, retry fallbacks. If you delete because a manifest says nothing depends on the endpoint, the outage arrives the first time one of those paths runs. That can be months later, far from the change that caused it.
Announce, redirect, then remove
Publish a sunset date and name the replacement. Use the graph to send each remaining caller to its owning team as a ticket, not a broadcast message. Alert on any calls during the window. Remove the endpoint or topic only after the window closes with no traffic.
When to adopt it, when to avoid it, and the impact on cost and engineering time
Not every organization needs a dependency platform. With around a dozen services and two teams, people usually already know who calls what, and a graph mostly confirms that. The investment pays off when nobody can reliably answer "who breaks if this changes?"
| Approach | Good fit | Main gap |
|---|---|---|
| Service catalog alone | Ownership and on-call routing | No observed edges, so it drifts from reality |
| Tracing UI alone | Live call paths during incidents | Misses rarely called paths and some async consumers; no ownership |
| Full in-house platform | Many teams with frequent cross-team breakages | Ongoing maintenance and coordination cost |
Costs come from three places: someone has to maintain the platform, graph checks add CI time, and a gate that blocks one team's deploy pulls in another team's engineers. Savings come from faster incident triage, fewer breaking deploys, and removals that don't need weeks of guesswork.
Illustrative estimate (assumed numbers, not a benchmark): Net engineer-hours per month = (incidents × hours saved each) + (breakages avoided × hours per breakage) − maintenance hours. With 6 incidents × 1.5 h, 2 breakages × 8 h, and 20 h of maintenance, the result is 9 + 16 − 20 = +5 h. Count CI compute separately.
That margin is thin. A small team should start with tracing and an ownership catalog.
If you adopt the practice, fit it into your existing workflow:
- Generate the graph automatically. Do not maintain it by hand.
- Link graph views directly from incident tooling.
- Review the riskiest edges every month: high fan-in, no contract, or no owner.
- Add deploy gates only for incident types your team has actually had.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to service dependency management, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- The dependency graph answers "what does this touch?" in seconds instead of a manual audit.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- You can size a change's blast radius before merging, avoiding surprise cross-team firefighting.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.