How Nx computes a cache key and why generated code goes stale
When Nx runs a cacheable target, it computes a hash and looks for a stored result under that key. The hash combines:
- Project files matched by the target's
inputs, usually expanded from named inputs such asdefaultorproduction. - Dependency inputs, which come from projects that Nx knows about in the project graph (the
^prefix). - External dependencies that Nx can resolve, such as npm packages the project imports.
- Runtime and environment inputs, which are command outputs and environment variables that you declare explicitly.
If the hash matches, Nx restores the stored terminal output and the declared outputs. It does not run the target. Nx never checks whether those files would still be correct. It only checks whether the declared inputs changed. Suppose a build consumes generated code but its inputs never mention the schema. In that case, editing schema.prisma or a .proto file leaves the hash unchanged, and Nx restores a build compiled against the old types.
| Failure mode | Why the hash misses it |
|---|---|
| Gitignored generated files | Nx skips ignored files when it hashes project files |
Prisma Client in node_modules |
The output sits outside every project root that Nx hashes |
.proto files in a shared project with no graph edge |
^ inputs only follow dependencies that are declared or inferred |
| Generator upgrade, same sources | A protoc binary or plugin outside the package graph is never hashed |
In each case, the cache is doing exactly what it was told. These are input-modeling bugs. The way to fix them is to describe the inputs correctly, not to turn caching off.
Who needs a dedicated codegen target and when to use one
Describing them correctly is worth the effort only in some repos.
When a dedicated codegen target pays off
The setup in this article is for backend and platform teams running Nx monorepos where Prisma clients or Protobuf stubs are generated during the build. It matters most when several projects consume the same generated code. A shared .proto package might feed three services and a gateway, or one Prisma schema might back an API and a worker. In those repos a stale artifact does not stay in one place. It flows into every dependent build and test result.
Remote caching raises the stakes. A bad local cache entry costs one developer an afternoon. A bad remote entry gets replayed to every machine and CI runner that computes the same hash, and nothing about it looks wrong until runtime.
When to skip it
Skip the extra target if you have a single small project that commits its generated code and reviews it in pull requests. Skip it if the generator is cheap enough to run inline before every build without anyone noticing. Also be honest about upkeep. Narrow inputs must be maintained when schema paths, plugins or output directories change. A team that will not keep them current is safer with broader inputs, or with no caching on codegen at all, than with precise inputs that silently drift.
The core pattern
Treat codegen as its own target. Its inputs are the schema files plus the generator version, and nothing else. Its outputs are declared explicitly so Nx can restore them from cache. Every build, test or typecheck that consumes the generated code declares a dependency on it, so it always runs first or is restored first.
┌────────────────────────┐ ┌────────────────────────┐
│ schema files │ │ generator version │
│ schema.prisma, *.proto │ │ prisma / protoc plugin │
└───────────┬────────────┘ └───────────┬────────────┘
│ inputs (nothing else) │
└───────────────┬───────────────┘
▼
┌───────────────────────────┐ ┌──────────────┐
│ codegen target │ │ Nx cache │
│ hash(schema + gen ver) │◄──────►│ local/remote │
│ outputs: declared dirs │ │ restore/store│
└─────────────┬─────────────┘ └──────────────┘
│ consumers depend on codegen:
│ it runs or is restored first
┌───────────────┴────────┬────────────────────────┐
▼ ▼ ▼
┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐
│ api:build │ │ worker:test │ │ gateway:typecheck │
│ Prisma client │ │ Prisma client │ │ proto stubs │
└────────────────────┘ └────────────────────┘ └────────────────────┘
The codegen target hashes only the schema files and generator version, stores and restores its declared outputs through the local or remote Nx cache, and every consuming build, test or typecheck depends on it.
Defining codegen targets with namedInputs, outputs and dependsOn
Start in nx.json. Under namedInputs, define prismaSchema as {projectRoot}/prisma/**/*.prisma. Define protoSources as {projectRoot}/**/*.proto plus {projectRoot}/buf.yaml and {projectRoot}/buf.gen.yaml. In targetDefaults, give prisma-generate the inputs ["prismaSchema"] and proto-gen the inputs ["protoSources"]. Mark both cacheable and set outputs to a directory inside the workspace, such as {projectRoot}/src/generated. Keeping the inputs this narrow means an edit to a TypeScript service file does not trigger regeneration. Nx can only restore output paths that are declared and that sit inside the workspace.
Next, wire the consumers. In the generating project, build, test, typecheck and lint each get dependsOn: ["prisma-generate"]. Projects that import another project's protobuf code use "^proto-gen", which runs the target in upstream dependencies.
On its own, dependsOn only controls ordering. Generated directories are usually gitignored, and ignored files never enter the project file hash. To make the generated code part of the cache key, add { "dependentTasksOutputFiles": "**/*.ts", "transitive": true } to the consumers' inputs. With that in place, a change in generated output invalidates every downstream result.
Then check your production named input. If it is an allowlist such as {projectRoot}/src/**/*, or if it negates non-source files, it can drop schema.prisma and .proto files. That removes them from ^production hashes in downstream projects. Add prismaSchema and protoSources to production explicitly.
These edges have a cost. Every consumer now waits on generation, so on a cold cache the critical path grows by the codegen runtime at each level of the dependency graph.
{
"namedInputs": {
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"sharedGlobals": [],
"prismaSchema": ["{projectRoot}/prisma/**/*.prisma"],
"protoSources": [
"{projectRoot}/**/*.proto",
"{projectRoot}/buf.yaml",
"{projectRoot}/buf.gen.yaml"
],
"production": [
"{projectRoot}/src/**/*",
"!{projectRoot}/**/*.spec.ts",
"prismaSchema",
"protoSources"
]
},
"targetDefaults": {
"prisma-generate": {
"cache": true,
"inputs": ["prismaSchema"],
"outputs": ["{projectRoot}/src/generated/prisma"]
},
"proto-gen": {
"cache": true,
"inputs": ["protoSources"],
"outputs": ["{projectRoot}/src/generated/proto"]
},
"build": {
"cache": true,
"dependsOn": ["prisma-generate", "^proto-gen", "^build"],
"inputs": [
"production",
"^production",
{ "dependentTasksOutputFiles": "**/*.ts", "transitive": true }
]
}
}
}
An nx.json sketch with narrow codegen inputs, in-workspace outputs, schema files kept in production, and a build default whose dependsOn and dependentTasksOutputFiles put generated code into the hash; repeat the build pattern for test, typecheck and lint.
Putting the generator version into the hash
The file globs from the previous section only capture what you feed the generator. The generator itself also determines the output: upgrading prisma changes the emitted client even when schema.prisma hasn't changed. If nothing in the hash reflects that upgrade, Nx will restore a client built by the old version.
For generators installed through npm, add an externalDependencies input listing the packages that drive generation. That means prisma and @prisma/client for Prisma, and the plugin packages for Protobuf, such as the protoc-gen-* or buf packages. Nx hashes the versions resolved in the lockfile, so a version bump invalidates the cache and unrelated dependency changes do not. If you use buf, also include buf.gen.yaml as a file input, because it pins remote plugin versions and options.
When the generator is a system binary, such as a protoc installed on the CI image, the lockfile does not know about it. Use a runtime input that runs the tool's version command, so the printed version becomes part of the hash. This has two costs. Nx spawns that process whenever it computes the hash. And it is easy to forget: if someone builds a new CI image without that binary, or with a different version, the input changes or fails. Installing the tool through npm avoids both problems.
Nondeterministic output is a separate problem. Some generators write timestamps or tool versions into file headers, or emit declarations in an unstable order. If those files are inputs to downstream tasks, every regeneration changes their hash and causes needless cache misses. If you commit the generated code, it also produces noisy diffs. The fix depends on the tool. Check whether it has an option to suppress headers or sort its output. If it doesn't, normalize the files in a post-generation step that runs inside the same target.
{
"name": "db",
"root": "libs/db",
"targets": {
"prisma-generate": {
"command": "prisma generate --schema=libs/db/prisma/schema.prisma",
"inputs": [
"{projectRoot}/prisma/schema.prisma",
{ "externalDependencies": ["prisma", "@prisma/client"] }
],
"outputs": ["{projectRoot}/src/generated/prisma"],
"cache": true
},
"proto-generate": {
"command": "protoc --plugin=node_modules/.bin/protoc-gen-ts_proto --ts_proto_out=libs/db/src/generated/proto -I libs/db/proto libs/db/proto/*.proto",
"inputs": [
"{projectRoot}/proto/**/*.proto",
{ "externalDependencies": ["ts-proto"] },
{ "runtime": "protoc --version" }
],
"outputs": ["{projectRoot}/src/generated/proto"],
"cache": true
}
}
}
A project.json where the Prisma target hashes the lockfile versions of prisma and @prisma/client, and the Protobuf target hashes the ts-proto package version plus the output of protoc --version, because protoc is a system binary installed on the CI image.
Prisma and Protobuf specifics: output paths, shared proto projects and committed code
Prisma: generate into the project
By default the Prisma client is written into node_modules. Nx does not treat that location as a project output, so it cannot restore it from cache. Set the generator's output field in schema.prisma to a directory inside the project, such as src/generated/prisma, and declare that directory in the target's outputs. Imports then change from the package name to a relative path or a TypeScript path alias. Point every import at the new location. A leftover import of the package name can still resolve to an older client somewhere in node_modules.
This has costs. Editors and language servers may index both locations and offer the wrong import. When several projects share one schema, give generation to a single library project and have the others depend on it. Generating the same schema twice into different folders makes it easy for one copy to fall behind the other.
Protobuf: a dedicated proto project
Put the .proto files in their own Nx project. Each consuming service declares a dependency on it and includes ^production, or another named input that covers the dependency's files, in its codegen inputs. A change to a shared message then changes the hash of every consumer through the project graph. Without that dependency, nothing in a service's own file set changes, so its cached output stays valid even though the contract changed.
Committed vs. generated at build time
| Aspect | Committed code | Generated at build time |
|---|---|---|
| Cache inputs | Generated files are ordinary source | Schema files plus generator version |
| Review noise | Large diffs on schema changes | Only schema diffs |
| Drift risk | Hand edits or a skipped regeneration | Low |
| Required safeguard | CI regenerates and fails on git diff |
Correct inputs and outputs |
Committing generated code makes it visible in review and removes the need for the generator at build time. The trade-off is that you need a CI step that regenerates the code and fails if the result differs from what was committed.
{
"name": "db-client",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/db-client/src",
"projectType": "library",
"targets": {
"prisma-generate": {
"executor": "nx:run-commands",
"cache": true,
"inputs": [
"{projectRoot}/prisma/schema.prisma",
{ "externalDependencies": ["prisma", "@prisma/client"] }
],
"outputs": ["{projectRoot}/src/generated/prisma"],
"options": {
"command": "prisma generate --schema=libs/db-client/prisma/schema.prisma"
}
},
"build": {
"dependsOn": ["prisma-generate"]
}
}
}
A single library project owns Prisma generation. The cache key covers the schema and the Prisma package versions, and the in-project output directory is declared so Nx can restore it on a cache hit.
Verifying the setup and making CI with remote caching safe
The same discipline applies to the cache configuration itself: test it before you trust it.
Verifying the inputs
Start from a clean, cached build. Edit only schema.prisma or a single .proto file, then rebuild. The codegen target and every target that consumes its output should miss the cache. If a downstream target hits, its inputs do not trace back to the schema. Usually a dependsOn or ^ input is missing. Revert the edit. Then bump the Prisma or protoc plugin package in the lockfile and confirm another miss. That proves the generator version is part of the hash.
When a result surprises you, check the hash details Nx records for the task, and use nx graph to confirm the dependency edges you expect. The hash details show exactly which files, external dependencies and runtime values were included. That is faster than guessing. Finally, run the build with --skip-nx-cache and diff the generated directories against the cached artifacts. Any difference means something unhashed is influencing generation.
CI checklist
- No postinstall generation. Codegen must run as a target so Nx can see it. Developers who expect a usable client right after install will hit errors, so document the new first command or add a setup script.
- Outputs declared. Without declared outputs, a cache hit restores nothing and leaves old files on disk.
- Environment modeled. If generation reads
DATABASE_URLor similar, either add it as an input or make sure the generated code does not depend on it. - Deliberate reset. Fixing the inputs only changes future hashes. Stale entries already in the remote cache can still be served for unchanged hashes. Plan to purge the remote cache through your provider, and to run
nx resetlocally.
Impact on cost and engineering time
The choice is between paying in compute and paying in attention. Broad inputs, such as hashing the whole project or workspace, rarely produce stale output but miss the cache whenever unrelated files change. Narrow inputs, such as the schema, the proto files and the generator version, hit the cache far more often. However, every change to the codegen setup must also update the inputs, or you are back to stale builds.
| Broad inputs | Narrow inputs | |
|---|---|---|
| Cache miss rate | High | Low |
| Risk of stale generated code | Low | Low only if inputs are kept current |
| Ongoing maintenance | Minimal | Update inputs when generators, plugins or paths change |
| Typical failure mode | Slow CI | Wrong code served quickly from cache |
Illustrative estimate (assumed numbers, not a benchmark).
Formula: wasted compute per day = runs per day × miss rate × (codegen time + consumers × consumer rebuild time)
Assumed inputs: 200 runs per day, 40 s for codegen, and 6 consumers that each take 60 s to rebuild.
- Broad inputs at a 50% miss rate: 200 × 0.5 × (40 + 360) = 40,000 s, or about 11 hours.
- Narrow inputs at a 10% miss rate: 200 × 0.1 × 400 = 8,000 s, or about 2.2 hours.
Most of the savings come from the consumers, not from codegen itself. Each avoided miss saves the downstream rebuilds that it would have triggered.
The engineering-time side is harder to measure, but it often matters more. A single stale Prisma client can cost an afternoon of debugging, because the type errors or runtime mismatches point away from the cache. Narrow inputs shift that cost into small, routine reviews whenever someone changes the codegen setup. When those reviews are missed, you pay the occasional larger cost of cleaning up a poisoned remote cache: you purge the entries, invalidate the affected hashes and rerun the pipelines.
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 cache generated code, 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.