Why the Nx project graph misses edges in Go, Docker, Terraform and proto files
The Nx project graph is a directed graph of the projects in your workspace and the dependencies between them. Nx uses it to answer two questions: which projects a change affects, and which task inputs go into a cache hash. If an edge is missing, Nx answers both questions incorrectly.
Nx's built-in dependency analysis is designed for JavaScript and TypeScript. It reads import and require statements in source files and the dependency fields of package.json manifests. Files in other languages produce no edges, even when Nx knows the projects exist. Three common cases show the gap:
- A Go service whose
go.modhas areplacedirective pointing at../../libs/authdepends on that library. Nx does not parsego.mod. - A Dockerfile that runs
COPY --fromon an image built by another project, or copies another project's build output, depends on that project. Nx does not read Dockerfiles. - A
.protofile that importsshared/common.protofrom a definitions project depends on it. Nx does not resolve proto imports.
Missing edges cause two failures. First, nx affected selects too few projects. You change the shared Go module or the base proto definitions, and the consumers are never built or tested in CI. Second, the cache can serve stale results. A consumer's hash does not include its upstream project, so Nx can replay an old build or test result after the dependency has changed.
This tutorial is for backend and platform engineers who run polyglot Nx workspaces. A custom graph plugin is worth writing when these relationships are declared in files you can parse, when they change often enough that maintaining implicitDependencies by hand drifts out of date, and when a missed rebuild would cost more than maintaining a small parser.
+--------------------------------------------------------------------+
| Nx built-in analysis: JS/TS import/require + package.json deps |
+--------------------------------------------------------------------+
CONSUMER EDGE UPSTREAM DECLARED IN
web-app ----------------> ui-lib TS import
api-go - - - X - - - -> libs/auth go.mod replace
api-image - - - X - - - -> api-build Dockerfile COPY
orders-proto - - - X - - - -> shared-proto .proto import
-----> edge Nx records - X -> real dependency Nx never sees
change libs/auth, api-build or shared-proto
|
+--------------+--------------+
| |
v v
+--------------------------+ +--------------------------+
| nx affected | | consumer cache hash |
| consumer not selected | | excludes upstream input |
+--------------------------+ +--------------------------+
| |
v v
+--------------------------+ +--------------------------+
| not built or tested | | old build/test result |
| in CI | | replayed from cache |
+--------------------------+ +--------------------------+
Nx's built-in analysis records edges from JS/TS imports and package.json, but misses dependencies declared in go.mod, Dockerfiles and .proto files. As a result, nx affected under-selects consumers and their cache hashes omit the upstream project.
How a project graph plugin works: createNodes, createDependencies and the context object
A project graph plugin is a module listed in the plugins array of nx.json. Nx loads it while it builds the graph and calls whichever of two optional exports it finds. createNodes infers projects and their targets from files that match a glob. createDependencies adds edges between projects that already exist in the graph. This article is mostly about createDependencies, because a Go service or a Terraform module usually already has a project.json. You only need createNodes when those projects are not declared anywhere and Nx would otherwise never know they exist.
The createDependencies contract
Nx calls createDependencies with two arguments: the options you set for the plugin in nx.json, and a context object. The context exposes three things this article relies on:
- The projects Nx already knows about. Each entry includes the project's root, which you use to map a file path back to a project name.
- The file map. This lists the files that belong to each project, so you don't have to walk the filesystem yourself.
- The workspace root. Use it to resolve the relative paths in the files you parse.
The function returns an array of dependency objects. Each one has a source project, a target project and a type. A static edge also needs a sourceFile, which is the file that declares the relationship. Nx uses that file to decide which edges must be recomputed when the file changes.
The plugin API has changed across Nx major versions. The createNodes signature and some context fields have been renamed or reshaped. Before you write code, check the exact type names and field names against the @nx/devkit typings in your installed node_modules.
// tools/terraform-deps.ts, registered in nx.json:
// "plugins": [{ "plugin": "./tools/terraform-deps", "options": { "extension": ".tf" } }]
// Type and field names match recent @nx/devkit typings. Check them against your installed version.
import { readFileSync } from 'node:fs';
import { join, posix } from 'node:path';
import { CreateDependencies, DependencyType, RawProjectGraphDependency } from '@nx/devkit';
type TfDepsOptions = { extension?: string };
export const createDependencies: CreateDependencies<TfDepsOptions> = (options, context) => {
const ext = options?.extension ?? '.tf';
// Known projects: map a workspace-relative path back to the project whose root contains it.
const roots = Object.entries(context.projects).map(([name, cfg]) => ({ name, root: cfg.root }));
const ownerOf = (path: string) =>
roots
.filter((r) => path === r.root || path.startsWith(r.root + '/'))
.sort((a, b) => b.root.length - a.root.length)[0]?.name; // deepest root wins
const deps: RawProjectGraphDependency[] = [];
// File map: the files Nx already assigned to each project, so there is no filesystem walk.
for (const [source, files] of Object.entries(context.fileMap.projectFileMap)) {
for (const { file } of files) {
if (!file.endsWith(ext)) continue;
// Workspace root: resolve the relative path before reading the file.
const text = readFileSync(join(context.workspaceRoot, file), 'utf-8');
// Local module references, e.g. source = "../modules/network"
for (const m of text.matchAll(/source\s*=\s*"(\.{1,2}\/[^"]+)"/g)) {
const target = ownerOf(posix.normalize(posix.join(posix.dirname(file), m[1])));
if (target && target !== source) {
// Static edge: sourceFile tells Nx which file declares the edge.
deps.push({ source, target, type: DependencyType.static, sourceFile: file });
}
}
}
}
return deps;
};
A minimal createDependencies plugin uses the plugin options, the known projects, the file map and the workspace root to turn local Terraform module references into static edges, each with a sourceFile.
Implementing createDependencies for go.mod replace directives and Cargo path dependencies
Start by narrowing the input. The context passed to createDependencies includes a file map grouped by project, so iterate over each project's files and keep only those whose basename is go.mod or Cargo.toml. You never walk the filesystem, and Nx has already applied its ignore rules to that list. This matters because the function runs on every graph computation, including the ones triggered by editor integrations. A recursive directory scan would be slow on large repositories and would also pick up files that Nx deliberately excludes.
For go.mod, read the file and collect the right-hand side of every replace directive, in both the single-line form and the block form. Keep only targets that start with ./ or ../. Replacements that point to another module version are not local edges. For Cargo.toml, look in [dependencies], [dev-dependencies] and [build-dependencies] for entries that carry a path key. Resolve each path against the directory of the manifest that declared it, then normalize the result to a workspace-relative path with forward slashes.
To map a resolved directory to its owning project, compare it against every project root. A root matches if the directory equals the root or starts with the root followed by /. The separator check stops libs/api from matching libs/api-client. When several roots match, the longest one wins, so a project nested inside another project claims its own files. Drop any edge from a project to itself.
Emit each edge as a static dependency, with sourceFile set to the manifest's workspace-relative path. Nx uses that field to tie the edge to a specific file, so a change to the manifest invalidates the edge correctly.
Don't shell out to go list or cargo metadata. Both depend on toolchain versions and can touch the network. A pure text parse over file contents gives the same output for the same inputs, and that keeps graph caching reliable.
import { CreateDependencies, DependencyType, RawProjectGraphDependency } from '@nx/devkit';
import { readFileSync } from 'node:fs';
import { join, posix } from 'node:path';
// Local replace targets only: matches '=> ./x' or '=> ../x' in single-line and block form.
const LOCAL_REPLACE = /=>\s*(\.{1,2}\/[^\s]+)/g;
// Deepest match wins: the longest root equal to dir, or a prefix of dir followed by '/'.
function ownerOf(dir: string, roots: Array<[string, string]>): string | undefined {
let best: [string, string] | undefined;
for (const [name, root] of roots) {
const matches = dir === root || dir.startsWith(root + '/'); // 'libs/api' never matches 'libs/api-client'
if (matches && (!best || root.length > best[1].length)) best = [name, root];
}
return best?.[0];
}
export const createDependencies: CreateDependencies = (_options, ctx) => {
const roots = Object.entries(ctx.projects).map(([name, p]) => [name, p.root] as [string, string]);
const deps: RawProjectGraphDependency[] = [];
// Use the file map Nx already built (ignore rules applied); never walk the filesystem.
for (const [source, files] of Object.entries(ctx.filesToProcess.projectFileMap)) {
for (const { file } of files) {
if (posix.basename(file) !== 'go.mod') continue;
// Strip '//' comments so commented-out replaces do not create edges.
const text = readFileSync(join(ctx.workspaceRoot, file), 'utf8').replace(/\/\/.*$/gm, '');
for (const [, rel] of text.matchAll(LOCAL_REPLACE)) {
// Resolve against the manifest's directory, normalized to a workspace-relative posix path.
const dir = posix.normalize(posix.join(posix.dirname(file), rel)).replace(/\/$/, '');
const target = ownerOf(dir, roots);
if (!target || target === source) continue; // unowned path or self-edge
// sourceFile ties the edge to this manifest so editing it invalidates the edge.
deps.push({ source, target, type: DependencyType.static, sourceFile: file });
}
}
}
return deps;
};
A createDependencies sketch that filters Nx's file map to go.mod files, extracts local replace targets, maps each to its owning project by longest root match, and emits static edges with sourceFile set.
A second parser: Dockerfile COPY --from and proto imports, and how to handle lines you cannot resolve
…the plugin's output reproducible and cacheable.
Dockerfiles and .proto files need a different shape: a line scanner rather than a structured document parse. For a Dockerfile, collect every FROM ... AS <name> first, then read each COPY --from=<ref>. If <ref> matches a stage name in the same file, it is internal indirection and produces no edge. If it is an image reference, map it to a project only through an explicit table, such as image name to project name, rather than by guessing. For proto files, match import "<path>"; and resolve the path against the roots of the projects in context.projects.
Skip any line you cannot resolve with certainty, and log the reason: a ${VERSION} build arg, an ARG-driven stage name, or a proto import that points at a generated file absent from the workspace. Before returning an edge, check that the target exists in the context. If it is missing, drop the edge. An edge to an unknown project can make graph construction fail for the whole workspace. A missing edge only weakens nx affected for one project.
Hand-rolled scanners have limits. Dockerfiles allow line continuations and heredocs. HCL contains expressions and dynamic blocks, and TOML has multi-line arrays and inline tables. A regex covers the common case and quietly misses the rest. Some dependencies can't be modeled at all. A Terraform module from a registry, a value read from remote state, or an image pulled by a mutable tag is resolved at run time, outside the repository. A static graph built from file contents cannot represent these accurately, so don't approximate them with edges.
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { DependencyType, logger, type CreateDependencies, type RawProjectGraphDependency } from '@nx/devkit';
// Explicit image -> project table. Never infer a project from an image name.
const IMAGE_TO_PROJECT: Record<string, string> = {
'registry.example.com/platform/base-runtime': 'base-runtime',
};
export const createDependencies: CreateDependencies = (_options, context) => {
const deps: RawProjectGraphDependency[] = [];
for (const [source, files] of Object.entries(context.filesToProcess.projectFileMap)) {
for (const { file } of files) {
if (!file.endsWith('Dockerfile')) continue;
const lines = readFileSync(join(context.workspaceRoot, file), 'utf8').split('\n');
// Pass 1: collect stage names so COPY --from=<stage> counts as internal indirection.
const stages = new Set<string>();
for (const l of lines) {
const m = /^\s*FROM\s+\S+\s+AS\s+(\S+)/i.exec(l);
if (m) stages.add(m[1].toLowerCase());
}
// Pass 2: resolve COPY --from=<ref>; skip and log anything uncertain.
for (const line of lines) {
const ref = /^\s*COPY\s+.*--from=(\S+)/i.exec(line)?.[1];
if (!ref || stages.has(ref.toLowerCase()) || /^\d+$/.test(ref)) continue;
if (ref.includes('$')) { logger.verbose(`[docker-deps] ${file}: build arg in "${line.trim()}", skipped`); continue; }
const image = ref.split('@')[0].replace(/:[^/]*$/, ''); // drop tag or digest
const target = IMAGE_TO_PROJECT[image];
if (!target) { logger.verbose(`[docker-deps] ${file}: no mapping for ${image}, skipped`); continue; }
// Never emit an edge to a project that is not in the context.
if (!context.projects[target]) { logger.verbose(`[docker-deps] ${file}: unknown project ${target}, dropped`); continue; }
if (target !== source) deps.push({ source, target, type: DependencyType.static, sourceFile: file });
}
}
}
return deps;
};
A line-based Dockerfile scanner for createDependencies. It treats COPY --from stage references as internal, maps external images to projects only through an explicit table, skips and logs build-arg references, and drops any edge whose target is not in context.projects.
Choosing static, dynamic or implicit dependencies and how each affects nx affected and caching
pretend otherwise by emitting a guessed edge.
For edges you can resolve, the dependency type records why the edge exists. A static dependency names a sourceFile inside the source project, such as the go.mod containing the replace directive, the Dockerfile with COPY --from, or the .proto file with the import. Nx validates that this file belongs to the source project, so the edge is traceable to a line someone can read in review. An implicit dependency has no source file. Use it for workspace-level relationships, such as a shared base image every service is expected to build on. Dynamic dependencies exist for lazily loaded JavaScript imports and rarely fit these parsers. Don't use the dynamic type to mean "I'm unsure."
| Type | Requires sourceFile |
Fits | Typical risk |
|---|---|---|---|
| Static | Yes | replace paths, COPY --from, proto imports |
Parsing every import inflates fan-in |
| Implicit | No | Conventions no file declares | Easy to add and hard to audit later |
| Dynamic | Yes | Lazy JS imports | Misused as "maybe" |
nx affected walks dependents from touched projects regardless of edge type. Task hashes include dependency inputs wherever targets use ^-prefixed inputs. Every edge therefore widens two things: what runs, and what invalidates the cache.
Errors in each direction cost different amounts. A missed edge is silent: CI skips a service that consumes a changed proto, and you discover it in production. An over-eager edge is visible but corrosive. If you add an edge for every google/protobuf import or every shared base image, a one-line change fans out to most projects and cache hits collapse. Emit an edge only when a change to the target must change the source's output.
Registering, iterating and verifying the plugin
Register the plugin in nx.json under plugins. Use the object form, with a plugin key pointing at the module path and an options key carrying settings such as which file globs to scan or whether to follow replace directives that leave the repo. The same options object reaches createDependencies as its first argument, so behaviour changes go in configuration rather than in code.
Keep the development loop honest
Nx caches the computed project graph. The Nx daemon also keeps plugin code loaded between commands. After you edit the plugin, the next nx graph can still show edges from the old version. That looks like a parsing bug but is only a stale process. Run nx reset after every plugin change: it stops the daemon and clears the cache. Make it part of your edit-run script so you don't have to remember it.
Verify from three angles
nx graphshows whether the edges exist and point the right way.nx show project <name>confirms that the inferred targets and dependencies for a single project match what you expect.nx affected -t buildwith--baseand--headset around a commit that only touches a shared Go module or.protopackage should select every downstream consumer and nothing else. If it selects too little, an edge is missing. If it selects too much, an edge is over-broad.
For unit tests, call createDependencies directly with a fabricated context. Give it a small projects map and a file map pointing at fixture go.mod and .proto files, then assert on the exact edges it returns. Include a fixture that references a project that does not exist and assert that the function returns no edge for it. Nx validates edges against known projects, so emitting one for an unknown project can break graph construction for the whole workspace.
Alternatives, when to avoid a plugin, and the cost in engineering time
A plugin is not the only way to get those edges into the graph, and it is not always the right one. There are three realistic options:
| Approach | Where edges live | Who keeps them correct | Main risk |
|---|---|---|---|
| Do nothing | Nowhere | Nobody | nx affected skips projects and the cache serves stale results |
implicitDependencies in project.json |
Hand-written JSON | Whoever edits the source file, if they remember | The JSON drifts from go.mod, Dockerfiles and .proto imports |
createDependencies plugin |
Derived from the files themselves | The platform team, in code | Parser bugs, slow graph builds, upgrade work |
Move existing implicitDependencies into the plugin when the relationship is already written in a file the plugin parses, when it repeats across many projects, or when it changes often. Keep them hand-written when there are only a few stable links that no file expresses, such as a service that needs a shared fixtures directory.
The plugin is code the platform team owns. It needs tests, a named owner, documentation, and a check after every Nx upgrade, because the plugin API has changed between major versions. Nx runs it whenever the graph is recomputed, so a slow parser slows down every Nx command for every engineer.
In return, affected selects the right projects, you get fewer stale cache hits, and nobody has to maintain edges by hand.
Illustrative estimate (assumed numbers, not a benchmark). Monthly savings = missed-edge incidents × hours per incident + edge edits × minutes per edit ÷ 60. Assume 3 incidents at 4 h each, plus 20 edits at 10 min each: 12 + 3.3 ≈ 15 h/month. The plugin is worth building if that figure is clearly higher than its build cost plus ongoing maintenance.
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 nx createDependencies plugin, 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.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
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.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- 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.