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:

  1. Resolve configuration. For every project, Nx combines targets inferred by plugins, the matching entries in targetDefaults in nx.json, and the targets declared in the project's own project.json or package.json. The result is the effective configuration for each target, including its dependsOn, inputs and outputs.
  2. Build the project graph. Nx analyzes imports and manifests and adds any implicitDependencies to record which projects depend on which.
  3. Expand the requested tasks. Starting from the tasks you asked for, Nx follows each dependsOn entry. 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.
  4. Schedule. Nx starts every task whose dependencies have all finished, up to the parallelism limit set by --parallel or the workspace configuration. When a task finishes, any task waiting only on it becomes eligible to run.
  5. 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 in outputs and 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 build target and consumers read the compiled output instead of the source, the order of builds matters, and ^build is 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. dependsOn makes 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.json or a project.json override needs to understand the merge rules, because a single overriding dependsOn array 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.json or a shared env schema should be listed in inputs with 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-build target should depend on build and include the Dockerfile in its inputs.

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 ^build in targetDefaults, 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 lint and typecheck off the full ^build chain lets them start right away instead of waiting for upstream compiles.
  • Fewer repeated builds. With correct inputs and outputs, unchanged tasks are restored from cache. Combined with nx 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 dependsOn right and checking it with nx show project removes that class of failure instead of leaving engineers to rerun jobs and guess.
  • Faster onboarding and review. A single pipeline definition in nx.json is 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 lint depending 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.

  1. Confirm the edge exists. Run nx graph --focus=<project> and check that the dependency appears. ^build only 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 to implicitDependencies in its project.json rather than adding a manual dependsOn on a named project.
  2. Match target names exactly. ^build looks for a target called build on each dependency. If a library calls it compile or build-lib, Nx skips that library without an error.
  3. Look for a project-level override. A dependsOn array in project.json or package.json replaces the array from targetDefaults instead of merging with it. If a project is missing ^build, it was probably dropped in that override.
  4. Inspect the effective config. Run nx show project <name> --json and read the resolved dependsOn, inputs and outputs. This output is what Nx actually executes. Your intent in nx.json does not count if the resolved config disagrees.
  5. 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.
  6. Verify inputs and outputs. If outputs is missing, a cache hit restores nothing. If inputs omits 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.