What the Nx project graph sees, and what it misses

Nx builds its project graph by reading your workspace. It parses source files for import statements, resolves them to the projects that own the imported paths, and reads package.json dependencies between workspace packages. Each resolved reference becomes an edge. Affected detection depends on these edges. When you run nx affected, Nx maps changed files to projects, then walks the graph to find every project that depends on them. If an edge is missing, a downstream project is never rebuilt or retested.

This approach works well when coupling appears in code as imports. Backend systems often couple in ways no import reveals:

  • Network calls. orders-api calls payments-api over HTTP or consumes its events from a queue. The only link is a URL or a topic name in configuration.
  • Contracts and generated clients. An OpenAPI spec or .proto file lives in one project. Another project consumes a client generated from it. The generated code may be produced at build time or committed without a traceable import.
  • Shared migrations. Several services run against a schema owned by a single migrations project, and none of them import it.
  • Runtime file reads. Config, seed data or test fixtures are loaded by path at runtime, not imported.
  • Dockerfiles. An image build copies another project's build output into its own image.

Implicit dependencies are edges you declare by hand to cover these cases. This tutorial is for backend and platform engineers whose CI relies on affected detection and who need its blind spots closed.

───▶ edge Nx infers from imports and package.json
┄┄┄▶ coupling Nx cannot see: declare it as an implicit dependency

┌──────────────┐                    ┌──────────────┐
│  orders-api  │──── import ───────▶│ shared-types │
└──┬─┬─┬─┬─┬───┘                    └──────────────┘
   ┊ ┊ ┊ ┊ ┊                            ┌────────────────────┐
   ┊ ┊ ┊ ┊ └┄┄ HTTP / queue topic ┄┄┄┄┄▶│ payments-api       │
   ┊ ┊ ┊ ┊                              └────────────────────┘
   ┊ ┊ ┊ ┊                              ┌────────────────────┐
   ┊ ┊ ┊ └┄┄ generated client ┄┄┄┄┄┄┄┄┄▶│ payments-contract  │
   ┊ ┊ ┊                                └────────────────────┘
   ┊ ┊ ┊                                ┌────────────────────┐
   ┊ ┊ └┄┄ runs schema from ┄┄┄┄┄┄┄┄┄┄┄▶│ db-migrations      │
   ┊ ┊                                  └────────────────────┘
   ┊ ┊                                  ┌────────────────────┐
   ┊ └┄┄ reads config/fixtures ┄┄┄┄┄┄┄┄▶│ test-fixtures      │
   ┊                                    └────────────────────┘
   ┊                                    ┌────────────────────┐
   └┄┄ Dockerfile COPY of dist/ ┄┄┄┄┄┄┄▶│ auth-sidecar       │
                                        └────────────────────┘

Nx infers only the import edge from orders-api to shared-types, so changes to the five projects it reaches through HTTP or queues, generated contract clients, shared migrations, runtime file reads and Dockerfile copies never mark it as affected unless those edges are declared by hand.

Declaring an implicit dependency: orders-api and payments-contract

Take a monorepo where payments-contract holds the JSON Schema files that define the payment events, and orders-api is a service that validates incoming payloads against those schemas. The service reads the schema files at build time through a script, so no TypeScript or JavaScript file in orders-api imports anything from payments-contract. Nx's source analysis therefore finds no edge, and a schema change that breaks order validation goes untested.

To fix this, declare the dependency on the consumer. In orders-api/project.json, add a top-level implicitDependencies array containing "payments-contract". If the project is defined through package.json instead, put the same array under the nx key: an "nx" object with "implicitDependencies": ["payments-contract"]. Nx merges both sources into one project configuration, so pick one location per project and keep it there.

Direction matters. The entry means "orders-api depends on payments-contract." When payments-contract changes, Nx marks orders-api as affected. When orders-api changes, payments-contract is not affected. This matches the real risk: the schema can break the service, but the service cannot break the schema. If you put the entry on the wrong project, the edge points backwards, and the schema change you were trying to catch still slips through.

Now suppose a branch edits only a schema file inside payments-contract. nx affected -t test marks payments-contract as affected because it changed. It marks orders-api as affected through the declared edge. It also marks any project that depends on orders-api, because affected detection follows edges transitively. Nx then runs test only for the affected projects that define that target.

# orders-api/project.json: declare the edge on the CONSUMER.
# This reads "orders-api depends on payments-contract".
cat > orders-api/project.json <<'EOF'
{
  "name": "orders-api",
  "implicitDependencies": ["payments-contract"]
}
EOF

# Equivalent if orders-api is defined through package.json instead.
# Use one location per project, not both.
# {
#   "name": "orders-api",
#   "nx": {
#     "implicitDependencies": ["payments-contract"]
#   }
# }

# On a branch, change only a schema file in payments-contract.
git switch -c schema-change
echo '{ "type": "object" }' > payments-contract/schemas/payment-captured.json
git commit -am "Tighten payment-captured schema"

# List affected projects relative to main:
#   payments-contract  (changed directly)
#   orders-api         (via the implicit edge)
#   anything that depends on orders-api (edges are followed transitively)
npx nx show projects --affected --base=main

# Run test only for affected projects that define a test target.
npx nx affected -t test --base=main

# Reverse check: a change only inside orders-api would NOT mark
# payments-contract as affected, because the edge points from consumer to dependency.

Declaring payments-contract as an implicit dependency of orders-api, then showing that a schema-only change makes nx affected select both projects plus any downstream dependents of orders-api.

Wildcards and negation: "*" and !project

The array accepts two pieces of special syntax, and both change the graph more than their size suggests.

"*": depend on everything

Setting implicitDependencies to "*" makes a project depend on every other project in the workspace. This fits a narrow case: an end-to-end suite or a deployment bundle that really should re-run whenever anything changes. Under affected detection, a change to any project now marks this one as affected.

The danger comes from transitivity. Suppose "*" lands on payments-contract, which most services import. A change to an unrelated docs-site now marks payments-contract as affected, and every project that depends on it follows. As an illustrative estimate, in a workspace of 60 projects where 50 sit downstream of payments-contract, almost any pull request triggers roughly 50 projects' worth of test and build. Affected detection stops saving anything. Keep "*" on leaf projects that nothing else depends on.

!project: remove an inferred edge

Prefixing a name with ! removes an edge Nx inferred from source analysis. One legitimate case is a type-only import, where orders-api pulls a TypeScript type from admin-tools that is erased at compile time. Another is a dev-only import used in a local script. In both cases the edge adds affected runs without guarding any real behavior.

The risk is that negation is silent. If someone later adds a runtime call through that import, the graph still claims there is no edge. Changes to admin-tools then skip orders-api's tests, and the break shows up after merge. Every negation is a promise that the coupling is not real, so it should be re-checked when the import changes.

# e2e-suite is a leaf: nothing depends on it, so "*" only re-runs this one project.
cat > apps/e2e-suite/project.json <<'EOF'
{
  "name": "e2e-suite",
  "implicitDependencies": ["*"]
}
EOF

# orders-api only imports a type from admin-tools, which is erased at compile time.
# The "!" entry removes the inferred edge so admin-tools changes stop re-testing orders-api.
# Re-check this entry whenever the import from admin-tools changes.
cat > apps/orders-api/project.json <<'EOF'
{
  "name": "orders-api",
  "implicitDependencies": ["!admin-tools"]
}
EOF

# Do NOT do this: payments-contract has many dependents, so "*" here
# makes a docs-only change flow through every service that imports it.
# { "name": "payments-contract", "implicitDependencies": ["*"] }

# Inspect what a change would trigger before merging the config.
npx nx show projects --affected --base=main --head=HEAD

# Export the graph and confirm the orders-api -> admin-tools edge is gone.
npx nx graph --file=graph.json

Shows "" kept on a leaf e2e project, a "!admin-tools" entry removing a type-only edge from orders-api, and how to check the resulting affected set and graph.*

Verifying the edge instead of trusting the config

Configuration that parses is not the same as configuration that works. A typo in a project name, a negation that overrides the entry you just added, or a project.json that never got saved all produce the same result: no edge. So check the graph directly instead of assuming the config was applied.

Run nx graph and open payments-contract in the project graph view. orders-api should show up as a dependent. If it doesn't, Nx never registered the dependency, no matter what the JSON appears to say. This check takes seconds, and it tests what Nx actually computed, not what you meant to write.

Next, test the behavior you care about. On a branch that changes only a file inside payments-contract, run nx show projects --affected or nx affected -t test against your main branch as the base. The output should list payments-contract, orders-api, and every other project you expect to depend on the contract. A dependent that is missing from that list will be skipped in CI in exactly the same way.

The reason for this extra care is that Nx cannot detect a missing edge. Coupling that has not been declared looks the same as coupling that does not exist. When a dependency is under-declared, CI passes with a green run on a smaller set of projects, and the failure appears later on main or in production. Two habits close that gap:

  • Run the full target set on a schedule, not only affected, so skipped breakages come to light.
  • Make implicit dependency changes an explicit review item. A reviewer should be able to name the real coupling behind every implicit dependency entry and every negation.
#!/usr/bin/env bash
# Check that the implicit edge orders-api -> payments-contract exists in the
# graph Nx computed, not just in project.json.
set -euo pipefail

BASE=${BASE:-main}

# 1. Visual check: open the graph focused on the upstream project.
#    orders-api should appear as a dependent.
# npx nx graph --focus=payments-contract

# 2. Scripted check: export the computed graph and look for the edge.
npx nx graph --file=graph.json

if jq -e '.graph.dependencies["orders-api"][]
          | select(.target == "payments-contract" and .type == "implicit")' \
     graph.json > /dev/null; then
  echo "OK: implicit edge orders-api -> payments-contract is registered"
else
  echo "MISSING: Nx did not register orders-api -> payments-contract" >&2
  exit 1
fi

# 3. Behavior check: on a branch that touches only payments-contract,
#    every expected dependent must be in the affected set.
AFFECTED=$(npx nx show projects --affected --base="$BASE" --head=HEAD)
echo "$AFFECTED"

for project in payments-contract orders-api; do
  if ! grep -qx "$project" <<< "$AFFECTED"; then
    # A project missing here will be skipped by CI.
    echo "NOT AFFECTED: $project would be skipped by CI" >&2
    exit 1
  fi
done
echo "Affected set includes all expected dependents"

A script that exports the computed project graph, fails if the implicit orders-api to payments-contract edge is missing, and then confirms that both projects appear in the affected set for a branch that changes only the contract.

Implicit dependencies vs dependsOn vs inputs

Each of these three mechanisms answers a different question, and mixing them up is how graphs drift away from reality.

Implicit dependencies change the project graph. They add an edge, so affected follows it and every graph-based feature treats the projects as linked.

dependsOn, set on a target or in targetDefaults in nx.json, controls task order. It never creates an edge. "dependsOn": ["^build"] means "build my dependencies first," and Nx resolves "my dependencies" from the graph. That is why the two combine. Once orders-api has an implicit dependency on payments-contract, ^build also runs the contract's build before the API's build. The risk is cycles. Suppose two services already share a library, and you add an implicit edge between the services in the opposite direction from an existing path. The task graph then has no valid order, and ^build stops working for both projects.

Inputs decide what invalidates a cached task. namedInputs in nx.json, such as a shared sharedGlobals set that points at a root config file, can be referenced from a target's inputs. When the coupling is a file rather than a project, inputs give you exact cache invalidation without pulling a whole project into the affected set.

Mechanism Adds graph edge Affects task order Affects cache hash Use when coupled to
implicitDependencies Yes Only via ^ targets Yes, through dependency inputs A project
dependsOn No Yes No Another task
inputs / namedInputs No No Yes A file or glob

The decision rule: if the coupled thing is a project, add an edge. If it is a file, use inputs.

When implicit dependencies stop scaling: project graph plugins

A single implicitDependencies entry is cheap. Twenty are not. Suppose every service under services/ reads contracts/<name>/ by convention, and a new service appears every few weeks. Each one needs a matching edge in its project.json, added by hand. Nothing fails when someone forgets. The graph just goes quietly wrong, and affected detection skips a service whose contract changed. The edges now encode a rule that lives in people's heads instead of in the repository.

When coupling follows a pattern, compute it rather than declare it. A project graph plugin is code that Nx runs while building the graph. It can inspect files, apply your convention, and add dependency edges that are not written in any project configuration. The official Nx documentation on extending the project graph describes the plugin API. Check it against your installed version, because the hook names and signatures have changed over time.

The cost is real:

  • It is code you own. It needs tests, review, and updates when Nx changes the API.
  • It runs during graph computation. Slow file scanning in a plugin slows every command that needs the graph.
  • A bug spreads to everyone at once. A missing edge hides affected projects for every developer and every CI run, not for one service.

Avoid implicit dependencies entirely in two cases. If the coupling is to files and not to a project, use inputs. If the rule can be derived mechanically and the list keeps growing, write a plugin. Keep hand-written edges for the few exceptions that no convention captures.

Impact on cost and engineering time, and keeping edges honest

Every edge you keep has a price, and it is paid in two places: CI minutes and missed failures.

Edge problem What happens in CI Who pays
Over-declared ("*", speculative links) Unrelated projects rebuild and retest on every change CI budget, PR wait time
Under-declared (missing link) Dependent tests are skipped, so a broken contract merges On-call, incident time
Coarse project-level edge A README edit in payments-contract marks orders-api affected CI budget
Stale edge (retired or renamed project) Wasted runs, or graph errors that block pipelines Whoever debugs the config

Under-declaring is the expensive one. It costs nothing visible until a broken contract reaches production. Over-declaring costs a little on every PR, and because each extra run looks harmless, teams tend to tolerate it.

Illustrative estimate (assumed numbers, not a benchmark)

Extra CI minutes per week = PRs per week × share touching the source project × extra dependents × minutes per dependent

Assume 40 PRs per week, 25% of them touching payments-contract, and 4 minutes per dependent. With a "*" that pulls in 12 projects: 40 × 0.25 × 12 × 4 = 480 minutes. With edges to the 2 real consumers: 40 × 0.25 × 2 × 4 = 80 minutes. The narrow version still runs the tests that would catch a breaking change.

Keeping edges honest

  • Write the reason for each edge beside it, or add it as an item in your review checklist, so a reviewer can see why it exists.
  • When you retire or rename a service, search for edges that point at it and remove or update them.
  • Treat an unexplained "*" or !project as a review smell. Ask the author for the reason before you approve.

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 implicit dependencies, 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.