A code graph is a small, deterministic map of your codebase's architecture, stored in a graph database, that an AI agent (or a developer) can query to answer cross-cutting questions in one step — "what breaks if I change this?", "which tests cover this?", "does this layer secretly depend on that one?", "which of these can no test reach?".
This guide explains how to design and build one for any codebase. It is framework-agnostic in method; the examples use Deep Memory as the substrate because it gives you a governed vocabulary, idempotent writes, and a ready-made query surface for AI agents — but the design principles apply to raw Neo4j, or any graph store. This repository's own code graph (code-graph.md, built by scripts/code-graph/) is the worked reference implementation; the method below is what produced it, generalized.
The single most important decision is what you leave out.
A code dump — every symbol, every file, every reference, the full AST or call graph — is the obvious thing to build and almost always the wrong one. It is:
- Huge and noisy. Tens of thousands of nodes; the signal you actually care about is buried.
- Redundant. Your compiler, language server (LSP), and
grepalready answer "find references to this function" instantly and exactly. A graph that merely re-encodes that adds nothing. - Useless to an AI. Drowning an agent in a 50k-node blob makes it slower, not smarter — the whole point is to reduce what it has to read.
A focused vocabulary is the opposite: a deliberately small set of node and edge types, chosen to match your architecture and to answer the handful of questions that are genuinely slow today. This repo's code graph models ~9 node types — packages, the provider seam, the MCP tool surface, docs, tests, source files, the error hierarchy — and nothing else. It does not model every function, every variable, every call. It models the seams: the cross-cutting relationships that span files, packages, and layers, exactly where grep and LSP are weakest.
Two consequences fall out of this, and they are the heart of the method:
- Every codebase needs a different vocabulary. A web app's seams are routes, components, hooks,
stores, and API calls. A microservice mesh's seams are services, queues, topics, and schemas. A
data pipeline's are sources, transforms, and sinks. The method in this guide is universal; the
model you produce is bespoke. Copying this repo's
Provider/McpToolvocabulary into a React app would be worthless — you must design your own from your own architecture. - The value is often in the absence and the invariant, not the presence. "Core has no runtime dependency" (a missing edge), "this interface has no implementation" (a missing in-edge), "this tool has no test" (another) are the high-value queries. You can only ask them because the model is curated enough that an absence is meaningful. In a code dump, absence means nothing — you just didn't extract it.
Keep this front of mind the whole way through: you are modelling an architecture, not indexing a codebase.
A code graph earns its keep when:
- The codebase is large enough that an AI agent (or a new engineer) can't hold its structure in their head, and answering "what connects to what" means many searches.
- The valuable questions are cross-cutting — they span files, modules, packages, services, or
layers — which is precisely where LSP "find references" (scoped, single-language, within-project)
and
grep(textual, no structure) fall down. - You have architectural rules you want to keep honest — a layering constraint, a dependency direction, a "this must never import that" — that today live only in people's heads or a CONTRIBUTING doc.
It is not worth it when: the project is small; the questions are local ("find references in this file") where the LSP already wins; or what you want is runtime behaviour (use tracing/profiling, not a static graph). Don't build a graph to relitigate what your tools already do well.
Write down the 5–15 questions you (or an AI working on this repo) keep needing to answer and that are slow today. Be concrete. Examples that drove this repo's graph:
- "If I change the
StorageProviderinterface, which implementations and tests break?" - "Does the
corepackage have any runtime dependency?" (it must not) - "Which MCP tools mutate state vs only read?"
- "Which source files import this one — what's the blast radius of editing it?"
- "Which tools / files have no test?"
- "What errors can this code throw, and what catches them?"
Your list will look nothing like this. That's the point. The vocabulary is reverse-engineered from the questions — if a node or edge type doesn't help answer a question on your list, it doesn't belong in the graph.
Each question contains nouns. Promote the recurring, navigable ones to entity types. Rules of thumb:
- Keep the set small. Single digits is healthy. If you're past ~12 types, you're probably dumping.
- Each type must be deterministically extractable from a concrete source signal — a naming convention, an AST shape, a file location, a manifest entry (see Step 5). If you can't point at the signal, it's not an entity type yet.
- Pick names that read well as a sentence with your edges: "
ProviderImplIMPLEMENTSProviderContract". (If your store enforces a name-similarity guard — Deep Memory rejects pairs scoring ≥ 0.85 Jaro-Winkler — keep sibling names distinct:ProviderContractvsProviderImplpasses;ServicevsServiceswould not.)
Edges are where the value concentrates. For each one decide: source type → target type, direction, and what it means. Prioritise two kinds:
- Invariant edges — ones whose presence or absence encodes a rule. The whole reason
coreis a node is so its missing outgoing runtime-dependency edge can be checked. - Blast-radius edges — ones you traverse to answer "what depends on / breaks with X":
IMPLEMENTS,IMPORTS,COVERS,THROWS,DEPENDS_ON.
Put attributes that vary per-edge on the edge as properties (e.g. depType: runtime|peer|dev,
detection: nominal|structural, role: subject|fixture) rather than inventing a new edge type for
each variant.
Not everything deserves the same weight. Split your model into tiers:
- Architectural constructs (few, important): interfaces, services, endpoints, error types. Give
these rich edges and anchor them to their owner (a
CONTAINS/OWNSedge). - Bulk (many, uniform): every source file, every component. Model these only if a question needs
them, and keep them light. This repo has 186
Modulenodes for the file-level import graph, but it deliberately does not give each aCONTAINSedge to its package — 186 edges would swamp a package's neighbourhood and make exploration useless. Instead eachModulecarries apackageproperty. Granularity is a design lever: coarse where you can, fine only where a question forces it.
The graph must be reproducible from source with no LLM and no guessing. Every node and edge comes from a concrete, mechanical signal:
| Signal | Examples |
|---|---|
| Naming convention | a class/interface ending in Provider, Service, Controller, Error |
| AST shape | implements X / extends Y clauses; a get name() string literal; decorators (@Component, @Injectable); throw new XError(); export declarations |
| File layout | packages/<x>/src/...; app/**/page.tsx; **/*.test.ts |
| Manifests | package.json deps; openapi.yaml endpoints; a router config; docker-compose.yml services |
| Markdown / docs | links to source files; symbol names in prose |
Use a real parser for the AST signals (e.g. ts-morph / the TypeScript compiler API for TS,
tree-sitter for polyglot, or your language's AST library) — not regex over source, which breaks on
the first edge case.
Two extraction lessons worth internalising:
- Nominal vs structural. Prefer the explicit signal (a
implementsclause). But the explicit signal sometimes isn't there: this repo'sNeo4jStorageProviderdeliberately omits itsimplements StorageProviderclause, so a nominal-only scan silently dropped the storage backend that powers the graph itself. The fix was structural detection — a class that declares every required member of the interface is treated as implementing it — and then flagging the inferred edge (detection: structural) so a consumer knows it was inferred, not declared. Infer when you must; never hide that you inferred. - Resolve, don't fabricate. When a reference can't be resolved to a modelled node (an import of an untracked package, a doc link to a non-source file), drop it — don't invent a placeholder node. A clean "this edge is absent" is more useful than a fictional one.
A rebuild you can run on every commit beats a one-off snapshot that rots. The pattern this repo uses, and the one to copy:
- Deterministic ids. Derive each node's id from its natural key (e.g.
uuidv5("module:" + path)), so the same logical thing always maps to the same graph node across runs. - Fingerprints. Hash each node's salient content. On rebuild, re-extract the whole desired
graph, read the current graph, and diff: create the new, update the changed (fingerprint differs),
delete the gone. For edges, key on
type|source|targetplus a discriminator for multi-edges. - Apply only the difference. The result is idempotent (a no-op run writes nothing), self-healing (drift is corrected), and always reflects your working tree. Verify this: a second consecutive build must report zero changes.
A graph that overstates its certainty is dangerous. Bake the limits in:
- Static facts, not runtime proof. An edge means "this relationship exists in the source", not "this path runs". Say so.
- Flag inferred edges (the
detectionproperty above). - Record provenance. Stamp the build with the git commit, branch, dirty flag, and timestamp, so a consumer can tell how stale the graph is and against which commit — and refresh on demand.
Don't trust the graph because it built. Spot-check it:
- Cross-check counts against source (
grep -c, file counts) — "the graph says 24 error types; does the source have 24 exportedextends *Errorclasses?" - Traverse a few known relationships end-to-end and confirm they match reality.
- Look hard at surprising absences — a missing edge is either a real finding (a genuine gap, like an untested tool) or an extraction bug. Both are worth knowing.
Ship the highest-value seam first (often the dependency graph or the core interface→implementation relationship), prove it answers real questions, then add a layer at a time. A good substrate lets the vocabulary evolve in place — Deep Memory reconciles entity/relationship type changes on the next run without rebuilding the data — so growing the model is cheap. This repo grew from "packages + providers + tools + docs + tests" to add a file-level import graph and an error hierarchy as later, separate iterations.
To drive home that the model is bespoke, here are starting-point vocabularies for four very different systems. These are sketches to adapt, not prescriptions.
| Entities | Edges | Headline query |
|---|---|---|
Package, ProviderContract, ProviderImpl, McpServer, McpTool, Doc, Test, Module, ErrorType |
DEPENDS_ON_PACKAGE, IMPLEMENTS, CONTAINS, ADVERTISES, IMPORTS, COVERS, THROWS, EXTENDS, DOCUMENTS |
"Change this contract → which impls + tests break?"; "Does core have a runtime dep?" |
Extraction: package.json for deps; implements/extends clauses and get name() literals via
ts-morph; markdown links; *.test.ts imports. (See code-graph.md for the full
model.)
| Entities | Edges | Headline query |
|---|---|---|
Route, Component, Hook, Store/Context, ApiClient, EnvVar |
Route ─RENDERS→ Component, Component ─USES→ Hook, Component ─READS→ Store, Component ─CALLS→ ApiClient, Hook ─READS→ EnvVar |
"Which routes render a component that calls this (changing) API?"; "Which components read this store?" |
Extraction: file-based routing layout (app/**/page.tsx); import graph; useX() call sites;
process.env.X reads; the API client's method surface.
| Entities | Edges | Headline query |
|---|---|---|
Service, Endpoint, Queue/Topic, Schema/Message, Datastore, Job |
Service ─EXPOSES→ Endpoint, Service ─PUBLISHES/CONSUMES→ Topic, Endpoint ─VALIDATES→ Schema, Service ─READS/WRITES→ Datastore, Service ─CALLS→ Endpoint |
"If I change this message schema, which consumers break?"; "Who writes to this datastore?" |
Extraction: OpenAPI/proto/AsyncAPI specs; queue client publish/subscribe call sites; service manifests
(docker-compose, k8s, Terraform); DB client usage.
| Entities | Edges | Headline query |
|---|---|---|
Source, Dataset/Table, Transform/Job, Sink, Schedule |
Job ─READS→ Dataset, Job ─WRITES→ Dataset, Schedule ─TRIGGERS→ Job, Sink ─EXPOSES→ Dataset |
"Upstream/downstream lineage of this table — what breaks if its schema changes?" |
Extraction: job definitions (SQL FROM/INTO, DAG configs); scheduler config; table/dataset
references in queries.
Notice none of these reuse another's node types. The architecture dictates the vocabulary.
- Dumping the whole AST / call graph. If your node count rivals your line count, you've built an index, not a model. Cut back to the seams.
- Re-encoding what the LSP already does. "Find references" within a project is solved. Model the relationships that cross the boundaries the LSP can't see.
- Modelling runtime-tracked data. Don't add a "Conversation" or "Request" node to a static graph; that's runtime state, tracked elsewhere. (In Deep Memory specifically, provenance is recorded on every mutation — don't duplicate it as entities.)
- Fabricating unresolved edges. Drop what you can't resolve; never invent placeholder targets.
- Letting bulk nodes swamp navigation. Anchor important constructs richly; keep high-count node types light (properties over edges).
- Hiding inference. If you matched something heuristically, mark it.
- A one-off snapshot. Without deterministic ids + fingerprints + a re-runnable build, the graph rots within a week and people stop trusting it.
- Embeddings you don't need. A structural graph is navigated by type, name, and traversal — not fuzzy text. Skip embeddings unless you have a genuine "find the concept I can't name" use case; they cost money and add nothing to "what implements X".
Before you build, your draft model should pass all of these:
- Every entity type answers at least one question on your list.
- Every entity type has a concrete, deterministic extraction signal.
- Every relationship type is directional and reads as a sentence.
- At least one query relies on an absence (a missing edge / unimplemented / untested).
- High-count node types are kept light (properties, not a web of edges).
- Inferred edges are distinguishable from declared ones.
- The build is idempotent and stamps provenance.
- You can state, in one sentence each, the caveats (what the graph does not prove).
You can build a code graph on raw Neo4j or any graph DB. Deep Memory adds three things that make it a good fit, especially for AI consumers:
- A governed vocabulary. You declare entity/relationship types with descriptions and required properties; the schema is enforced and evolves in place as your model grows.
- Idempotent, identity-stable writes and a delta-friendly model — the reconciliation pattern in Step 6 maps directly onto its API.
- An MCP tool surface for agents. Once built, an AI assistant queries the graph through the
deep-memoryMCP server —memory_explore_neighborhood(blast radius),memory_find_entities,memory_query_graph(traversals, projections) — the same tools documented in packages/mcp-server/README.md.
The fastest way to start is to copy this repo's reference implementation and swap the vocabulary:
| File | What to adapt |
|---|---|
| scripts/code-graph/vocabulary.ts | Replace the entity/relationship types with your model (Steps 2–4). |
| scripts/code-graph/extract-*.ts | One extractor per layer — mirror these for your signals (Step 5). |
| scripts/code-graph/rebuild.ts | The orchestrator: extract → connect → reconcile (delta) → verify. The id/fingerprint/edge-diff machinery is reusable as-is (Step 6). |
Then build it (a local Neo4j via the bundled docker-compose.neo4j.yml, then pnpm code-graph:rebuild)
and query it through the MCP server. No embeddings are wired, so it costs nothing to build and rebuild.
AI agents building this for a user: treat Steps 1–4 as a short conversation — surface the candidate questions and the draft vocabulary, get the user to confirm the seams that matter to them, then implement. Do not start by dumping every symbol; start by asking what's slow to find.
- code-graph.md — this repo's code graph: what it models, why it's optional, how to build it. The worked example this guide generalises.
- code-graph-usage.md — the terse query reference (slugs, recipes, Cypher).
- ai-requirements.md — the progressive-discovery design philosophy behind Deep Memory's tool surface, which is why a focused graph serves an AI better than a dump.