NxtSoftLabs
← All writing

Cross-service impact analysis with cgraph seam

July 16, 2026·7 min read

cgraph seam joins the code graphs of independently built services into one queryable graph by declaring their wire contract — which endpoints exist, who calls them, and where response schemas are mirrored. Once fused, impact analysis stops at nothing: a schema change in the provider traces through the endpoint to the exact call sites and mirror types in every consumer repo.

Blast radius stops at the repo wall

A per-repo code graph answers what breaks if I change this — inside that repo. But the change that actually hurts is the one that crosses a service boundary: the ML team renames a field on a response schema, and the failure surfaces two repos away, in a TypeScript type someone hand-copied eight months ago. No single repo's graph can see that edge, because the edge isn't in any repo. It's in the wire contract between them.

The obvious fixes all have problems. Whole-org monorepo graphs don't exist when the services genuinely live in separate repos. Sniffing traffic gives you a runtime sample, not a contract. And guessing edges from string-matching URLs across repos produces exactly the kind of plausible-but-wrong edge you can't build tooling on.

CGraph's answer is a seam: a small, host-authored spec of the contract, resolved against the real per-repo graphs, compiled into cross-service edges that are anchored to real nodes — or not emitted at all.

Declare the contract once

A seam spec is a JSON file that names the provider, its schemas and endpoints, and the places consumers touch them. This one describes an ml-api provider with one scoring endpoint, consumed by a backend and a web service:

{
  "provider": "ml-api",
  "api_version": "v3",
  "services": [
    { "name": "ml-api",  "role": "provider", "owned": false },
    { "name": "backend", "role": "consumer", "owned": true },
    { "name": "web",     "role": "consumer", "owned": true }
  ],
  "schemas": [
    { "name": "ScoreResult", "canonical": "ml-api/src/schemas/score.ts" }
  ],
  "endpoints": [
    { "method": "POST", "path": "/v3/score", "response_schema": "ScoreResult" }
  ],
  "consumes": [
    { "service": "backend", "method": "POST", "path": "/v3/score",
      "call_site": { "graph": "backend", "file": "src/score.ts", "line": 42 } },
    { "service": "web", "method": "POST", "path": "/v3/score",
      "call_site": { "graph": "web", "file": "src/api/score.ts", "line": 18 } }
  ],
  "mirrors": [
    { "schema": "ScoreResult", "graph": "backend", "file": "src/types.ts", "line": 10 }
  ]
}

Two kinds of anchors matter here. A call_site says this service calls that endpoint, from this file and line. A mirror says this local type is a hand-maintained copy of that provider schema — the field-rename time bomb made explicit and trackable.

cgraph seam gen resolves the spec against each named consumer graph and emits a contract fragment:

cgraph seam gen --seam seam.json \
  --graphs backend=backend/graph.json \
  --graphs web=web/graph.json \
  --out seam-out

The fragment introduces three node kinds — service, endpoint, schema — and five edge relations that stitch the graphs together:

RelationFrom → toMeaning
CONSUMESservice → endpointthis service calls that endpoint
SERVED_BYendpoint → servicethe provider that owns the endpoint
RESPONDS_WITHendpoint → schemathe response shape
CONSUMED_ATendpoint → code nodethe resolved call site in a consumer
MIRRORED_BYschema → code nodea hand-copied type in a consumer
web · consumerfetchScore()src/api/score.ts:18backend · consumerscoreModel()src/score.ts:40–50type ScoreResultsrc/types.ts:10ml-api · providerml-apiservicePOST /v3/scoreendpointScoreResultschema · v3CONSUMESSERVED_BYRESPONDS_WITHCONSUMED_ATMIRRORED_BY
Fig. 1 — One seam across three repos. The endpoint and schema are contract nodes from the seam; everything inside a dashed boundary comes from that service's own graph. Each consumer also gets a CONSUMES edge to the endpoint (drawn once here for legibility).

Anchors resolve to real nodes — or nothing ships

The dangerous failure mode for any cross-graph tool is the dangling edge: a contract edge pointing at a node that doesn't exist, silently rotting as code moves. seam gen refuses to produce one. Each anchor's (file, line) is resolved against the named consumer graph to the smallest non-file node whose span contains that line, and the edge targets that node's real id.

file · src/score.ts1–100function · outer()1–100function · scoreModel()40–50 · smallest span wins1255075100call_site · line 42
Fig. 2 — Resolving the backend call site at src/score.ts:42. The line falls inside three nested spans; the smallest containing node, scoreModel() at lines 40–50, is the one the CONSUMED_AT edge targets.

If any anchor resolves to nothing — the file moved, the function was deleted, the line drifted out of every span — the whole command fails with an error naming that anchor, and no fragment is written. A stale seam spec breaks loudly at generation time instead of quietly lying at query time. The same fail-loud rule covers the spec itself: a consumes entry referencing an undeclared endpoint, a mirror naming an unknown schema, or an anchor pointing at a graph you didn't supply are all hard errors.

Generation is also deterministic: the same spec and graphs produce a byte-equivalent fragment every time, so you can commit the output and diff it in CI like any other build artifact.

Fuse it into one clustered graph

cgraph seam fuse merges the contract fragment with the service graphs into a single view:

cgraph seam fuse --seam seam-out/chunk_00.json \
  --graph backend=backend/graph.json \
  --graph web=web/graph.json \
  --out fused/
seam.json
contract spec
seam gen
resolve anchors
chunk_00.json
contract fragment
seam fuse
merge + cluster
fused/
graph.json · graph.html
seam query / graphd
cross-service reads
Fig. 3 — The seam pipeline. Everything left of the fused output is deterministic and fail-loud; everything right of it is read-only.

Two details make the fused graph pleasant to use. First, every node is tagged with its service as a community, so the standard graph.html renderer draws each service as its own colored cluster joined by the contract edges — you can open the fused topology in a browser and see the seam. Second, the fragment's shadow code-refs collapse onto the real nodes from each service's graph, so a contract edge lands on the actual scoreModel() node with its full neighborhood, not a stub. And like gen, fuse fails loud: if an edge references a service graph you forgot to pass, you get an error, not a graph with holes.

Ask cross-service questions

The fused graph answers the same read ops a normal CGraph daemon does — query, path, explain, impact, context — except the traversal now crosses service boundaries. The one-shot form:

cgraph seam query --graph fused/graph.json impact \
  '{"id":"schema:ml-api:v3:ScoreResult","direction":"dependents"}'

That is the field-rename question answered in one call: the schema's dependents are the endpoint that responds with it, the services that consume that endpoint, the exact call sites, and the mirror type in backend/src/types.ts that someone must now update by hand.

changeddepth 1depth 2ScoreResultschema · v3POST /v3/scoreendpointtype ScoreResultbackend · src/types.ts:10fetchScore()web · src/api/score.ts:18scoreModel()backend · src/score.ts:42RESPONDS_WITHMIRRORED_BYCONSUMESCONSUMED_AT
Fig. 4 — Dependents of the ScoreResult schema, by traversal depth. The mirror edge is the one humans forget; here it is a first-class dependent.

For repeated queries there's a resident option: seam fuse drops a .cgraph-seam marker in its output directory, and graphd started on that directory serves the fused graph read-only — no building, no file watching. Because it's addressed like any project root, the MCP tools work unchanged: point project_root at the seam directory and your coding agent runs cross-service impact queries the same way it queries a single repo.

What a seam is not

A seam is a derived, read-only snapshot, and the design leans into that honestly:

  • It doesn't watch anything. Services keep evolving; the seam reflects the graphs it was fused from. Re-run gen and fuse (in CI, typically) and send the daemon an update to reload.
  • It doesn't discover contracts. You author the spec — which is a feature: the seam encodes what the contract should be, and generation failing on a moved anchor tells you the contract drifted.
  • It doesn't accept writes. Write ops against a seam are rejected; the per-repo graphs stay the source of truth.

Try it

CGraph is open source. Build a graph per service, write a seam spec for one endpoint you actually worry about, and run the three commands above — start with the schema you've hand-mirrored somewhere, because that's the edge no other tool can see. Read the code on GitHub.