How Nx turns dependsOn into a task graph
In Nx, a task is a target run on a project, such as lib:build or service:test. On its own, Nx knows which projects depend on which, but not which tasks must wait for others. dependsOn supplies that. Each entry declares an edge: "before this task runs, that task must finish." Nx combines these edges with the project graph, which it builds from imports and package dependencies, to compute a task graph. It then runs every task whose prerequisites are complete, in parallel where it can.
Project graph Task graph
service lib:build
| |
v v
lib service:build
|
v
service:test
|
v
service:deploy
Here service:build declares ^build, so it waits on the build of every project service depends on. service:test depends on service:build, and service:deploy depends on service:test. If another library is unrelated to service, its build can run alongside this chain.
Undeclared ordering is not guaranteed. If test silently relies on build output and you never declared that edge, it may pass locally and fail in parallel CI runs.
This matters most for backend and platform engineers running multi-project monorepos. Shell scripts and CI step order encode sequence by hand. They run everything serially or need manual parallelism, and they drift as projects are added. A declared graph stays correct as the repo grows. It parallelizes automatically and lets Nx work out the minimum set of tasks a change actually requires.
Project graph dependsOn edges
(imports, package deps) (per target)
+---------+ +---------------------+
| service | | build: ["^build"] |
+---------+ | test: ["build"] |
| | deploy: ["test"] |
v +---------------------+
+---------+ |
| lib | |
+---------+ |
| |
+---------------+----------------+
v
+-------------------+
| Nx: compute task |
| graph, schedule |
+-------------------+
|
+---------------+----------------+
v v
+-----------+ +-------------+
| lib:build | | other:build |
+-----------+ +-------------+
| no edge: runs in
v parallel with chain
+---------------+ +--------------+ +----------------+
| service:build |--->| service:test |--->| service:deploy |
+---------------+ +--------------+ +----------------+
Nx combines the project graph with the edges declared in dependsOn to build a task graph, then runs lib:build, service:build, service:test and service:deploy in order while an unrelated build runs in parallel.
How it works, step by step
When you run a command such as nx run-many -t test or nx affected -t deploy, Nx goes through roughly these stages:
- Build the project graph. Nx analyzes source imports, package manifests and any
implicitDependenciesto work out which projects depend on which. - Resolve each project's targets. The configuration for a target comes from the project's own
project.jsonorpackage.json, fromtargetDefaultsinnx.json, and from any plugins that infer targets. The result is whatnx show project <name>prints. - Expand the requested tasks. Starting from the tasks you asked for, Nx follows every
dependsOnentry. Plain names add tasks on the same project,^entries add tasks on dependency projects, and namedprojectsadd tasks on those projects. It repeats this until no new tasks appear. The result is the task graph. A cycle stops the run with an error. - Hash and check the cache. For each task, Nx computes a hash from its declared
inputsand the hashes of the tasks it depends on. If that hash matches a local or remote cache entry, the task is restored rather than executed. - Schedule. Nx runs every task whose prerequisites have finished, up to the
--parallellimit. If a task fails, its dependents do not run.
With nx affected, an extra step happens first. Nx compares the working tree against a base commit, marks the changed projects and everything that depends on them as affected, and only requests tasks for those projects.
Who should use dependsOn pipelines
- Teams with a monorepo of interdependent projects. If libraries must be compiled before the services or apps that import them,
^buildencodes that once instead of in every script. - Platform and CI owners. They spend time keeping pipeline step order in sync with the code. A declared graph moves that ordering into the repository, where it is reviewed alongside the projects it describes.
- Projects with code generation. When OpenAPI clients, Prisma types or protobuf stubs must exist before build and test, a
generateedge makes sure every entry point produces them, including a developer running a single test target. - Teams whose CI time grows with the repo. Ordering, parallelism, caching and affected detection all rely on the same graph. Adding
dependsOnedges is what makes the rest of these features safe to use.
dependsOn pipelines are a poor fit in two cases. One is a repository with one or two packages and a fixed build order. The other is when your main need is release orchestration: environment promotion, approvals or rollbacks. That belongs in a CD system, as discussed in the deploy section below.
dependsOn syntax: same-project targets, ^ cross-project edges and the object form
Each entry in a target's dependsOn array describes one kind of edge, and the string form encodes the edge type in a single character.
A plain target name such as "build" points at the same project. If test lists "build", Nx schedules that project's own build first. Use this when one step consumes another step's output inside a single package, for example tests that run against compiled code.
A caret prefix such as "^build" points at the project's dependencies. Nx adds a build task for every project this one depends on, as inferred from imports and package manifests or declared explicitly. This rule is why libraries compile before the apps that consume them, and new libraries are covered as soon as they are added.
The object form states the same intent with named fields:
| Form | Meaning |
|---|---|
"build" |
build on the same project |
"^build" |
build on every dependency project |
{"target": "build"} |
Same as "build" |
{"target": "build", "dependencies": true} |
Same as "^build" |
{"target": "build", "projects": ["api"]} |
build on the named project api only |
A projects list hard-codes an edge that the project graph cannot see. One case where this is justified is an e2e target that needs a specific backend built, but where that backend is never imported. Prefer declaring a real project dependency where you can, because named edges do not update when projects are renamed or split.
The options available in the object form have changed across Nx releases. Check the documentation for your installed version before relying on a specific field.
// apps/web/project.json (Nx parses comments in project.json)
{
"name": "web",
"targets": {
"build": {
// Build every library that web depends on before web itself
"dependsOn": ["^build"],
"executor": "nx:run-commands",
"options": { "command": "tsc -p apps/web/tsconfig.app.json" }
},
"test": {
// Tests run against compiled output, so build this project first
"dependsOn": ["build"],
"executor": "nx:run-commands",
"options": { "command": "node --test dist/apps/web" }
},
"e2e": {
"dependsOn": [
// Object form, same meaning as "build"
{ "target": "build" },
// Object form, same meaning as "^build"
{ "target": "build", "dependencies": true },
// web calls api over HTTP and never imports it, so the graph
// has no edge. Name the project explicitly. This edge does not
// follow renames or splits of api.
{ "target": "build", "projects": ["api"] }
],
"executor": "nx:run-commands",
"options": { "command": "node scripts/run-e2e.js" }
}
}
}
This project.json shows a same-project edge (test depends on build), a ^build edge to dependency projects, the equivalent object forms, and one named-project edge to an api backend that web never imports, so the project graph cannot infer it.
Setting pipeline rules once in targetDefaults
Rather than repeating dependsOn in every project.json, put the pipeline rules in the targetDefaults block of nx.json. Each key is a target name, and the rule applies to every project that defines that target.
A typical layout:
builddepends on^build, so a library is always compiled before the projects that import it.testdepends onbuildonly when tests run against compiled output, such as integration tests that loaddist/. If your test runner compiles from source, this edge adds wait time and protects nothing.lintgets nodependsOn. It reads source files only, so it can start immediately and run in parallel with everything else.generate(codegen from OpenAPI specs, Prisma schemas or protobufs) is listed as a prerequisite ofbuildonly in the projects that need it, or as a default if most projects do.
When a project defines its own dependsOn for a target, do not assume it is merged with the default. In practice, a project-level array typically replaces the default array. A project that adds generate to its build target can therefore silently drop ^build. If you want both, restate both in the project.
To see what Nx will actually run, check the resolved configuration with nx show project <name> instead of reading either file on its own.
That check matters because of the main trade-off with targetDefaults. They keep configuration DRY, but someone reading a single project.json cannot see the edges that apply to it. Add a short comment or README note pointing to nx.json, so reviewers know where to look for those rules.
cat > nx.json <<'EOF'
{
"targetDefaults": {
"build": { "dependsOn": ["^build"] },
"lint": {}
}
}
EOF
cat > apps/api/project.json <<'EOF'
{
"name": "api",
"targets": {
"generate": { "command": "prisma generate" },
"build": { "dependsOn": ["generate", "^build"] },
"test": { "dependsOn": ["build"] }
}
}
EOF
nx show project api --json | jq '.targets.build.dependsOn'
nx show project api --json | jq '.targets.test.dependsOn'
Pipeline defaults are set in nx.json targetDefaults. A project that overrides build restates ^build next to generate, because the project-level array replaces the default instead of merging with it, and nx show project confirms the dependsOn edges Nx resolves.
Modeling deploy as a target, and when to stop at verified artifacts
A deploy target is ordinary configuration: give the service a deploy target whose dependsOn lists build and either its own test or a dedicated verify target that bundles tests, lint and image scanning. Set cache: false on it. A cache hit replays logs and restores outputs, but it does not push anything, so a cached deploy reports success without doing anything.
The same reasoning applies to every target upstream of deploy. If build also uploads an image or runs a migration, that side effect is skipped on a cache hit, and nothing warns you. Keep cacheable targets pure: they read inputs and write files. Put anything that changes the outside world in non-cacheable targets.
Avoid ^deploy unless your libraries really publish something, such as packages to a registry. For most libraries, ^deploy either does nothing or invents a deployment step for code that never runs on its own.
Deploy-as-target has real limits. dependsOn orders tasks within a single run. It cannot express staging-before-production, manual approvals or rollbacks. It ties releases to repository tooling and whatever credentials the machine running it holds. Affected detection only sees tracked inputs, so a service whose runtime config changed elsewhere can be skipped.
| Concern | Deploy as Nx target | Nx builds, CD deploys |
|---|---|---|
| Environment promotion | Not modeled | Native |
| Approvals and rollback | Scripted by hand | Built in |
| Credentials | On the runner | Scoped to CD |
| Out-of-repo config changes | Can be missed | Tracked by CD |
For anything beyond simple setups, stop at verify. Have Nx produce tested, versioned artifacts, and let a separate CD system promote them through environments.
{
"name": "orders-api",
"targets": {
"build": {
"executor": "nx:run-commands",
"cache": true,
"dependsOn": ["^build"],
"outputs": ["{workspaceRoot}/dist/apps/orders-api"],
"options": { "command": "node tools/build.mjs apps/orders-api" }
},
"test": {
"executor": "nx:run-commands",
"cache": true,
"dependsOn": ["build"],
"options": { "command": "node tools/test.mjs apps/orders-api" }
},
"verify": {
"executor": "nx:run-commands",
"cache": true,
"dependsOn": ["test", "lint"],
"options": { "command": "node tools/scan-image.mjs dist/apps/orders-api" }
},
"deploy": {
"executor": "nx:run-commands",
"cache": false,
"dependsOn": ["build", "verify"],
"options": { "command": "node tools/deploy.mjs apps/orders-api --env staging" }
}
}
}
A project.json where build, test and verify stay cacheable and side-effect free, while deploy depends on build and verify, uses cache: false, and avoids ^deploy.
Caching, inputs and outputs: what makes skipping tasks safe
dependsOn only decides order. Whether a task actually runs is decided by its computation hash, which comes from the task's inputs. On a cache hit, Nx skips execution, replays the terminal output and restores the files matched by outputs. Skipping is only safe if those two lists match what the task really reads and writes. dependsOn assumes the upstream artifacts exist once the upstream task reports success. It does not check.
Here is how that assumption fails. A shared library's build emits to dist/libs/shared, but its outputs is declared as {projectRoot}/dist. Locally nothing looks wrong, because the files from the last real run are still on disk. On a fresh CI agent pulling from a remote cache, shared:build is a hit: logs replay, the task shows as successful, and nothing is restored to dist/libs/shared. Then api:build runs via ^build and fails on missing modules. On a long-lived agent it is worse. api compiles against whatever stale shared output is left over, and the tests pass against old code. The fix is to declare {workspaceRoot}/dist/libs/shared as the output. Apply the same check to inputs: if a build reads an environment variable or a root config file that isn't listed, the hash won't change when that value changes.
Illustrative estimate: assume a 10-project chain at 3 minutes per build. Accurate config turns an unchanged-graph CI run from roughly 30 minutes into seconds of cache restores. One stale-artifact bug that looks like a logic error can easily cost an afternoon of debugging. That is more time than many uncached runs would have taken.
Inspecting the graph and running it in CI
Before touching CI, check what Nx thinks the pipeline is. nx graph shows project dependencies. Adding --graph to a run command, such as nx run-many -t deploy --graph, renders the task graph for that target without executing it. Use it as a dry run: every dependsOn edge appears as an arrow, so a missing ^build shows up as a gap. During a real run, --verbose prints more detail when a task fails or behaves unexpectedly.
Once the graph looks right, collapse hand-ordered CI steps into one invocation on the top-level targets, such as nx affected -t test deploy. Nx pulls in prerequisites, orders them, and skips cached work.
--parallel sets the maximum number of concurrent tasks. It never overrides declared edges. That creates a trade-off:
- Over-declaring serializes work. If
testdepends on^buildeverywhere, unit tests that only need source files wait for every upstream build. - Under-declaring creates races. A missing edge often passes with
--parallel=1and fails intermittently at higher parallelism, so test locally with the same setting CI uses.
| Approach | Ordering source | Maintenance cost | Engineering time impact |
|---|---|---|---|
| Shell scripts | Manual sequence | Grows with every new project | Full rebuilds; ordering bugs hidden in scripts |
| CI step ordering | Pipeline YAML | Duplicates the dependency graph | Coarse parallelism; no per-task skipping |
dependsOn |
Project graph plus rules | Declared once in nx.json |
Only affected, uncached tasks run |
Alternatives to dependsOn pipelines
The table above compares the most common options. In more detail:
- Hand-written scripts. A root script calls each package's build in a fixed order. This is easy to read and has no tooling dependency. However, someone has to update the order whenever a dependency changes. Scripts usually run serially, and they rebuild everything unless you add your own change detection.
- CI pipeline step ordering. Jobs or stages in the CI configuration encode the sequence, and parallelism comes from splitting jobs. This works well when there are a few large units. It becomes a second copy of the dependency graph that can drift from the code, and its granularity is the job, not the task.
- Package manager workspace scripts. Many workspace-aware package managers can run a script across packages in dependency order. They give you topological ordering with little setup. Typically, they don't offer per-task edges like "test needs this project's build," content-hashed caching, or affected detection.
- General-purpose build systems. Tools that model every build action with declared inputs and outputs can provide stronger correctness and caching guarantees than Nx. They usually require rewriting build logic in their own rule language, which is a much larger migration.
- A CD system for release ordering. For deploys specifically, a CD system is often a better choice than any task runner, because it handles promotion, approvals and rollback. A common split is that Nx orders and caches build, test and verify, and CD takes over from the artifacts.
Choose dependsOn when you already use Nx, or are willing to adopt it, and want the ordering to come from the project graph rather than a separately maintained list.
How dependsOn saves engineering time
The time savings come from four places, and each one depends on the edges being declared correctly:
- No ordering maintenance. With
^buildintargetDefaults, a new library is ordered correctly the moment another project imports it. No one edits a script or CI file to slot it in. - Automatic parallelism. Nx schedules every task as soon as its prerequisites finish. Independent branches of the graph run at the same time without anyone hand-splitting CI jobs.
- Skipping unaffected and cached work.
nx affectedlimits the run to projects touched by a change and their dependents. The cache then skips any task whose inputs haven't changed. - Fewer ordering-related failures. Explicit edges remove the "works locally, fails in parallel CI" class of bugs, which are expensive to diagnose because they look intermittent.
Illustrative estimate, with assumed inputs: a repository has 20 projects, and each build takes an assumed 2 minutes when run serially. A change touches one library consumed by 4 services. A script that rebuilds everything does 20 builds, about 40 minutes. With nx affected and ^build, 5 builds run: the library first, then the 4 services in parallel if enough agents or cores are available. That works out to roughly 4 minutes of wall-clock time, plus cache-restore overhead for the rest. Your numbers will differ. The savings shrink if most changes touch a widely shared library, and they disappear if inputs or outputs are wrong and force reruns or cause stale-artifact failures.
Common patterns and a debugging checklist
Worked patterns
Generated code. If build and test both read files produced by a generate target (OpenAPI clients, Prisma types, protobuf stubs), add generate to both targets' dependsOn in targetDefaults. Then a developer who runs only test still gets fresh types, and caching keeps the generator from re-running when its inputs haven't changed.
Shared library, many services. With "dependsOn": ["^build"] on build, changing one library rebuilds it once, and the services that consume it then build in parallel. You don't maintain a per-service script.
Integration tests. If the tests need a built dependency, ^build on the test target is enough. If they need a running service, keep that out of dependsOn. A task that never exits blocks the pipeline. Start the service in the test target's own setup or in a CI step before the run.
Debugging checklist
- Circular task dependency: Nx refuses to run and names the cycle. Break it by splitting a target, for example moving codegen out of
build. - Missing target name:
^buildsilently skips dependencies that lackbuild. Check that the expected tasks actually appear innx graph. - Missing project edge: a dependency Nx can't infer from imports, such as a runtime-loaded config, needs
implicitDependencies. - Green locally, red in CI: stale
dist/files are hiding a missing edge. Delete the outputs, runnx reset, and rerun with--skip-nx-cache.
When it's worth it
Adopt dependsOn pipelines once you have several projects with real build-order dependencies and CI time you want to cut. A repository with two packages and one build order is served fine by a short script.
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 task pipeline, 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.