How Nx builds the project graph and what it cannot see
Nx builds its project graph by reading your workspace. It analyzes source files for import and require statements that resolve to another project, often through TypeScript path aliases. It also reads package.json files and adds an edge when one project depends on a package that another project in the workspace produces. Each edge says "if B changes, A may be affected." Commands such as nx affected walk these edges outward from the files changed in a commit to decide which projects to build, test, or lint.
This inference only sees links that appear in code or package manifests. Backend systems often depend on each other in ways that never show up as an import:
- Network calls between services. An
ordersservice callsinventoryover HTTP or gRPC, or reads messages thatbillingpublishes to a queue. No import connects them. - Shared SQL migrations or schema files. Several services run queries against tables defined in one migrations directory.
- Generated clients. A client is generated from an OpenAPI or protobuf spec owned by another project, so the dependency is on the spec file, not on its code.
- Config or infrastructure directories read at runtime. Examples include YAML loaded by path or deployment manifests a service expects to exist.
An implicit dependency is an edge you declare by hand to cover one of these cases. This matters most to backend and platform engineers who use nx affected to keep CI fast. If an edge is missing, a change to the inventory API or a shared migration does not mark the dependent services as affected. Their tests are skipped, CI passes, and the break shows up in staging or production.
INFERRED BY NX (edges visible in the project graph)
+----------+ +---------------+
| orders |--- import / path alias -->| shared-utils |
+----+-----+ +---------------+
| +---------------+
+--- package.json dependency ---->| logger-lib |
+---------------+
MISSED BY INFERENCE (declare as implicit dependencies)
+----------+ +---------------+
| orders |.... HTTP / gRPC call ....>| inventory |
+----+-----+ +---------------+
: +---------------+
+..... consumes queue messages ..>| billing |
: +---------------+
: +---------------+
+..... queries shared tables ....>| db-migrations |
: +---------------+
: +---------------+
+.. client generated from spec ..>| openapi-spec |
: +---------------+
: +---------------+
+... reads YAML at runtime ......>| infra-config |
+---------------+
----> edge Nx infers from source imports or package.json
....> edge Nx cannot see; without it, nx affected skips orders
Nx infers the solid edges from imports and package.json, while network calls, shared migrations, spec-generated clients and runtime config (dotted) stay invisible until you declare them as implicit dependencies.
Declaring implicitDependencies in project.json
The fix lives in the dependent project's project.json. Open apps/orders/project.json and add a top-level implicitDependencies array next to name, sourceRoot and targets. Put "db-migrations" in it. The string must match the name that Nx assigns to the migrations project, not its folder path. From then on, Nx draws an edge from orders to db-migrations, and any change to a migration file marks orders as affected. Declare the edge on the consumer because the consumer is the project that breaks when the dependency changes.
The array accepts three entry forms:
- Plain project names, such as
"db-migrations", add an edge to that single project. - A
!prefix, such as"!legacy-utils", removes an edge that Nx inferred from imports. Use it when static analysis finds a link that does not exist at runtime, for example a type-only import left over from a refactor. - Glob patterns, such as
"*-contracts", add edges to every project whose name matches. This keeps the list stable as new contract projects are added.
Glob matching and negation have changed across Nx releases. Before you rely on either, check the documentation for the Nx version in your lockfile.
Edges must point at named projects. If orders relies on OpenAPI specs or SQL schemas kept in a top-level schemas/ folder, Nx has no project to link to. Turn that folder into its own project by adding a project.json with a name such as order-contracts. You can then depend on it by name, give it its own targets such as linting or code generation, and see it as a node in the graph.
# 1) Give the top-level schemas/ folder its own Nx project so edges have a real target.
cat > schemas/project.json <<'EOF'
{
"name": "order-contracts",
"$schema": "../node_modules/nx/schemas/project-schema.json",
"projectType": "library",
"sourceRoot": "schemas",
"targets": {}
}
EOF
# 2) Declare the links on the consumer, apps/orders/project.json.
# Entries are project names, not folder paths:
# - "db-migrations": plain name, adds one edge
# - "*-contracts": glob, matches order-contracts and future *-contracts projects
# - "!legacy-utils": removes an edge Nx inferred from a stale type-only import
# The existing build/serve/test targets are omitted here for brevity.
cat > apps/orders/project.json <<'EOF'
{
"name": "orders",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"sourceRoot": "apps/orders/src",
"implicitDependencies": ["db-migrations", "*-contracts", "!legacy-utils"],
"targets": {}
}
EOF
# 3) Check the result in the project graph before relying on it in CI.
# Glob and '!' handling vary by Nx version, so check your installed version's docs.
npx nx graph
Shell steps that turn a top-level schemas/ folder into an order-contracts Nx project and add implicitDependencies to orders. The array uses all three entry forms: a plain project name, a glob and a '!' negation.
Verifying the edge with nx graph and nx affected
Start with nx graph and focus on the service that should now depend on the migrations project. The new edge should appear next to the edges Nx inferred from imports. Check this before anything else, because nx affected reads this same graph. If the edge is missing here, it will be missing from affected calculations too. The usual causes are a typo in the project name or a declaration placed on the wrong project.
Next, test the behaviour the declaration was meant to fix. Edit a migration file. Adding a comment is enough, because Nx compares files, not semantics. Then run nx affected with the test target against your base branch. Before the declaration, this command listed only the migrations project, if that project has a test target at all. Now the dependent service should appear as well, since a change to one of its dependencies marks it as affected. Run the same command against a commit from before the declaration to see the difference side by side.
Last, look for cycles. Implicit dependencies are easy to add in both directions without noticing, for example a service pointing at the migrations project while the migrations project points back at the service for a shared seed script. Select the two projects in the graph view and confirm the path runs only one way. Alternatively, export the graph to JSON with nx graph --file=graph.json and inspect the dependency entries for each project.
Cycles matter most when targets use dependsOn with dependency targets such as ^build. Each project waits on the other, so Nx cannot derive a consistent order. The run either fails or runs tasks in an order you did not intend.
#!/usr/bin/env bash
set -euo pipefail
# 1. Open the graph focused on the service and confirm the implicit edge
# to db-migrations appears next to the import-based edges.
npx nx graph --focus=orders-service
# 2. Touch a migration file. A comment is enough because Nx compares files.
echo '-- verify implicit dependency' >> libs/db-migrations/migrations/0007_add_index.sql
# List affected projects, then run tests. orders-service should now appear.
npx nx show projects --affected --base=main
npx nx affected -t test --base=main
# Compare against a commit from before the declaration was added.
# Replace <sha-before-declaration> with a real commit hash.
npx nx show projects --affected --base=<sha-before-declaration>
# 3. Export the graph and check that the edge only runs one way.
npx nx graph --file=graph.json
# Edges from the service. Expect db-migrations with type "implicit".
jq '.graph.dependencies["orders-service"]' graph.json
# Edges from migrations back to the service would form a cycle.
if jq -e '.graph.dependencies["db-migrations"][] | select(.target == "orders-service")' graph.json > /dev/null; then
echo "Cycle: db-migrations also depends on orders-service" >&2
exit 1
fi
echo "No cycle between orders-service and db-migrations"
# Discard the test edit.
git checkout -- libs/db-migrations/migrations/0007_add_index.sql
An end-to-end check that confirms the implicit edge in nx graph, shows that editing a migration now marks the dependent service as affected, and uses the exported graph JSON to detect a reverse edge that would create a cycle.
Choosing dependency direction for runtime calls
The default rule is that the consumer depends on the provider. If orders-api calls billing-api over HTTP, add billing-api to the implicitDependencies of orders-api. A change to billing-api can break the contract that orders-api relies on, so nx affected should retest the caller. The reverse is rarely true. A change inside orders-api cannot break billing-api's endpoints, so an edge in that direction only adds CI work. Declaring both directions also creates the cycle problem described above.
The rule needs adjusting in two common cases.
Queues and event streams
With a message broker, neither service calls the other directly. Both depend on the message shape. If the schema lives in its own project, such as a shared library of event types, point both the producer and the consumer at that project. Static analysis often finds this edge already through imports. If the schema exists only inside the producer's code, treat the producer as the provider and add it to the consumer's implicitDependencies.
Generated clients
When orders-api imports a client library generated from billing-api's API spec, Nx already sees the import edge from orders-api to the client. The missing link is between the client and the spec it is generated from. Declare billing-api as an implicit dependency of the client project, not of orders-api. The chain then carries the change through to the caller without a redundant edge.
Write these three cases into your contributing guide as the team convention, and check new declarations against it in code review. If each team picks its own direction, the graph ends up inconsistent. Some changes will then retest too much and others too little, and nobody can tell which from reading a single project.json.
Alternatives: inputs, namedInputs and dependsOn compared
Nx offers three mechanisms that look interchangeable but operate at different layers. Picking the wrong one usually produces a graph that looks correct while the cache or task order is still wrong.
| Mechanism | Where it lives | What it controls | Effect on nx affected |
|---|---|---|---|
implicitDependencies |
project.json |
Adds or removes edges in the project graph | Yes, at project level |
inputs / namedInputs |
Target config, nx.json |
Which files and values feed a target's cache hash | Indirect, through file matching |
dependsOn |
Target config, targetDefaults |
Which tasks run before a given task | No; it only orders tasks |
The most common misuse is reaching for implicitDependencies when a test suite keeps returning stale cached results. Suppose a service's integration tests read a shared fixture directory or a root-level config file. Adding an implicit edge to some unrelated project doesn't fix that, because the file was never in the target's hash. The fix is to add that path to the target's inputs, or to a namedInputs entry that several targets reuse. That way the cache invalidates exactly when the file changes.
dependsOn addresses a third problem: build artifacts that must exist before another task runs, such as ^build before serve. It does not tell Nx that two projects are related.
Implicit edges are also coarse. An edge means any change anywhere in the upstream project marks the downstream project affected, for every target. If only one directory or one target matters, use narrower inputs, or split the upstream project so the edge points at the part that actually matters.
When to avoid implicit dependencies and what can go wrong
Every edge you add also widens the affected set. The worst case is a hub: a shared config or common-types project that most services list as an implicit dependency. Any commit to the hub, including a comment fix, makes nx affected select nearly the whole backend. CI time then grows as if the monorepo had no graph at all. If you find yourself adding the same upstream to a dozen services, that project probably needs to be split, or the services need narrower inputs.
Declarations go stale silently
Nx infers import edges from source on every run. It never re-checks implicitDependencies. When a service stops calling another, or starts calling a new one, nothing fails. The nx project graph still renders cleanly and looks authoritative, so reviewers trust an edge list that no longer matches runtime behavior.
Under-declaring has no warning
A missing edge means a breaking change to an API contract or queue payload can merge without the consumer's tests running. No tooling flags the gap. Implicit dependencies only decide which tests run. They do not check compatibility, so you still need contract tests and schema checks against the producer's published contract.
Coarse triggers and version drift
An implicit edge covers the whole upstream project. Edits to its README or docs folder still mark dependents affected unless inputs exclude those files. Glob patterns, negation syntax and how the visualizer displays implicit edges have changed across Nx releases. Before you rely on a pattern, check how it resolves in your workspace's version.
Impact on CI cost and engineering time, plus a review practice
Each implicit edge changes what nx affected selects, so the edges have to be accurate in both directions. A missing edge leaves a consumer's tests unscheduled when its provider changes, and the breakage shows up in staging or production, where it costs more engineering time to trace. An unnecessary edge schedules work that can't fail for the reason you added it, and every PR that touches the hub pays for that work.
Illustrative estimate (assumed numbers, not a benchmark)
Formula: CI minutes per PR = affected projects × average target minutes per project
Assume 30 backend projects and 4 minutes of lint, test and build per project. A typical change affects the edited project plus 3 dependents, so 4 × 4 = 16 minutes. Now suppose someone declares a frequently edited shared-config project as an implicit dependency of all 30 services. Any change to it affects 31 projects, so 31 × 4 = 124 minutes. If 10 PRs a week touch it, the extra cost is (124 − 16) × 10 = 1,080 CI minutes per week. Remote caching won't recover that time, because the hub's changed hash invalidates every downstream cache entry.
A review practice
- Treat
implicitDependenciesas reviewed config. Route changes through code owners for the projects on both ends of the edge. - Record why each edge exists. Add a comment or README note naming the runtime link it represents, such as a queue, an HTTP contract or a shared schema file.
- Audit periodically. Compare the graph against actual service communication, using sources such as tracing data, API gateway routes or queue subscriptions. Delete edges whose reason no longer exists, and add edges for calls the graph doesn't show.
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.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
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.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- 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.