Koragraph

Cross-service edges

A cross-service edge is a dependency that leaves one repository or service and lands in another, which is exactly where single-repository tooling goes silent.

A cross-service edge is a dependency that leaves one repository or running service and lands in another. It is a real connection in the software, one piece of code relying on another, but it crosses a boundary that a tool looking at a single repository cannot see across. That boundary is exactly where most code understanding tooling goes quiet.

What a service boundary is

Start with why software gets split up at all. A small program lives in one place. As it grows, teams break it into separate pieces that run on their own, deploy on their own, and often live in their own repositories. A service is one of those pieces: a self-contained program that does a job and talks to the others over some channel rather than by sharing memory. A shopping site might have a service for the product catalog, one for the shopping cart, one for payments, and one for shipping. Each is its own codebase, its own deployable unit, maintained by its own team.

The line between two services is a service boundary. Inside a service, code calls other code directly and the connection is visible in the text: this function calls that one, right there in the file. Across the boundary, that directness is gone. The cart service cannot simply call a function inside the payments service, because the payments service is a different program, possibly on a different machine. To reach it, the cart has to send a message across the boundary and wait for a reply. The connection is still there. It is just no longer a function call you can point at.

The many ways one service reaches another

There is not one channel across a boundary but several, and each hides the connection in its own way. It is worth walking through the common ones, because the whole difficulty of cross-service edges is that the link is always real and never written as a plain call.

The most common is an HTTP request. HTTP is the protocol the web runs on: one program sends a request to an address, another program answers. When the cart needs to charge a card, it sends a request to an address like the payments service’s charge endpoint and reads the response. In the cart’s source code, that dependency appears as a piece of text: a URL string and a verb. There is no function named charge being called. There is a string that happens to name a route that the payments service happens to handle.

A close cousin is gRPC, a faster, more structured way for services to call each other. It feels more like a real function call because you define the available operations up front in a shared schema, but under the hood it is still a message crossing a boundary to a separate program. The caller and the handler live in different repositories, joined by that shared definition.

Then there is messaging that is not a direct request at all. With message topics and queues, one service announces that something happened by publishing a message to a named channel, and any number of other services that subscribe to that channel receive it. The order service publishes “order placed” to a topic; the shipping service and the email service both listen and react. The publisher does not know or name its subscribers. The only thing tying the two sides together is a shared string: the name of the topic. This is a genuine dependency, shipping truly depends on the order service’s message, but nothing in either codebase points at the other.

Services also depend on each other through shared packages. A team factors common code, a data model, a set of validation rules, into a package that several services import. Now every one of those services depends on that package, and through it, on each other’s assumptions. Change the shared model and you have reached into every service that imports it.

There are also framework routes, where a web framework maps incoming addresses to the functions that handle them, so the true target of an inbound request is discoverable only if you understand the framework’s routing rules. And there is the wiring in a Docker Compose file, a configuration file that declares which services run together and how they find each other by name. That file often holds the clearest statement of who talks to whom, expressed as service names and network links rather than as code at all.

Why a single-repo tool goes blind

Here is the core of the problem. Most tools that understand code work one repository at a time. They read the files in front of them, resolve the calls and imports they can see, and build a picture of that repository. And within its own walls, each repository looks complete. Every function the cart service calls is defined somewhere the tool can reach. Every import resolves. Nothing dangles. The tool has no reason to suspect anything is missing, because from inside one repository, nothing appears to be missing.

But the cart’s dependency on payments was never a call the tool could resolve. It was a URL string. To the single-repo tool, that string is just text sitting in a variable. It does not know that the string names a route, that the route belongs to another service, or that another service even exists. The dependency is real, but it lives in the gap between two repositories, and a tool that only ever looks inside one repository at a time will never stand in that gap.

So the boundary becomes a wall of silence. Ask the single-repo tool “what depends on the payments charge endpoint” and it can answer only for callers inside the payments repository itself. The cart, the retry job in a third service, the admin tool in a fourth, all of them depend on that endpoint, and all of them are invisible, because the evidence of their dependence lives in their repositories, not in payments’.

Inside its own walls every repository looks complete. The dependencies that matter most are the ones that were never inside any single repository to begin with.

This blindness compounds as a system grows. With two services the missing links might be held in a few people’s memory. With twenty services and forty repositories, no one holds the full map, and the tooling that could have held it for them stops at every wall. New engineers inherit a system where the most important relationships are the ones least written down, and every change across a boundary becomes an act of faith that some caller you never saw is not about to break.

The string-in-a-URL problem

It is worth dwelling on why this is genuinely hard and not just overlooked. When one function calls another in the same language and repository, the name in the code refers to a definite thing the tool can find and verify. When one service calls another over HTTP, the “name” of what it is calling is a string, and a string is not checked by anything. The cart writes an address as text. Nothing confirms that a service answers at that address, that the route is spelled the way the cart spelled it, or that the shape of data the cart sends is the shape payments expects. The compiler that would scream about a misspelled function name says nothing about a misspelled URL, because to the compiler it is just characters.

This is why cross-service bugs are quiet and expensive. Rename a route on the payments side and every caller that hardcoded the old address keeps compiling, keeps deploying, and fails only when a real request is sent. The dependency was real the whole time. It was just expressed as a matching pair of strings in two different repositories, with nothing in either one aware of the other. To recover the edge, a tool has to recognize that a string on the calling side names a route defined on the serving side, and connect the two across the boundary. That is a matching problem, not a lookup, and it is the substance of resolving a cross-service edge.

A short worked example

Walk one edge end to end. The cart service, in its own repository, builds a request to an address ending in the path for charging a card and sends it. On the calling side, all that exists is a string naming that path and a bit of code that sends the request. In a separate repository, the payments service declares a route that answers requests at that same path and runs a handler function when one arrives.

To a single-repo tool, these are two unrelated facts in two unrelated projects: a string here, a route there. To resolve the cross-service edge, a tool has to notice that the string the cart sends matches the route payments serves, and record an edge from the sending code in one repository to the handler function in the other. Once that one edge exists, “who calls the charge endpoint” finally includes the cart, and a change to the handler can be traced back to the caller that will feel it. Repeat that across every request, message, and shared import, and the separate maps knit into one.

Resolving the edges turns many graphs into one

Now the payoff. If you can recognize these boundary-crossing links and record each one as an edge, the map changes shape entirely. Before, you had a pile of separate repository maps, each internally complete and mutually blind. After, you have a single connected graph in which a request in the cart reaches the handler in payments, a published message reaches its subscribers, and a shared package’s model reaches every service that imports it. The walls between repositories become doorways you can walk through while following a dependency.

This is what makes questions across a whole system answerable at all. “Everything that depends on the payments charge endpoint” now has a real answer that spans repositories. “What happens downstream when an order is placed” can follow the message topic from publisher to every subscriber. The connections were always there in the running system; resolving cross-service edges is the act of writing them down so a tool, or an AI agent, can see them without having to already know the whole architecture by heart.

How Koragraph handles this

Koragraph is built to be multi-repository from the start, which is the prerequisite for seeing across a boundary at all: a tool that only ever holds one repository in view cannot, even in principle, connect a caller in one to a handler in another. It resolves cross-service links across the boundary, including HTTP and gRPC calls, message topics, package imports, framework routes, and the wiring declared in Docker Compose files. It parses each repository with tree-sitter across twelve languages and stores the resolved facts in a local database that persists across sessions, so the connected graph is available to an agent without rebuilding it each time.

A detail worth noting is that all of this runs on the developer’s own machine with no cloud dependency, and no source code leaves the machine. That matters for cross-service work in particular, because seeing the whole picture means holding several repositories at once, and holding several private repositories at once is precisely the situation where teams are most careful about where their code goes.

Where this connects

Cross-service edges are one kind of relationship inside the larger code knowledge graph, the explicit map of how a codebase is connected rather than what its files literally say. They are the edges that keep that map from stopping at a repository’s edge.

Because they are dependencies, they belong to the dependency graph, the view of what relies on what, and they are the reason that view can be trusted across a whole system rather than one service at a time. They are also what gives blast radius, the question of everything that could break if you change one piece of code, its true reach, since a change to a route or a message format can break callers in repositories you were not even looking at. And recovering them is the job of the ingest pipeline, the process that reads repositories and resolves their edges into stored facts. Follow any of those three next, and the picture of one connected system, rather than many blind ones, fills in.

Connected concepts

The code knowledge graphA code knowledge graph is a structured map of a codebase where every declaration is a node and every real relationship, calls, imports, inheritance, cross-service links, is an edge.The dependency graphA dependency graph records which units of code rely on which others through imports and packages, at the coarser grain of files and modules rather than individual calls.Blast radiusBlast radius is everything that could break if you change one piece of code, including the paths that cross a repository boundary and the ones no test covers.The ingest pipelineIngest is the pass that turns a pile of repositories into one graph: parse with tree-sitter, extract declarations, resolve every edge, mine the git history, and write it all to the local store.

Where this sits

Back to the full graphThe short glossary