How Nx turns the project graph and dependsOn into a task graph
In Nx, a task is a single project-target pair, such as orders-api:build or shared-db:test. Running a task pipeline means Nx computes which tasks must run, in what order, and which can run in parallel.
That computation starts from the project graph. Nx builds it by analyzing source imports between projects, dependencies declared in package manifests, and any implicitDependencies you list for relationships the analysis cannot see, such as a service that reads a generated schema file. The project graph only states that one project depends on another. It says nothing about which targets must finish first.
dependsOn supplies that ordering. When a target declares dependsOn, Nx adds edges between tasks, and the result is the task graph. Here, orders-api imports two libraries, and build depends on the builds of its dependencies:
Project graph Task graph (build)
orders-api orders-api:build
├── shared-db ├──> shared-db:build ───┐
└── shared-auth └──> shared-auth:build │
└── shared-db └────────────────> shared-db:build
Nx runs shared-db:build first, then shared-auth:build, then orders-api:build. It never runs a task before the tasks it depends on have finished.
This matters in CI for three reasons. If an edge is missing, a service can compile or bundle against a stale or absent library output, and the failure may only appear on a cold runner. If you add edges that aren't needed, independent tasks run one after another instead of in parallel. Caching is also tracked per task, so a correct graph determines which tasks can be restored from cache and which must be rebuilt.
┌─────────────────────────┐ ┌──────────────────────────────────────┐
│ Inputs │ │ Project graph (projects only) │
│ source imports │─────>│ │
│ package manifests │ │ orders-api ──> shared-auth │
│ implicitDependencies │ │ │ │ │
└─────────────────────────┘ │ └──> shared-db <┘ │
└──────────────────┬───────────────────┘
│
build.dependsOn: ["^build"] adds task edges │
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ Task graph (target: build) A ──> B means A waits for B │
│ │
│ orders-api:build ──> shared-auth:build ──> shared-db:build │
│ │ ▲ │
│ └──────────────────────────────────────────┘ │
│ │
│ Run order: shared-db:build, shared-auth:build, orders-api:build │
│ Missing edge: stale output. Extra edge: lost parallelism. │
└──────────────────────────────────────────────────────────────────────────┘
Nx builds the project graph from imports, manifests and implicitDependencies, and dependsOn turns it into a task graph whose edges determine run order, parallelism and caching.
How it works, step by step
When you run a command such as nx build orders-api or nx run-many -t build, Nx goes through the same sequence each time:
- Resolve configuration. For every project, Nx combines targets inferred by plugins, the matching entries in
targetDefaultsinnx.json, and the targets declared in the project's ownproject.jsonorpackage.json. The result is the effective configuration for each target, including itsdependsOn,inputsandoutputs. - Build the project graph. Nx analyzes imports and manifests and adds any
implicitDependenciesto record which projects depend on which. - Expand the requested tasks. Starting from the tasks you asked for, Nx follows each
dependsOnentry. A plain target name adds a task in the same project. A^entry adds that target on every dependency project in the project graph. It repeats this recursively until no new tasks appear. The resulting set of tasks and edges is the task graph. - Schedule. Nx starts every task whose dependencies have all finished, up to the parallelism limit set by
--parallelor the workspace configuration. When a task finishes, any task waiting only on it becomes eligible to run. - Check the cache. Before running a cacheable task, Nx computes a hash from the files and values described by its
inputs, plus the hashes of the tasks it depends on. On a match, Nx restores the files listed inoutputsand replays the terminal output instead of running the command.
dependsOn affects steps 3 and 4. inputs and outputs affect step 5. The rest of this article covers how to configure each one so the resulting graph matches what your build actually needs.
Who should use dependsOn and targetDefaults
Not every Nx workspace needs careful pipeline configuration. It pays off when the following conditions apply:
- Workspaces with buildable libraries. If libraries have their own
buildtarget and consumers read the compiled output instead of the source, the order of builds matters, and^buildis how you express that ordering. - Teams with several services sharing code. Backend monorepos where multiple APIs, workers or jobs import shared database, auth or messaging libraries gain the most from a single pipeline definition in
targetDefaults. Each new service picks up the right ordering without extra configuration. - Workspaces with code generation. Prisma clients, OpenAPI or protobuf clients, and other generated sources need to exist before compile and typecheck.
dependsOnmakes that step part of the graph instead of a manual pre-step. - Engineers who own CI. Platform and build engineers who maintain CI pipelines use these settings to replace hand-ordered script steps with one task graph that Nx schedules, parallelizes and caches.
- Reviewers of pipeline changes. Anyone approving changes to
nx.jsonor aproject.jsonoverride needs to understand the merge rules, because a single overridingdependsOnarray can quietly remove an edge.
If your workspace has a few applications and libraries that are consumed only as source through path aliases, with no separate library build, you may only need a small targetDefaults block. The detailed patterns later in this article matter less in that case.
build vs ^build vs the object form of dependsOn
Each entry in dependsOn adds edges to that task graph, and the syntax you choose determines whether those edges stay inside one project or follow the project graph outward.
A plain string such as "build" points at a target in the same project. A test target with dependsOn: ["build"] builds that project first and nothing else. Use it when one target consumes another target's output within a single package, such as a docker target that needs the compiled dist folder.
The caret form "^build" means "run build on every project this one depends on, first." This gives correct cross-library build order. When a service imports a shared library, Nx builds the library before the service, using edges taken from the project graph. You don't have to keep a manual list in sync with your imports.
The object form handles cases the strings can't express. It can name specific projects, opt into dependency projects explicitly, or control how CLI arguments propagate. With params: "forward", a flag passed to the top-level task, such as --configuration=production, also reaches the tasks it depends on. Accepted fields and their semantics have changed across releases, so check the documentation for your installed Nx version before relying on a particular combination.
Comparison of the forms
| Form | Snippet | Runs first | Typical use |
|---|---|---|---|
| Same-project string | "dependsOn": ["build"] |
build in this project |
Packaging or testing local output |
| Caret string | "dependsOn": ["^build"] |
build in all dependency projects |
Library-before-consumer build order |
| Object, named projects | { "target": "build", "projects": ["shared-proto"] } |
build in the listed projects |
Codegen or schema projects not imported directly |
| Object, dependencies | { "target": "build", "dependencies": true, "params": "forward" } |
build in dependencies, with CLI params |
Propagating a configuration flag down the chain |
// apps/api/project.json (Nx parses project and workspace JSON with comments allowed)
{
"name": "api",
"targets": {
"build": {
"executor": "nx:run-commands",
"options": { "command": "tsc -p apps/api/tsconfig.app.json" },
"dependsOn": [
// Caret form: build every project 'api' depends on first
"^build",
// Object form, named project: generated code that 'api' does not import directly
{ "target": "build", "projects": ["shared-proto"] }
]
},
"docker": {
"executor": "nx:run-commands",
"options": { "command": "docker build -t example/api:dev apps/api" },
// Same-project string: needs this project's compiled dist folder
"dependsOn": ["build"]
},
"e2e": {
"executor": "nx:run-commands",
"options": { "command": "node apps/api/e2e/run.js" },
"dependsOn": [
// Object form with dependencies: true is the explicit equivalent of "^build".
// params: "forward" passes flags such as --configuration=production
// from the top-level task to these build tasks.
{ "target": "build", "dependencies": true, "params": "forward" }
]
}
}
}
A project.json for an api app that uses each dependsOn form: the same-project string, the caret string, and the object form with named projects, dependencies, and forwarded params.
Declaring the pipeline once in targetDefaults in nx.json
The table above describes what each form means. In practice you rarely want to write any of them in project.json. The targetDefaults object in nx.json is keyed by target name, and every project that defines a target with that name inherits its configuration.
A typical backend workspace sets three things for build:
dependsOn: ["^build"]inputs: ["production", "^production"]outputs: ["{options.outputPath}"], or a path such as{projectRoot}/dist
test then gets dependsOn: ["build"] and its own inputs. The named inputs must be defined under namedInputs in the same file.
Declaring this once matters more as the repo grows. When the same dependsOn array is copy-pasted into forty project.json files, those copies drift. A new service gets created from an old template and misses ^build. A refactor that renames a target has to touch every file. A reviewer can't tell which deviations are deliberate. With one central definition, a new engineer learns the pipeline by reading a single file, and any project-level override visibly stands out as an exception.
Keep inputs and outputs next to dependsOn, because ordering alone doesn't make caching correct. dependsOn decides when a task runs. inputs decide the cache key. outputs decide what Nx stores and restores. If outputs points somewhere other than where the compiler actually writes, a cache hit replays the terminal log but restores nothing useful. Downstream tasks then read whatever happens to be on disk from an earlier run. The pipeline order is right, every task reports success, and the artifact is stale.
{
"$schema": "./node_modules/nx/schemas/nx-schema.json",
"namedInputs": {
"sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"],
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"production": [
"default",
"!{projectRoot}/**/*.spec.ts",
"!{projectRoot}/jest.config.ts"
]
},
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"inputs": ["production", "^production"],
"outputs": ["{projectRoot}/dist"],
"cache": true
},
"test": {
"dependsOn": ["build"],
"inputs": ["default", "^production"],
"cache": true
}
}
}
An nx.json that defines the build and test pipeline once. Each target keeps dependsOn, inputs and outputs together. The build outputs path must match where the compiler actually writes.
How project-level config merges with targetDefaults and how to verify it
The same silent failure shows up when a project overrides its pipeline. Nx does not union a project's dependsOn with the one in targetDefaults. For dependsOn, the project-level value replaces the inherited array. Suppose targetDefaults declares build with ["^build"], and one service adds "dependsOn": ["generate-client"] to its own project.json. That service now waits for its codegen step and no longer waits for its library dependencies to build. Nothing errors. The build reads stale or missing dist output from a sibling library, and it often passes locally because an earlier run left artifacts on disk.
The safe override restates the inherited edge explicitly: "dependsOn": ["^build", "generate-client"]. Treat any project-level dependsOn as a full copy of the default plus your addition. Add a short comment or a review rule that says so, because the next person to edit the file will assume the default still applies.
Before you change CI, check what Nx resolves. Run nx show project <name> to print the project's effective configuration after targetDefaults, inferred targets, and local overrides have been combined. This shows the real dependsOn for each target, not what one file suggests. Then open nx graph and use the task view to confirm that the expected upstream tasks appear before the target.
Centralizing rules in nx.json has a cost. A single project.json no longer tells you how its tasks are ordered. Make resolved-config inspection a routine part of reviewing pipeline changes, not something reserved for debugging.
nx show project api --json | jq '.targets.build.dependsOn'
if ! nx show project api --json \
| jq -e '.targets.build.dependsOn | index("^build")' > /dev/null; then
echo "api:build does not depend on ^build; check project.json overrides" >&2
exit 1
fi
nx graph
A project-level dependsOn replaces the targetDefaults value, so the safe override restates ^build; the commands use nx show project and nx graph to confirm the resolved pipeline before CI changes.
Pipeline patterns for backend services
Most backend workspaces share the same pipeline shape: generate, then build and typecheck, with lint on its own branch.
Codegen before build and typecheck
Generated code such as a Prisma client or an OpenAPI client must exist before the compiler runs. In targetDefaults, give generate an inputs entry for the schema or spec file and an outputs entry for the generated directory. That makes the generated code cacheable. Set build to depend on generate and ^build. Set typecheck to depend on generate only. Typecheck needs the generated types but not the emitted JavaScript, so it does not have to wait for anything else.
Keep lint independent of build
Lint usually reads source, not build output, so give it no dependsOn and let it run in parallel with everything else. Type-aware lint rules that resolve generated types are the exception. Those projects need generate added to lint's dependencies.
Dependencies Nx cannot see
Nx infers edges from imports and package manifests. It misses three common cases:
- Shared config files. A root-level
tsconfig.base.jsonor a shared env schema should be listed ininputswith the{workspaceRoot}/prefix. Without that, editing the file can produce a stale cache hit. - Generated code consumed by path. If a service reads another project's generated output by file path rather than by import, add the producer to
implicitDependencies. That way^dependencies cover it. - Docker build contexts. A
docker-buildtarget should depend onbuildand include the Dockerfile in itsinputs.
The per-project override belongs on the service that owns the Prisma schema. It points generate inputs at that schema and declares its implicit dependency on the client-generating project.
How a correct pipeline saves engineering time
A well-configured task graph saves time in a few specific ways. Each one follows from the mechanics described above.
- No hand-maintained build order. With
^buildintargetDefaults, the order follows the project graph. When someone adds an import between two libraries, the build order updates without anyone editing a CI script or a list of steps. - Less waiting in CI and locally. Nx runs independent tasks in parallel and only serializes along real edges. Keeping
lintandtypecheckoff the full^buildchain lets them start right away instead of waiting for upstream compiles. - Fewer repeated builds. With correct
inputsandoutputs, unchanged tasks are restored from cache. Combined withnx affected, which limits a run to projects touched by a change and their dependents, a typical pull request rebuilds only part of the workspace. - Less time debugging flaky failures. Missing edges cause the most expensive kind of failure: a build that passes on a warm machine and fails on a clean runner. Getting
dependsOnright and checking it withnx show projectremoves that class of failure instead of leaving engineers to rerun jobs and guess. - Faster onboarding and review. A single pipeline definition in
nx.jsonis one file to read. A new service inherits the pipeline automatically, and reviewers only need to scrutinize the overrides.
Illustrative estimate (assumed numbers, not a measured result)
Assume:
- a team that opens 20 pull requests per working day
- an extra edge (for example
lintdepending on^build) adds an assumed 5 minutes of waiting to each CI run before lint feedback appears - one in ten of those runs is a run where an engineer is actively waiting for the result
Formula: daily engineer wait time ≈ pull requests per day × fraction actively waited on × added delay.
Here that is 20 × 0.1 × 5 = 10 minutes of engineer waiting per day from a single unneeded edge, plus 20 × 5 = 100 minutes of extra CI runner wall time if the delay ties up runners. Replace the assumed values with your own numbers from CI logs before you draw conclusions.
The savings from caching depend on how often inputs change and how much of the graph a typical change touches, so measure them in your own workspace instead of assuming a fixed figure.
When to avoid heavy dependsOn, alternatives, and the cost trade-offs
Every edge in dependsOn is a scheduling constraint. Nx cannot start a task until its dependencies finish, so each extra edge removes some parallelism.
Over-declaring means adding edges a target doesn't need. A common case is making lint and test depend on ^build everywhere. A linter that reads source files doesn't need compiled upstream output, but with that edge it waits for the whole upstream build chain anyway.
Under-declaring means leaving out edges a target does need. Runs are faster, but failures appear only when upstream artifacts are missing, such as on a clean checkout or after a cache miss. Caching makes this worse: restored outputs can hide a missing edge for weeks, until someone clears the cache or changes an input.
| Approach | CI time | Failure risk | Maintenance cost |
|---|---|---|---|
Broad targetDefaults edges |
Highest; serializes unrelated targets | Low | Low; one place to edit |
Per-project dependsOn only |
Lower; edges only where needed | Medium; easy to forget one | High; config spread across projects |
| Plugin-inferred targets | Varies with plugin defaults | Medium; behavior can change with upgrades | Low, until a default shifts |
| Package-manager scripts, manual order | No task-graph parallelism | High; order lives in people's heads | High |
Illustrative estimate (assumed numbers, not a benchmark)
Assume:
- a dependency chain 4 projects deep
- 3 minutes average build per level
- enough agents that lint is not capacity-bound
Formula: added lint delay ≈ depth × average build time.
Here that is 4 × 3 = 12 minutes before the deepest project's lint can start, compared with starting immediately.
Defaults and inferred targets have changed across Nx releases. After an upgrade, re-check the resolved configuration rather than assuming it is the same.
Debugging checklist: wrong order or a dependency that was not built
When a dependency was not built, or was built in the wrong order, work through these checks in sequence. Each one rules out a separate layer of the pipeline.
- Confirm the edge exists. Run
nx graph --focus=<project>and check that the dependency appears.^buildonly follows edges in the project graph. If Nx cannot detect the import, for example because of a dynamic import, a non-standard path alias, or a dependency that exists only at runtime, there is no edge to follow. Add the project toimplicitDependenciesin itsproject.jsonrather than adding a manualdependsOnon a named project. - Match target names exactly.
^buildlooks for a target calledbuildon each dependency. If a library calls itcompileorbuild-lib, Nx skips that library without an error. - Look for a project-level override. A
dependsOnarray inproject.jsonorpackage.jsonreplaces the array fromtargetDefaultsinstead of merging with it. If a project is missing^build, it was probably dropped in that override. - Inspect the effective config. Run
nx show project <name> --jsonand read the resolveddependsOn,inputsandoutputs. This output is what Nx actually executes. Your intent innx.jsondoes not count if the resolved config disagrees. - Skip the cache. Run the target again with
--skip-nx-cache. Cache hits can restore stale artifacts, which hides a missing edge. If the target fails only when the cache is skipped, the ordering bug was always there. - Verify inputs and outputs. If
outputsis missing, a cache hit restores nothing. Ifinputsomits a file the target reads, Nx reuses a cached result that is stale.
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.