What Nx adds to a Go monorepo, and who it is for
Nx does not compile Go code. In a Go monorepo it works as a task graph and cache layer on top of the toolchain you already use. go build, go test and go vet still do the work. Nx decides which of those commands to run, in what order, and whether a previous result can be reused instead of running the command again.
The nx-go plugin connects the two. At a conceptual level it does three things:
- Registers projects. It identifies Go projects in the workspace so Nx can treat each one as a node in its project graph.
- Adds tasks. It provides run, build, test and lint style tasks that wrap the standard
gocommands rather than reimplementing them. - Infers dependencies. It reads imports and module relationships to work out which projects depend on which. This lets Nx answer questions like "what does this change affect?"
nx-go is a community project. It is not part of Go and not part of Nx core. Its option names, defaults and setup steps have changed over time, and they may change again. Pin the plugin version, read its changelog before upgrading, and follow its README for current configuration instead of copying snippets from older posts, including this one.
The setup pays off for:
- teams running many Go services
- repos with shared internal libraries
- polyglot repos where Go sits next to frontend or tooling code
- teams whose CI time grows with every new package
The focus of this tutorial follows from that. Adding targets is easy. What determines whether Nx saves time or produces wrong results is getting three things right: project boundaries, cache inputs and graph edges.
Choosing a layout: one go.mod or a go.work of modules
Decide this before installing the plugin, because the layout sets what Nx treats as a project and how far a change spreads.
Single module
go.mod,go.sumcmd/api/cmd/worker/internal/auth/internal/billing/
There is one module, so module boundaries cannot separate projects. You declare projects by directory, usually with a project.json per directory. Nx then builds edges from package imports by matching each import path against the module path. This gives fine-grained edges. The cost is that go.mod and go.sum are inputs shared by every project. A single go get that bumps one transitive dependency changes those files, and every project becomes affected.
Workspace of modules
go.work,go.work.sumservices/api/go.modservices/worker/go.modlibs/auth/go.mod
Each go.mod is a natural project root. Edges come from require entries that point at sibling modules, which resolve locally through use (or replace) directives. A dependency bump stays inside one module unless you change go.work itself. If you edit go.work or go.work.sum, most of the repository is marked affected again.
| Aspect | Single go.mod |
go.work of modules |
|---|---|---|
| Project boundary | Directory you declare | Each go.mod |
| Edge source | Package imports | Module requires plus use/replace |
| Third-party bump affects | Every project | The modules that require it |
| Widest-blast-radius files | go.mod, go.sum |
go.work, go.work.sum |
| Ongoing cost | Low | Version and replace hygiene |
Pick multiple modules when teams upgrade dependencies independently. Stay with one module when lockstep upgrades are acceptable.
// go.work (repository root)
// Each `use` line makes a module a local workspace member,
// so Go resolves sibling requires from disk, not a proxy.
// Editing this file can mark most projects affected.
go 1.22
use (
./libs/auth
./services/api
./services/worker
)
// services/api/go.mod
// This module is its own project root.
module example.com/acme/services/api
go 1.22
require (
// Sibling module: this require creates the api -> auth edge.
example.com/acme/libs/auth v0.0.0
// Third-party dependency: bumping it changes only this
// module's go.mod and go.sum, so only api (and anything
// that depends on api) is affected.
github.com/google/uuid v1.6.0
)
// Optional: keeps builds working when the workspace is disabled
// (GOWORK=off), by pointing the sibling require at the local path.
replace example.com/acme/libs/auth => ../../libs/auth
A go.work file and one member go.mod showing how use, require and replace directives define the workspace layout's project boundaries and dependency edges.
How it works: inferred targets, overrides and the project graph
With the nx-go plugin, a Go project usually needs no project.json at all. The plugin scans the workspace, treats a directory with a go.mod (or a module listed in go.work) as a project, and generates targets such as test and lint. It adds build where it finds a main package. These are inferred targets. They are computed from the files on disk each time Nx builds its project graph. Explicit targets, by contrast, are declared by hand. Inference means a new service gets correct targets and cache settings the moment its module exists. Nobody has to copy a config file that later drifts.
To see what Nx actually resolved, run nx show project <name>. It prints every target with its command, inputs and outputs, and the --web flag opens the same view in a browser. nx graph shows how projects depend on each other. It is the quickest way to confirm that an import between modules became an edge.
When a default does not fit, add a project.json that names only the target you want to change. For example, you can add -race to test, or set an output path on build. Nx merges that entry into the inferred target with the same name, and every other target stays inferred.
+------------------------------------------+
| Local cache <--> Remote cache |
+------------------------------------------+
| Nx task graph + Nx cache |
+------------------------------------------+
| nx-go inference (projects, targets) |
+------------------------------------------+
| Go toolchain + GOCACHE |
+------------------------------------------+
Nx only wraps the toolchain, so go test ./... and go build keep working exactly as before. Engineers who never type nx lose nothing.
# Override only the targets that need it. Nx merges these entries into the
# inferred targets of the same name, and lint stays fully inferred.
cat > apps/api/project.json <<'EOF'
{
"name": "api",
"targets": {
"test": {
"options": {
"command": "go test -race ./...",
"cwd": "{projectRoot}"
}
},
"build": {
"options": {
"command": "go build -o {workspaceRoot}/dist/{projectRoot}/api .",
"cwd": "{projectRoot}"
},
"outputs": ["{workspaceRoot}/dist/{projectRoot}"]
}
}
}
EOF
# Print the merged result: the test command now includes -race, build lists
# the dist output, and the inferred inputs and other targets are unchanged.
nx show project api
# Same view in a browser, then the cross-project dependency graph.
nx show project api --web
nx graph
# Plain Go still works with no Nx involved; GOCACHE is used as usual.
cd apps/api && go test -race ./...
A minimal project.json that overrides only the inferred test and build targets for an api service, then uses nx show project and nx graph to verify the merged configuration.
Cache inputs, outputs and how Nx caching interacts with GOCACHE
Nx decides whether to run a task by hashing its inputs, so the input list has to describe everything the Go toolchain reads. For a Go project that means **/*.go, go.mod, go.sum, and at the workspace root go.work and go.work.sum. Two inputs are easy to miss. The first is files pulled in by //go:embed, such as templates, SQL migrations or static assets. The second is testdata/ directories. Leave generated or vendored output out of the hash unless you commit it. Otherwise every regeneration invalidates the cache.
Define a production named input that excludes **/*_test.go and testdata/, then point build at it. With that in place, editing only tests leaves build caches intact, while test still uses the full default set.
Toolchain state belongs in the hash too:
- Env inputs for
CGO_ENABLED,GOFLAGS,GOOSandGOARCH. - Whatever variable feeds your
-ldflagsversion stamp. - A runtime input for
go version. - Runtime inputs for external generators such as
protoc --version.
The reason is that a wrong cache hit is worse than a slow build. It ships a stale binary that looks green.
Declare outputs explicitly. Build with go build -o dist/... and list dist/... as the target's output. A task with no declared outputs only replays its logs on a cache hit, so no binary is restored.
| Nx cache | Go build/test cache (GOCACHE) |
|
|---|---|---|
| Skips | The entire task process | Per-package compilation and test runs |
| Scope | Local, or shared through a remote cache | Local to one machine and user |
| Hits when the other misses | A fresh CI runner with an empty GOCACHE |
A non-Go file in the Nx inputs changed but the compiled code did not |
| Misses when the other hits | GOCACHE is warm, but a declared input changed |
Never runs at all, because Nx skipped the task |
Set cache: false on integration tests that touch databases or networks. Their results are not deterministic, so a cached pass proves nothing.
{
"namedInputs": {
"goToolchain": [
"{workspaceRoot}/go.work",
"{workspaceRoot}/go.work.sum",
{ "runtime": "go version" },
{ "runtime": "protoc --version" },
{ "env": "CGO_ENABLED" },
{ "env": "GOFLAGS" },
{ "env": "GOOS" },
{ "env": "GOARCH" },
{ "env": "APP_VERSION" }
],
"default": [
"{projectRoot}/**/*",
"!{projectRoot}/dist/**/*",
"goToolchain"
],
"production": [
"default",
"!{projectRoot}/**/*_test.go",
"!{projectRoot}/**/testdata/**/*"
]
},
"targetDefaults": {
"build": {
"cache": true,
"inputs": ["production"],
"outputs": ["{projectRoot}/dist"]
},
"test": {
"cache": true,
"inputs": ["default"]
},
"integration-test": {
"cache": false
}
}
}
An nx.json that hashes Go sources, embed assets, testdata, go.work files and toolchain state, keeps _test.go edits out of build hashes, restores dist output on hits, and disables caching for integration tests.
Affected Go tests and polyglot dependency edges
Suppose you change libs/auth, a shared internal package. Run nx affected -t test --base=origin/main --head=HEAD. Nx maps the changed files to the auth project, then walks the project graph in reverse. Every service whose Go files import auth, directly or through another library, gets its test target scheduled. Services that never import it are skipped. The selection comes from import statements, not from directory proximity.
A change to go.mod or go.work behaves differently. The Go plugin usually treats these files as inputs to every Go project, because a dependency bump can change the behavior of any package that resolves through them. A one-line version change can therefore select most of the repository. That result is correct, but it is expensive.
Polyglot edges
In a polyglot monorepo, nothing in Go source imports a TypeScript project, and the reverse is also true. A shared OpenAPI or protobuf spec breaks this assumption. Give the spec its own project with a generate target, and declare its generated files as outputs so the target is cacheable. Then add implicitDependencies on the spec from both the Go service and the TS client, and set dependsOn so build and test run generate first.
Import-based inference also misses:
- cgo sources and C headers
- files selected by build tags or
GOOS/GOARCH go:generatesteps//go:embedassets- runtime-only links: config files, migrations, network contracts
Every missing edge carries the same risk. affected silently skips tests that should have run, and CI still passes.
# 1. The spec gets its own project with a cacheable generate target.
# Declared outputs let Nx restore the generated clients from cache.
cat > libs/api-spec/project.json <<'EOF'
{
"name": "api-spec",
"targets": {
"generate": {
"executor": "nx:run-commands",
"options": { "command": "./scripts/gen-clients.sh libs/api-spec/openapi.yaml" },
"inputs": ["{projectRoot}/openapi.yaml", "{workspaceRoot}/scripts/gen-clients.sh"],
"outputs": ["{workspaceRoot}/services/orders/gen", "{workspaceRoot}/apps/web/src/gen"],
"cache": true
}
}
}
EOF
# 2. The Go service has no import of the spec, so declare the edge by hand.
# "^generate" runs generate on dependencies (now including api-spec) first.
# apps/web/project.json gets the same implicitDependencies and dependsOn.
cat > services/orders/project.json <<'EOF'
{
"name": "orders",
"implicitDependencies": ["api-spec"],
"targets": {
"build": { "dependsOn": ["^generate"] },
"test": { "dependsOn": ["^generate"] }
}
}
EOF
# 3. Confirm the edge exists, then check what a spec-only change selects.
nx show projects --affected --base=origin/main --head=HEAD
nx affected -t test --base=origin/main --head=HEAD
A shared OpenAPI spec modeled as its own project with a cacheable generate target, linked to a Go service through implicitDependencies and dependsOn so that a spec change selects that service's tests in nx affected.
Implementing CI and validating the setup
Affected detection is a git diff, so CI needs enough history to contain the base commit. Many CI systems clone shallowly by default. When the base commit is missing, nx affected either errors out or falls back to a comparison you didn't intend, and the second case is worse because the job still passes. Fetch full history, or at least enough to reach the merge base. Pass --base and --head explicitly. On pull requests, use the merge base with the target branch. On the main branch, use the last commit that passed CI, not HEAD~1. After a failed or skipped run, HEAD~1 drops changes from the comparison, and too few projects get selected.
Run nx affected -t lint test build with those refs. Persist the Nx cache directory and GOCACHE between jobs using your CI system's cache mechanism, keyed on the branch with a fallback to main. A remote cache shares results across machines, but it requires setup: storage, credentials, and eviction. It also raises a trust question. Any writer can poison artifacts that other jobs will reuse, so restrict writes to protected branches.
Keep two safety nets in place:
- Run every target on all projects nightly to catch edges the graph misses.
- Run a small set of integration tests on every change, regardless of what
affectedreports.
Validation checklist
- Run the same target twice. The second run should be a cache hit.
- Edit a file that should be an input, such as
go.sumor an embedded asset. The next run should be a cache miss. - Compare
go list -deps ./...for a service againstnx graph. Any internal package missing from the graph is a missing edge.
When to avoid it, alternatives, and impact on cost and engineering time
Nx is the wrong tool for a single service or a handful of packages. There, go test ./... already finishes quickly, and Go's test cache skips unchanged packages. Go-only teams that do not want Node.js and a package manager in every dev environment and CI image will find the extra toolchain costs more than it saves. If most changes touch a shared root go.mod, almost every project is affected on every PR, so affected selection buys nothing.
The costs continue after setup:
- Plugin dependency. The nx-go plugin is community-maintained, so its support for new Nx releases can lag behind Nx core.
- Drift.
project.jsonoverrides are a second description of project structure. They drift from the Go module layout unless CI checks them. - Habits. Engineers have to run tasks through Nx instead of calling
godirectly, or they bypass the graph.
On one developer machine, Nx adds little on top of GOCACHE. The real savings come from skipping whole CI jobs and from a shared remote cache.
Illustrative estimate (assumed numbers, not a benchmark):
- Assumed: 30 services, 6 CI minutes each, 3 services affected per PR on average, 200 PRs per month.
- Formula: saved minutes = PRs × (services − affected) × minutes per service.
- Result: 200 × (30 − 3) × 6 = 32,400 runner-minutes per month.
- Subtract the Nx install and graph computation time that every CI run now pays.
| Option | Setup effort | Affected correctness | Remote caching | Polyglot support |
|---|---|---|---|---|
| Plain go tooling with test cache | None | Per package, local only | No | No |
| Makefile/script change detection | Low | Path-based, often misses dependencies | No | Manual |
| Bazel with rules_go | High | Hermetic and precise | Yes | Strong |
| Other monorepo task runners | Medium | Depends on Go graph support | Often | Varies |
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.