What the Nx project graph sees and what it misses
Nx builds the project graph by reading what it can parse. It scans each project's source files for import and require statements, resolves them through workspace path aliases and package.json manifests, and adds an edge whenever one project's code references another's. Language plugins can extend this, but the principle is the same: an edge exists only if a static reference exists. nx affected uses this graph to decide what to rebuild and retest, so a missing edge means a change can ship without the dependent project ever being checked.
implicitDependencies is the manual correction. You declare in a project's configuration that it depends on another project, and Nx adds that edge as if an import existed.
Static analysis cannot see couplings that happen at build time, at runtime, or through the filesystem:
- Shared runtime contracts. HTTP or gRPC services that load the same
.protoor OpenAPI file at runtime rather than importing it. - Generated code. A client generated from a schema owned by another project, where the generated output is what gets imported.
- Packaging projects. Docker or infrastructure projects that copy another project's build output into an image.
- End-to-end suites. An e2e project that exercises a deployed app over the network.
- Path-based reads. Config or fixture files loaded by relative path.
import-derived (visible) runtime (invisible)
api ──imports──▶ shared-dto api-e2e ┄┄HTTP┄┄▶ api
web ──imports──▶ shared-dto (no import, no edge)
Without a declared edge, a change to api does not mark api-e2e as affected.
SEEN BY STATIC ANALYSIS (───▶ = edge in the project graph)
┌────────────┐ ┌────────────┐ ┌────────────┐
│ web │─ import ─▶│ shared-dto │◀─ import ─│ api │
└────────────┘ └────────────┘ └────────────┘
NOT SEEN (┄┄▶ = real dependency, no edge; nx affected misses it)
┌────────────┐ ┌────────────┐
│ api-e2e │┄┄ HTTP to deployed app ┄┄┄┄┄┄┄▶│ api │
└────────────┘ └────────────┘
┌────────────┐ ┌────────────┐
│ api-image │┄┄ copies build output ┄┄┄┄┄┄┄┄▶│ api │
└────────────┘ └────────────┘
┌────────────┐ ┌────────────┐
│ api │┄┄ loads .proto at runtime ┄┄┄┄▶│ contracts │
└────────────┘ └────────────┘
┌────────────┐ ┌────────────┐
│ api-client │┄┄ generated from schema ┄┄┄┄┄┄▶│ contracts │
└────────────┘ └────────────┘
┌────────────┐ ┌────────────┐
│ api-e2e │┄┄ reads file by path ┄┄┄┄┄┄┄┄┄▶│ fixtures │
└────────────┘ └────────────┘
implicitDependencies on the left-hand project turns ┄┄▶ into ───▶
Nx adds solid edges only where it finds an import or package.json reference; the dotted runtime, build-output, codegen and path-based couplings stay out of the graph until you declare them with implicitDependencies.
When to declare an implicit dependency, and who should own it
The people who notice a missing edge are usually the ones who own CI: backend and platform engineers who answer for why a broken service passed the pipeline. They should own these declarations too. The project authors know which HTTP calls, shared config files, or generated artifacts their code relies on, but platform owners know what the graph can detect and are responsible for keeping nx affected accurate. Review each declaration with both groups.
The decision rule
Declare an implicit dependency only when all three conditions hold:
- The coupling is real. A change in the target can break the dependent project at build time or at runtime.
- Analysis cannot see it. No import, no
package.jsonreference, nothing a plugin infers. - You cannot reasonably make it visible. If the link can be expressed as an import of a shared contract library, do that. An import stays correct when someone refactors. A hand-written edge does not.
Why under-declaring is worse
A missing edge produces no error. affected leaves the dependent project out, its tests never run, and CI passes. The break appears later, in staging when the e2e suite finally runs against the new api, or in production when a consumer hits a changed response shape. By then the failing change is several commits back and harder to find.
Why over-declaring still costs
Each extra edge widens the affected set. More projects build and test on every change, so CI takes longer and costs more. Too many edges also make the graph less useful for reasoning about the codebase, because some of its links no longer reflect real couplings. Engineers then learn to distrust it.
How to declare implicitDependencies in project.json
The field is an array of strings at the top level of a project's project.json, named implicitDependencies. Each string identifies another project in the workspace by the name Nx knows it by, which may differ from its directory name. If a project is configured through package.json instead, the same array goes inside the nx object of that file. Nx reads both locations, so put the declaration wherever the rest of that project's configuration already lives.
Entries can take three forms:
- A plain project name adds one edge from the declaring project to the named project. This is the safest form because its effect is fixed and visible.
- A glob pattern, such as
shared-*, adds an edge to every project whose name matches. A bare*matches every project in the workspace. - A name prefixed with
!removes an edge that Nx inferred. Use it when static analysis finds a false link, for example an import that appears only in a test fixture or a generated file.
Treat globs with caution. A glob is evaluated against the current set of project names each time the graph is computed. When someone later adds a project whose name happens to match, a new edge appears without any change to the declaring file and without any review. CI then begins rebuilding and retesting that project's dependents, and nothing in the diff explains why.
JSON has no comment syntax, so the reason for each entry is easily lost. Record it in a place reviewers will see. Options include a short note in the project's README, a keyed entry in a sibling documentation file, or the commit message that introduced the entry. The note should name the hidden coupling, such as "reads the schema file that api-contracts generates," so that a later engineer can tell when the edge is no longer needed.
# File: apps/billing/project.json
{
"name": "billing",
"implicitDependencies": [
"api-contracts",
"shared-config-*",
"!legacy-fixtures"
]
}
# File: tools/schema-check/package.json (project configured via package.json)
{
"name": "schema-check",
"version": "0.0.0",
"private": true,
"nx": {
"implicitDependencies": ["api-contracts"]
}
}
# File: apps/billing/README.md
# Reasons for the implicitDependencies entries above. JSON has no comments,
# so the reasons live here, next to the project, where reviewers see them.
#
# - api-contracts: billing reads the schema file that api-contracts
# generates at build time. There is no import, so Nx cannot infer the edge.
# - shared-config-*: billing loads every shared-config project's JSON at
# runtime. Glob warning: any new project named shared-config-<x> becomes a
# dependency of billing automatically, with no diff to this file.
# - !legacy-fixtures: the only import of legacy-fixtures is in a test fixture
# that is not shipped. This removes the inferred edge.
The implicitDependencies array in project.json and in the nx object of package.json, using a plain name, a glob, and a '!' exclusion, with the reason for each entry recorded in the project's README.
implicitDependencies vs dependsOn: graph edges and task ordering
The two settings answer different questions. implicitDependencies tells Nx that one project depends on another, which adds an edge to the project graph. Every command that reads the graph sees that edge, including nx affected, so a change to the upstream project marks the downstream one as affected. dependsOn is set on a target and controls execution order. It says which tasks must finish before this one starts. It does not decide whether a project is selected in the first place.
A Docker project that needs both
Take an api-image project whose docker target copies the compiled output of the api app into an image. No import connects them, so Nx sees no link. That causes two separate failures:
- Selection. A commit that touches only
apidoes not markapi-imageas affected, so CI never rebuilds the image. Declaringapiinapi-image'simplicitDependenciesfixes this. - Ordering. Even when both projects are selected,
dockercan start beforeapi:buildhas written its output. AdependsOnentry of^buildon thedockertarget fixes this.
The two settings also depend on each other. ^build means "build my graph dependencies first," and without the implicit edge api is not a graph dependency of api-image. The ordering rule would then match nothing and give no protection.
| Setting | What it controls | What it affects | Typical use |
|---|---|---|---|
implicitDependencies |
Edges in the project graph | nx affected selection, graph visualisation, ^ target resolution |
Coupling through files, generated artefacts, or runtime contracts |
dependsOn |
Order of task execution | Which tasks run first within a single invocation | Making sure build output exists before packaging, testing, or deploying |
// apps/api-image/project.json
// Nx reads project.json as JSON with comments, so these notes are allowed.
{
"name": "api-image",
"projectType": "application",
"root": "apps/api-image",
// Graph edge: no import links api-image to api, so declare the link here.
// A change to api now marks api-image as affected,
// and api becomes a graph dependency that "^build" can match.
"implicitDependencies": ["api"],
"targets": {
"docker": {
// Task ordering: run build on every graph dependency (api) first,
// so its compiled output exists before the image copies it.
// Without the implicit edge above, "^build" would match nothing.
"dependsOn": ["^build"],
"executor": "nx:run-commands",
"options": {
// The Dockerfile COPYs the api build output, e.g. dist/apps/api
"command": "docker build -f apps/api-image/Dockerfile -t example-registry.local/api:dev ."
}
}
}
}
A Docker packaging project that uses implicitDependencies so it is affected when the api app changes, and dependsOn ["^build"] so api:build runs before the docker target.
Verifying each declaration against the graph
Treat every implicitDependencies entry as a claim to check, not a fact. The check takes a few minutes, and it repeats the same way each time.
Confirm a declared edge
- Run
nx graphand find the downstream project. The new edge should point from it to the upstream project. If the edge is missing, the project name is probably misspelled or the edit landed in the wrongproject.json. Nx resolves entries by project name, not by path. - Create a throwaway branch and change one file that belongs only to the upstream project. Run
nx affectedwith--baseset to your main branch and a target both projects define. The downstream project should appear in the selection. If it does not, the declaration has no effect on CI, whatever the graph view showed.
Confirm a negation
For a !-prefixed entry, do the reverse. The edge should be gone from nx graph. On a branch that touches only the formerly linked project, nx affected should no longer select the dependent one. If it is still selected, another edge is pulling it in. That edge could be a real import or a shared file input, and the negation alone will not remove it.
Why this is the only safeguard
Nx does not cross-check implicit edges against actual coupling. A wrong edge and a correct edge look the same to the tool. Repeat the check whenever either project is renamed, split, or moved.
An implicit edge also only tells Nx when to rebuild or retest. It says nothing about compatibility at runtime. For services that talk over the network, you still need contract tests and explicit versioning, because deployed versions can drift apart in ways the monorepo graph never sees.
#!/usr/bin/env bash
# Verify that orders-api really depends on shared-schemas via implicitDependencies.
# Placeholder project names; run from the workspace root with a clean working tree.
set -euo pipefail
DOWN=orders-api # project that declares the implicit dependency
UP=shared-schemas # project it claims to depend on
# Step 1: export the project graph and look for the edge DOWN -> UP.
npx nx graph --file=graph.json
jq -e --arg d "$DOWN" --arg u "$UP" \
'.graph.dependencies[$d][] | select(.target == $u)' graph.json \
|| { echo "No edge $DOWN -> $UP: check the project name and which project.json you edited"; exit 1; }
# Step 2: on a throwaway branch, touch a file that belongs only to UP.
git switch -c verify-implicit-edge
echo "// verify implicit edge" >> libs/shared-schemas/src/index.ts
git commit -am "tmp: touch $UP only"
# DOWN must be selected, otherwise the declaration has no effect on CI.
npx nx show projects --affected --base=main --head=HEAD | grep -qx "$DOWN" \
&& echo "OK: $DOWN is affected by a change to $UP" \
|| echo "FAIL: $DOWN not selected"
# Same check through the target CI actually runs (both projects define 'test').
npx nx affected -t test --base=main --head=HEAD
# For a '!' negation, invert both checks: jq should find no edge,
# and grep should NOT match. If DOWN is still selected, another edge
# (a real import or a shared file input) is pulling it in.
# Clean up the throwaway branch.
git switch main
git branch -D verify-implicit-edge
rm -f graph.json
A repeatable script that checks that an implicit edge appears in the exported Nx project graph, and then confirms that a change to only the upstream project makes nx affected select the downstream project.
Alternatives that keep the graph accurate without manual edges
Inside the repository, though, you can often remove the need for a manual edge altogether. If the project graph can find a link through static analysis, the link stays accurate as the code changes.
Make the dependency importable
A project that reads another project's files by relative path is invisible to the graph. Wrap those files in a library and have the consumer import it. The import itself becomes the edge.
Extract shared contracts
When several services share schemas, API definitions or message formats, move them into a dedicated contracts project that every consumer imports. Changes to the contracts then mark all real consumers as affected, and only those.
Scope with inputs instead of whole projects
Sometimes the coupling is to a few files, not to an entire project. In that case, use namedInputs or a target's inputs to reference those files directly. This affects cache invalidation and affected results without adding a project-level edge.
| Approach | Precision | Discoverability | Maintenance cost |
|---|---|---|---|
implicitDependencies |
Whole project | Low, only visible in project.json |
Manual, drifts silently |
| Importable library | Import-level | High, visible in code | Low |
| Shared contracts project | Import-level | High | Low once extracted |
namedInputs / task inputs |
File-level | Medium, visible in config | Moderate |
Negating false positives
Static analysis can also over-report. A common case is an import used only by a dev script. Adding !project-name to implicitDependencies removes that edge. The risk is that the negation stays in place after someone adds a real runtime import. The graph will then hide a true dependency, and affected will skip projects that should run.
Write a justification next to every negation, either in a comment or in the commit message. The justification should name the file that causes the false positive, so a reviewer can tell when the negation has stopped being true.
Impact on CI cost, engineering time, and long-term maintenance
Every implicit edge adds downstream projects to nx affected. When the upstream project changes, everything that declares it is rebuilt, retested and relinted, even if that pull request never touched the coupling the edge exists for. One narrow edge costs little. A broad edge on a frequently edited project costs a lot. If enough broad edges accumulate, affected ends up selecting most of the workspace, and you lose the benefit you adopted it for.
Illustrative estimate (assumed numbers, not a benchmark). Assume a workspace of 120 projects. Assume the average PR affects 8 of them. Assume a shared config project is edited in 25% of PRs, and a new edge makes 60 projects depend on it.
Expected projects per PR ≈ baseline + (change rate of upstream × newly dependent projects) ≈ 8 + (0.25 × 60) = 23
That is almost three times the CI work per PR. It is an upper bound, because some of those 60 projects would already have been affected.
The cost runs the other way too. An edge that is accurate catches breakage in CI, while the author still has context. Without it, the same failure shows up after deploy, and someone has to trace a runtime fault back to an untracked file.
To keep that trade-off healthy:
- Review
implicitDependencieschanges with the same care as build configuration. - Record the reason for each entry next to it.
- Periodically check each declared edge against the coupling it was added for. When a coupling disappears, the edge stays, and nothing will flag it.
When to avoid it
Manual edges are a cost, so the default answer to "should we add one?" is no. Treat the following as signs that another tool fits better.
The dependency is a real import Nx failed to resolve
If project A imports from B and the graph shows no edge, the coupling is not invisible. Something in module resolution is broken, such as a path alias missing from the shared TypeScript config or a package name that does not match the workspace. Declaring the edge by hand hides the misconfiguration, and the next project that imports B the same way will have the same gap. Fix the resolution so every importer benefits.
You only need one task to run before another
If B's build must finish before A's tests run, but a change in B does not affect A's correctness, the requirement is about ordering, not about being affected. Use dependsOn instead. An implicit edge here makes nx affected select A on every change to B, which adds CI work with no added safety.
The edge targets a file, not a project
A common warning sign is a project that exists only to own a config file or schema, created so that other projects can declare an edge to it. If the trigger is "this file changed," model it as an input on the targets that read the file. That keeps cache invalidation tied to the specific targets that consume the file. An edge to the whole project would affect everything that depends on it.
The list keeps growing
If a project's implicit dependencies list grows every quarter, or many projects point at the same node, the workspace is describing a shared concern one edge at a time. Shared concerns like that are usually better handled with workspace-level inputs or by restructuring the code so the coupling becomes an import the graph can see.
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 implicitDependencies, 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.