How Nx turns dependsOn into a task graph
If you run a monorepo with a dozen services and shared libraries, you have probably seen a build fail because a library hadn't been compiled yet. Nx prevents this by building a task graph before it runs anything.
It starts from two inputs. The project graph records which projects depend on which, inferred from imports and declared dependencies. The dependsOn setting on each target says what must finish before that target starts. A task is one target in one project, such as api:build. Nx expands every requested task into the tasks it depends on, and those into theirs, until the graph is complete.
dependsOn can express two kinds of dependency:
- Same project:
testdepends onbuildof the same project, so tests run against fresh artifacts. - Same-named target in dependency projects:
builddepends onbuildof every project this one depends on, so upstream code compiles first.
Nx then schedules the graph in topological order. A task starts only after all of its prerequisites succeed. Any tasks with no dependency between them can run at the same time, up to the configured parallel limit.
lib-db:build lib-auth:build <- no edge between them: run in parallel
\ /
\ /
v v
api:build <- waits for both libraries
|
v
api:test <- same-project dependency on api:build
Nx never infers order from command-line position or from target names. If the edge isn't in the graph, Nx treats the two tasks as independent and may run them concurrently.
+--------------------------+ +--------------------------------+
| Project graph | | dependsOn |
| api --> lib-db | | build: ["^build"] (deps) |
| api --> lib-auth | | test: ["build"] (same) |
+------------+-------------+ +---------------+----------------+
| |
+------------------+------------------+
|
v
Task graph (api:test requested)
Wave 1 +----------------+ +------------------+
parallel | lib-db:build | | lib-auth:build |
+-------+--------+ +--------+---------+
| |
+-------------+-------------+
| ^build: wait for both libs
v
Wave 2 +---------------+
| api:build |
+-------+-------+
| build: same-project dependency
v
Wave 3 +---------------+
| api:test |
+---------------+
Nx combines the project graph with each target's dependsOn: "^build" points at build in dependency projects, "build" points at the same project. It then runs the resulting task graph in topological waves, so the two library builds, which have no edge between them, can run in parallel.
build vs ^build: same-project and upstream target dependencies
A dependsOn entry without a caret refers to a target on the same project. "dependsOn": ["build"] on api:test means "run api:build first." A caret prefix changes the scope to upstream projects. "^build" means "run the build target of every project this one depends on in the project graph." Nx derives that project graph from imports and declared package.json dependencies, not from the task configuration.
The caret form is the usual default for build because compilation follows the dependency direction. An app that imports libs/auth cannot bundle until auth has emitted its output, and auth may in turn wait on libs/util. A single "^build" entry covers that whole chain recursively. You don't have to list each library, and the order stays correct as projects are added. A plain "build" here would only point the target at itself, which is meaningless for upstream ordering.
| Entry | Schedules before the current task |
|---|---|
"build" |
build on the same project |
"^build" |
build on every direct and transitive dependency |
"lint" |
lint on the same project, unrelated to the graph |
Object with target and dependencies: true |
Same as the caret form, with room for extra options |
Object with target and projects |
That target on explicitly named projects |
The object form handles cases the shorthand can't express. Examples include depending on a project that has no import relationship with this one, or passing parameters through to the upstream task. The field names and accepted values have changed across Nx releases, so check them against the documentation for the Nx version you have installed before relying on them.
{
"targetDefaults": {
"build": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["build"]
},
"package": {
"dependsOn": [
{ "target": "build", "dependencies": true }
]
},
"e2e": {
"dependsOn": [
"build",
{ "target": "build", "projects": ["api"] }
]
}
}
}
An nx.json targetDefaults block where build waits on upstream builds via ^build and test waits on its own project's build. package uses the object form with dependencies: true, and e2e also builds a named project with no import link; verify these fields for your Nx version.
Implementing the pipeline once in nx.json targetDefaults
Start with nx.json and nothing else. Under targetDefaults, the example in this section defines four entries keyed by target name:
builddepends on^build, so every library a project imports is compiled first.testdepends on^build. If your test runner resolves sibling libraries through their built output rather than their source, make it depend onbuildinstead.linthas nodependsOn, because it only reads source files and can start immediately.deploydepends onbuildandtestin the same project, so a deploy only runs on an artifact whose tests have passed.
Nx applies these defaults to every project that has a target with a matching name, whether that target is declared in project.json or inferred from package.json scripts. Configuration at the project level takes precedence. For dependsOn, a project-level array generally replaces the default instead of being appended to it. An override must therefore restate every dependency the project still needs, or those dependencies are silently dropped. Confirm the merge behavior against the docs for your installed version.
Override dependsOn only when a project actually differs from the rest. A typical case is a service whose test target needs a generated client produced by its own build. JSON has no comments, so record the reason somewhere people will see it. Use a target description or metadata field if your Nx version supports one, or add a short note in the project README. If an override has no stated reason, nobody can tell whether it is still needed, and it tends to stay in place indefinitely.
The payoff is that a newly generated project gets the correct build, test and deploy ordering without anyone touching it. Reviewers don't need to check pipeline wiring on every new package. They only need to look at overrides, and each override comes with its reason.
# Write the shared pipeline once in nx.json (JSON allows no comments, so they live here).
cat > nx.json <<'EOF'
{
"targetDefaults": {
"build": { "dependsOn": ["^build"], "cache": true },
"test": { "dependsOn": ["^build"], "cache": true },
"lint": { "cache": true },
"deploy": { "dependsOn": ["build", "test"] }
}
}
EOF
# build: compile imported libraries first. test: same, swap to "build" if tests read built output.
# lint: reads source only, no dependsOn. deploy: own build and passing tests first.
# One project differs: its tests need a client generated by its own build.
# A project-level dependsOn REPLACES the default, so restate ^build as well.
# metadata.description records why. Check that your Nx version supports it, or use the README.
cat > apps/billing-api/project.json <<'EOF'
{
"name": "billing-api",
"targets": {
"test": {
"dependsOn": ["build", "^build"],
"metadata": {
"description": "Override: tests import the API client generated by this project's build."
}
}
}
}
EOF
# Inspect the merged result before trusting it.
npx nx show project billing-api --json
# Run deploy everywhere. Nx orders ^build -> build -> test -> deploy per project.
npx nx run-many -t deploy
This sets up a single nx.json targetDefaults pipeline and a documented per-project dependsOn override that restates ^build, because project-level arrays replace the defaults instead of being added to them.
Modeling deploy without cascading side effects
Deploy is the one target where the upstream caret pattern works against you. If deploy declared ^deploy, running it for an API service would first deploy every library in its dependency graph. Most libraries have no deploy target, and the ones that do (a shared schema package that publishes to a registry, for example) should ship on their own schedule. The correct shape is a same-project dependency: deploy depends on build and test in its own project. Because build already carries ^build, the upstream libraries still get compiled. The app ships an artifact built from current sources, and no library performs a release as a side effect.
Deploy should also be excluded from caching, either by setting cache to false in its target default or by leaving it out of whatever cacheable-target list your setup uses. Caching assumes a target is a pure function of its inputs. Deploy changes state outside the workspace: a cluster, a bucket, a registry. If Nx sees matching inputs and replays a cached success, it reports a deploy that never ran. That failure is hard to notice, because the logs look normal and the environment stays stale.
Sometimes deploy belongs outside Nx altogether. If you need manual approval gates, promotion of the same artifact from staging to production, or one-click rollback, your CI/CD system already models those steps and Nx does not. The main alternative to an all-in-Nx pipeline is to use Nx only for build and test ordering and affected detection, then let the CI/CD system take the built artifacts and sequence the deploys itself.
{
"$schema": "./node_modules/nx/schemas/nx-schema.json",
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"cache": true
},
"test": {
"dependsOn": ["build"],
"cache": true
},
"deploy": {
"dependsOn": ["build", "test"],
"cache": false
}
}
}
An nx.json targetDefaults block where build pulls in upstream builds via ^build, while deploy depends only on its own project's build and test (no ^deploy, so libraries are never deployed as a side effect) and sets cache to false so Nx cannot replay a deploy that never ran.
How dependsOn interacts with inputs, outputs and caching
dependsOn only controls when a task runs. Whether the downstream task finds the right files depends on inputs and outputs. When Nx gets a cache hit for an upstream build, it replays the terminal output and restores only the files matching that target's outputs globs. If outputs points at dist/libs/shared but the build actually writes to libs/shared/dist, the cache hit succeeds and nothing lands where the consumer expects. The downstream build or test then fails with missing modules on a clean CI agent. On a developer laptop it may instead pick up leftover files from an older run. The task graph is correct, but the artifacts are wrong.
The example below pairs dependsOn: ["^build"] with inputs: ["production", "^production"] and an outputs entry built from {options.outputPath} or {projectRoot}/dist. The ^production input matters because it makes a project's hash include its dependencies' production sources. When a library changes, every consumer's hash changes with it, so the ordering that dependsOn expresses carries through to the cache.
Inaccurate inputs cause the subtler failures. If the build reads an environment variable, a root-level tsconfig.base.json or a generated schema that inputs does not list, changing that value leaves the hash unchanged. Nx then serves an artifact built under the old conditions. The result looks reproducible but is not: two machines report the same hash for different binaries. Declare these dependencies explicitly, for example with { "env": "API_URL" } or a {workspaceRoot}-prefixed file pattern. Inputs that are too broad have the opposite effect: they cause unnecessary cache misses but never produce wrong results.
Verifying the pipeline and spotting common misconfigurations
Before trusting the pipeline, check what Nx actually resolved, not what you think you configured. Open nx graph and switch to the task view for a target such as build. It shows every task Nx would schedule and the edges between them, so a library build missing from beneath an application build is easy to spot. Next, run a single target with --verbose, for example nx run api:build --verbose. The output lists the dependent tasks Nx scheduled and whether each was a cache hit, which confirms both ordering and caching in one run. In CI, compare nx affected -t test against a change you understand. If you edit one leaf library and twenty unrelated projects run their tests, a dependency rule is too broad.
| Misconfiguration | Symptom | Fix |
|---|---|---|
test depends on ^test |
Testing one project runs the test suites of every upstream library | Have test depend on ^build only if it needs compiled artifacts, or on nothing |
Missing ^ (build instead of ^build) |
Applications compile against stale or absent library output | Use ^build so upstream projects build first |
| Circular target dependencies | Nx reports a cycle and refuses to run | Break the loop. Two targets should not each require the other, directly or through a chain |
| Plugin-inferred targets | dependsOn differs from nx.json for no obvious reason |
Run nx show project <name> to see the merged target, then override it explicitly |
How dependsOn and targetDefaults are merged has changed across Nx releases. Check these patterns against the docs for your installed version.
Trade-offs, when to centralize, and impact on cost and engineering time
A blanket test → ^build rule is correct when tests import compiled output, such as a package consumed through its dist entry point. It is wasteful when the test runner resolves workspace source through path aliases. In that case every test run first rebuilds upstream libraries it never reads. Centralizing in targetDefaults also hides ordering: someone reading project.json sees no dependsOn and assumes there is none. Each exception becomes a per-project override, and overrides drift as the defaults change underneath them.
Errors are asymmetric. Over-declaring serializes the graph, which wastes CI time on every run but is easy to see. Under-declaring creates races. These pass locally because a warm cache already holds upstream outputs, and they fail intermittently in clean CI. That is the more expensive failure.
| Approach | Consistency | Visibility in project.json | Drift risk | Fits |
|---|---|---|---|---|
targetDefaults first |
High | Low | Overrides accumulate | Many similar projects |
Per-project dependsOn everywhere |
Low | High | Copies diverge | Few, heterogeneous projects |
| CI-defined ordering | Only in CI | None | Local and CI differ | Avoid |
| Hybrid (defaults plus documented overrides) | High | Medium | Managed | Most monorepos |
Illustrative estimate (assumed numbers, not a benchmark): extra CI minutes per month = unnecessary upstream builds per run × minutes per build × runs per month. With 4 builds × 1.5 min × 600 runs, that is 3,600 minutes. Compare that against debugging time = race incidents × hours per incident. Assume 3 incidents × 4 hours, or 12 engineer-hours. Plug in your own rates to decide which side to err on.
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 dependsOn targetDefaults, 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.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
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.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- 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.