How Nx sees a Go service and why you would use it
Nx does not compile Go. It decides which tasks need to run, runs them in dependency order, and replays cached results when the inputs have not changed. The Go toolchain still does everything else: go build resolves imports, go test runs tests, and go vet checks code. Nx only sees the commands you give it, the files you declare as inputs and the artifacts you declare as outputs.
To Nx, a Go service is a project: a directory with a project.json that names it and lists its targets. Without a Go-specific plugin, each target uses the generic nx:run-commands executor. A build target sets options.command to something like go build -o dist/apps/orders ./cmd/orders and sets cwd to the module root. A test target runs go test ./..., and a vet target runs go vet ./.... Running nx build orders then executes that command. Nx records the exit code, terminal output and declared outputs so a later run with identical inputs can restore them instead of running the command again.
The main reason to adopt this is consistency in a polyglot Nx workspace. If TypeScript frontends and Node services already use Nx, adding Go projects gives every language one task runner, one cache and one nx affected command for CI. A change to a shared Go package can trigger rebuilds of the services that depend on it while unrelated projects are skipped. This fits platform teams that run Go alongside other languages. A Go-only repository gets less from it, which a later section covers.
{
"name": "orders",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"sourceRoot": "apps/orders",
"targets": {
"build": {
"executor": "nx:run-commands",
"cache": true,
"inputs": ["{projectRoot}/**/*.go", "{projectRoot}/go.mod", "{projectRoot}/go.sum"],
"outputs": ["{workspaceRoot}/dist/apps/orders"],
"options": {
"command": "go build -o ../../dist/apps/orders ./cmd/orders",
"cwd": "apps/orders"
}
},
"test": {
"executor": "nx:run-commands",
"cache": true,
"inputs": ["{projectRoot}/**/*.go", "{projectRoot}/go.mod", "{projectRoot}/go.sum"],
"options": {
"command": "go test ./...",
"cwd": "apps/orders"
}
},
"vet": {
"executor": "nx:run-commands",
"cache": true,
"inputs": ["{projectRoot}/**/*.go", "{projectRoot}/go.mod", "{projectRoot}/go.sum"],
"options": {
"command": "go vet ./...",
"cwd": "apps/orders"
}
}
}
}
A minimal apps/orders/project.json that wraps go build, go test and go vet with nx:run-commands, declaring Go sources and module files as cache inputs and the built binary as the build output.
Module layout: one root go.mod or per-service modules with go.work
The choice that shapes everything after it is where module boundaries sit, because Nx can only infer dependencies that Go itself expresses.
One root go.mod
Every service and library lives in one module, and imports are plain package paths. Nx maps each directory to a project and derives edges from import statements. Setup is simple, but the root go.mod and go.sum are shared inputs. Bumping one service's logging library rewrites go.sum, and every Go project that lists it as an input, which is the usual configuration, is marked affected. The blast radius of a dependency change is the whole Go side of the repository.
Per-service modules with go.work
Each service and shared library has its own go.mod. A go.work file at the root ties them together so local builds resolve sibling modules from disk. A dependency bump now touches only one module's go.sum, so affected detection stays narrow. The cost is ongoing maintenance. New modules must be added to go.work. Each module declares its own requirement on shared libraries, so you need a policy for keeping those versions aligned. CI must also choose between building in workspace mode and building each module standalone.
| Concern | Root go.mod | Per-service go.mod + go.work |
|---|---|---|
| Nx project boundary | Directory within one module | Module directory |
| Third-party dependency bump | All Go projects affected | Only the owning module affected |
| Shared library versions | Always in lockstep | Can drift unless enforced |
| Ongoing maintenance | Low | go.work and version alignment |
Choose a root module if services ship together. Choose per-service modules when teams release independently.
Building a correct project graph for Go imports
Nx builds its project graph from static analysis of JavaScript and TypeScript imports plus whatever plugins report. It does not parse .go files. If services/billing imports libs/money, Nx has no edge between them unless you declare one, and nx affected will skip billing when money changes.
There are two ways to declare these edges.
Hand-maintained implicitDependencies. In each service's project.json, list the shared libraries it imports. This is explicit and has no extra tooling, but it drifts without warning. When someone adds an import and forgets the entry, Nx under-builds: the changed library's consumers are skipped, their tests never run, and a broken service passes CI. Over-building only costs minutes. Under-building ships defects.
A community nx-go plugin that reads Go imports and infers edges. This removes the drift problem. Before adopting one, check its recent maintenance activity and confirm it supports your Nx release. Treat it as one more dependency that has to keep up whenever you upgrade Nx.
Place shared code where Go's internal/ rule allows it to be imported. A package under internal/ is importable only from within the tree rooted at the parent of internal/. Shared libraries must therefore sit outside any single service's internal/ directory.
Tag projects type:service and type:shared. The rule is that services may depend only on shared libraries and never on each other. Nx's module-boundary lint rule checks JS/TS imports only, so it will not catch Go violations. To enforce the rule for Go, export the graph in CI with nx graph --file=graph.json and fail the build on any service-to-service edge. That check is only reliable if the graph is complete, which is one more reason to prefer inferred edges.
#!/usr/bin/env bash
# CI guard: fail the build if any Go service depends on another service.
# Only as reliable as the graph: missing implicitDependencies mean missing edges.
set -euo pipefail
# Export the full project graph (nodes, tags, dependency edges) as JSON.
npx nx graph --file=graph.json
# Collect every service->service edge.
violations=$(jq -r '
.graph as $g
# Lookup table of projects tagged type:service.
| ([ $g.nodes | to_entries[]
| select(any((.value.data.tags // [])[]; . == "type:service"))
| { (.key): true } ] | add // {}) as $isService
# Keep edges whose source and target are both services.
| $g.dependencies | to_entries[]
| select($isService[.key])
| .value[]
| select($isService[.target])
| "\(.source) -> \(.target)"
' graph.json)
if [[ -n "$violations" ]]; then
echo "Services must depend only on type:shared libraries. Found:" >&2
echo "$violations" >&2
exit 1
fi
echo "No service-to-service dependencies found."
A CI script exports the Nx project graph with nx graph --file=graph.json and uses jq to fail the build on any edge between two type:service projects, which Nx's JS/TS-only module-boundary lint rule cannot catch for Go.
Cache inputs and outputs that match how Go resolves code
Treat inputs as part of the same contract: the hash Nx computes for a target has to change whenever Go would compile different code.
Named inputs
Define a go named input covering {projectRoot}/**/*.go, {projectRoot}/go.mod and {projectRoot}/go.sum. Use the ^ prefix so the same set is included from every local dependency. Add environment inputs for GOOS, GOARCH and CGO_ENABLED. These change the output without touching a single file.
Getting the input set wrong fails in two directions:
- Missing input: a change to it leaves the hash unchanged, so Nx replays an old binary and reports success. This is a stale cache hit.
- Overly broad input: an input like
{projectRoot}/**/*invalidates the build on README edits. A missing^scope does the opposite and misses changes to shared libraries.
Outputs
For build, declare the binary path, such as {workspaceRoot}/dist/{projectName}, as the output so Nx can restore it on a cache hit. Test targets produce no artifact worth storing, so Nx caches only the terminal output and the pass/fail result.
How the Nx cache and the Go cache interact
Nx's cache is coarse. Any changed hashed file invalidates the whole target. Go's build and test cache in GOCACHE is content-addressed per package, so a miss in Nx still recompiles only the packages that changed, provided GOCACHE is warm.
Limits of hashing
Nx hashes only what you declare. Three common gaps:
- A toolchain bump goes unnoticed unless you add a runtime input like
go version. - An undeclared environment variable is ignored.
- Generated code that is gitignored is excluded from hashing.
{
"namedInputs": {
"go": [
"{projectRoot}/**/*.go",
"{projectRoot}/go.mod",
"{projectRoot}/go.sum"
],
"goProduction": ["go", "!{projectRoot}/**/*_test.go"],
"goEnv": [
{ "env": "GOOS" },
{ "env": "GOARCH" },
{ "env": "CGO_ENABLED" },
{ "runtime": "go version" }
]
},
"targetDefaults": {
"build": {
"cache": true,
"inputs": ["goProduction", "^goProduction", "goEnv"],
"outputs": ["{workspaceRoot}/dist/{projectName}"]
},
"test": {
"cache": true,
"inputs": ["go", "^goProduction", "goEnv"]
}
}
}
An nx.json fragment that hashes Go sources, go.mod and go.sum for each project and its local dependencies (^), adds GOOS, GOARCH, CGO_ENABLED and go version as inputs, and declares the binary as the only build output.
Worked example: edit a shared library and run affected builds
Take a workspace with four projects: api and worker are services that both import libs/auth, and billing-cli imports nothing from the workspace. Before editing anything, run nx graph and confirm that edges point from api and worker to auth, and that billing-cli has no edges to them. If the graph is wrong at this stage, every later result will be wrong too.
Change a function body in libs/auth and run nx affected -t build test. Nx diffs the working tree against the base ref, maps the changed files to auth, and then follows reverse edges to find api and worker. The task summary should list build and test for auth, api and worker. It should list nothing for billing-cli. Run the same command again without changes and all six tasks should come from the cache. That confirms the inputs are stable between runs.
Breaking it on purpose
You can see under-building in two ways, and they fail differently:
- Remove the edge. Exclude the dependency with
implicitDependencies: ["!auth"]onworker, or point its import at a module path the plugin cannot resolve.nx affectedthen leavesworkerout entirely, even thoughgo buildwould compile the changed code into it. - Remove the input. Leave the edge in place, but narrow
worker's build inputs to its own files by dropping the^dependency inputs.workeris still reported as affected. However, its hash does not change, so Nx restores the old binary from the cache.
The second failure is harder to notice. The task output shows worker running, but a cache hit on a project whose dependency just changed means the build is stale.
# 1. Check the graph before changing anything.
# Expect api -> auth and worker -> auth, and no edges to or from billing-cli.
nx graph --file=graph.json
nx show projects --affected --base=main # should print nothing on a clean tree
# 2. Change shared library code (placeholder file path).
echo '// trivial change to trigger affected detection' >> libs/auth/token.go
# 3. Nx maps the changed file to auth, then follows reverse edges.
nx show projects --affected --base=main # expect: auth, api, worker (not billing-cli)
nx affected -t build test --base=main # 6 tasks run: build+test for each of the three
# 4. Run again with no new edits. All 6 tasks should be cache hits.
nx affected -t build test --base=main
# --- Break it on purpose ---------------------------------------------------
# A) Remove the edge. In apps/worker/project.json add:
# "implicitDependencies": ["!auth"]
# worker now disappears from the affected list, even though
# `go build` would still compile the changed auth code into it.
nx show projects --affected --base=main # expect: auth, api <-- under-building
# B) Restore the edge, then narrow worker's build inputs instead. In project.json:
# "targets": { "build": { "inputs": ["{projectRoot}/**/*"] } }
# Removing the "^" dependency inputs means auth changes no longer
# affect worker's hash.
echo '// another change' >> libs/auth/token.go
nx affected -t build --base=main
# worker is listed, but its build is a cache hit. Treat that as the
# warning sign: the restored binary was built from the old auth code.
# Compare worker's configured inputs with the expected ones.
nx show project worker --json
This script checks the project graph, edits libs/auth, and confirms that only auth, api and worker rebuild and then hit the cache on a second run; it then removes either the dependency edge or the ^ dependency input to show the two different kinds of under-building.
Running affected Go builds in CI with warm caches
In CI the cost of that kind of mistake is a release, not a confusing log line, so the pipeline has to give Nx exact refs and enough history to compare them.
Explicit refs and fetch depth
Run nx affected -t build --base=origin/main --head=HEAD and pass the base ref explicitly. Many CI systems clone with depth 1 by default. Nx computes affected projects by diffing from the merge base of base and head. If that commit is not in the local clone, there is nothing to diff against. Depending on how the job is configured, the result is an error or a fallback that treats everything as affected. Fetch the base branch first with git fetch origin main. Then deepen the clone with git fetch --deepen=<n>, or use git fetch --unshallow on large histories when you cannot predict the depth. Confirm with git merge-base origin/main HEAD before calling Nx.
Two cache layers
Persist three directories between runs: Nx's cache directory, go env GOCACHE, and go env GOMODCACHE. Nx skips a task entirely when its inputs hash to a known result. When a task does run, because a shared library changed, Go still reuses compiled packages that did not change from GOCACHE. It also avoids downloading modules again from GOMODCACHE. Nx decides whether to run a task. Go reduces the cost of the tasks that run.
Shared cache trust
A remote cache restores artifacts without re-checking them. A poisoned or wrong artifact is restored as if it were a real build. Give write access to CI only, and give developer machines read-only tokens.
When to avoid Nx for Go, alternatives, and impact on cost and engineering time
Nx pays off when the dependency graph is wide and full builds are slow. A team that writes mostly or only Go also takes on a Node.js runtime, package.json, nx.json and per-project configuration, and a plugin that has to keep pace with both Nx and Go releases. That is a second toolchain in CI images and on every laptop, and someone has to own it. Go's own build cache already skips recompiling unchanged packages, so Nx's main contribution is skipping whole tasks and sharing results between machines.
| Approach | What you get | What you pay |
|---|---|---|
| Nx with a Go plugin | Project graph, local and remote task cache, affected |
Node.js toolchain, config upkeep, plugin upgrade risk |
go work plus Make |
Go-native setup with no extra runtime | No remote task cache, no affected detection |
git diff plus go list -deps script |
Affected detection that maps changed files to dependent packages | You maintain the script, and there is no artifact cache |
The ongoing costs are the initial setup, keeping dependency edges and cache inputs accurate, and testing each plugin upgrade before rolling it out. A missing input is worse than a slow build because it produces a stale cache hit that looks like a pass.
Illustrative estimate (assumed numbers, not a benchmark)
Hours saved per month = PRs × (full minutes − affected minutes) × share of that time a developer spends waiting ÷ 60
- Assumed inputs: 400 PRs, a 20-minute full run, an 8-minute affected run, 50% waiting share. That gives 400 × 12 × 0.5 ÷ 60 = 40 hours. Set that against an assumed 6 hours of monthly upkeep, and Nx is clearly ahead.
- Same assumptions with a smaller repo: a 4-minute full run and a 2-minute affected run give about 6.7 hours. That roughly equals the upkeep, so a
go listscript is the better choice.
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 go monorepo, 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.