Every Nx command that decides what to run depends on the project graph. This is a directed graph whose nodes are projects and whose edges are dependencies between them. Nx uses it to order tasks, so a library builds before the service that consumes it. It uses it to compute which projects are affected by a change. It also uses it to decide which files and upstream outputs feed each task's cache hash. If the graph is wrong, all three answers are wrong, and they are often wrong without any visible error.

Nx builds the graph mostly by inference. It discovers projects from configuration files, then analyzes their contents. It reads JavaScript and TypeScript import statements, workspace path mappings, and dependency entries in package manifests. Plugins can contribute more nodes and edges. If one project's source imports another, Nx draws the edge.

Inference only sees what code declares. Some links never show up as an import:

  • Shared contract files. A service generates or validates against an OpenAPI spec or .proto file that lives in another project.
  • Build-output packaging. A Docker image project copies another project's compiled output into its image.
  • Sibling configuration. A backend reads YAML or JSON config from a neighboring directory at runtime.
  • Polyglot code. Go, Python, or JVM projects whose imports the default analysis cannot read unless a plugin covers them.

These are the cases this article models. They mainly affect backend and platform engineers who own CI pipelines. When a hidden link is missed, Nx skips a build that should have run or replays a cache result that is stale.

  INFERRED BY NX (source imports, path mappings, package manifests)

  +-------------+  TS import      +---------------+
  | api-service |---------------->| shared-util   |
  +-------------+                 +---------------+
  +-------------+  manifest dep   +---------------+
  | web-app     |---------------->| api-client    |
  +-------------+                 +---------------+

  HIDDEN FROM INFERENCE (link never appears as an import)

  +-------------+  reads .proto   +---------------+
  | api-service |- - - - - - - - >| contracts     |  OpenAPI / .proto
  +-------------+                 +---------------+
  +-------------+  COPY dist/     +---------------+
  | docker-api  |- - - - - - - - >| api-service   |  build output
  +-------------+                 +---------------+
  +-------------+  loads YAML     +---------------+
  | billing-svc |- - - - - - - - >| shared-config |  sibling directory
  +-------------+                 +---------------+
  +-------------+  Go import      +---------------+
  | go-worker   |- - - - - - - - >| go-common     |  no JS/TS analysis
  +-------------+                 +---------------+

  ----->  edge in project graph     - - ->  real link, no graph edge
  Edges drive: task order | affected projects | cache hash inputs
  Missing edge => build skipped or stale cache result replayed

Nx draws edges only for links it can infer from imports and manifests, so contract files, packaged build output, sibling config, and polyglot imports stay invisible to task ordering, affected detection, and cache hashing.

What implicitDependencies and namedInputs each change

implicitDependencies is a project-level property, set in project.json or the nx block of package.json. It lists other project names, and Nx adds an edge from the declaring project to each one, as if a source import existed. Because the edge is part of the graph, it affects everything that reads the graph. A change in the dependency marks the dependent as affected, nx graph draws the link, and a target with dependsOn: ["^build"] builds the dependency first. The ^ means "this target on my dependencies," and implicit edges count as dependencies.

The negated form, "!other-project", removes an edge that Nx inferred. This is rarely the right fix. It is legitimate when inference produces a false positive, such as a match on a string that only looks like an import, or a tooling project that references another project's files without depending on its output at runtime. Before negating an edge, confirm that nothing in the build actually consumes that project.

namedInputs does not touch the graph. It defines reusable sets of file patterns, usually in nx.json, scoped with {projectRoot} or {workspaceRoot}. The common pair is default, covering all project files, and production, which is default minus tests and specs. A target's inputs array references these sets to decide what goes into its cache hash. ^production adds the matching files from every dependency. A change outside those sets leaves the hash unchanged.

Affects implicitDependencies namedInputs
Project graph Yes No
Affected calculation Yes No
Task ordering (^ in dependsOn) Yes No
Cache hash Indirectly, via ^ inputs Yes

Exact syntax and defaults differ between Nx versions, and plugin inference can supply inputs you never wrote. Check the resolved configuration rather than assuming it.

// nx.json (workspace root): namedInputs shape the cache hash only
{
  "namedInputs": {
    // Every file in the project plus shared globals
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    // default minus test files, so editing a spec does not change build hashes
    "production": [
      "default",
      "!{projectRoot}/**/*.spec.ts",
      "!{projectRoot}/jest.config.ts"
    ],
    // Workspace-level files that should invalidate every project
    "sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"]
  },
  "targetDefaults": {
    "build": {
      // Ordering: build this target on dependencies first (implicit edges count)
      "dependsOn": ["^build"],
      // Hash: own production files plus production files of each dependency
      "inputs": ["production", "^production"],
      "cache": true
    }
  }
}

// apps/api/project.json: implicitDependencies change the project graph
{
  "name": "api",
  // Add an edge to shared-config (no source import exists).
  // Remove a falsely inferred edge to legacy-scripts.
  "implicitDependencies": ["shared-config", "!legacy-scripts"]
}

The nx.json file defines default and production namedInputs that targets reference for their cache hash. The api project.json adds one graph edge with implicitDependencies and removes an inferred edge with the negated form. Nx accepts these comments, but strict JSON tools do not.

The decision rule comes down to one question: does changing X alter what Y is, or only what one of Y's targets produces?

If changing X must mark Y as affected and must also order Y's tasks after X's, you need a graph edge, so use implicitDependencies. If changing X only needs to invalidate the cached result of a specific target on Y, model X as an input to that target.

Hidden link Model as Reasoning
api-e2e calls api over HTTP, with no import implicitDependencies A change to api must trigger the e2e suite, and api must build before the e2e tests run.
billing codegen reads contracts/openapi.yaml Input on billing:build Only the generated build output depends on the spec. Lint and unit tests on handwritten code do not.
Root lint config applies to every project Input on lint Changing the config should re-run lint but should not rebuild or retest anything.
worker loads scheduler plugins by name at runtime implicitDependencies The build order and the affected set both depend on scheduler.

When to avoid implicitDependencies: the edge is coarse. Every target on the dependent project becomes affected, and the edge propagates to that project's dependents. One unnecessary edge can pull a large part of the workspace into every CI run.

When to avoid inputs: an input changes the hash, not the affected set. If CI only runs affected targets, a project whose cache would have missed can still be skipped, because nothing in the graph marks it as touched. Because inputs never mark a project affected, a link that must trigger CI on its own needs an edge.

Negation (!project in implicitDependencies, or ! patterns in inputs) removes something that Nx inferred or inherited. It hides real links from the graph, so use it only when you have confirmed the link is spurious. When you do use it, add a comment explaining why next to the configuration.

Implementing it in nx.json and project.json

Workspace-wide defaults belong in nx.json, and project.json holds only the exceptions. In namedInputs, define a default set covering every file in the project plus any shared globals. Then define a production set that starts from default and excludes spec files, test directories, and test-runner configuration. This exclusion keeps build hashes stable: editing a unit test should rerun tests, not invalidate every downstream build artifact.

Under targetDefaults, give the build target the inputs production and ^production. The caret tells Nx to include the production inputs of each dependency, so a library's test edits do not invalidate its consumers' builds, but its source changes do. Setting this once in nx.json keeps the behavior uniform. Override namedInputs in a single project.json only when that project genuinely differs, such as one whose fixtures ship in the bundle.

Add implicitDependencies in the dependent project's project.json as a list of project names. Keep these entries rare and specific.

Inputs can also capture things outside the file tree. These include environment variables that change compiled output and the output of a command, such as a toolchain version check. Both affect results without touching source files. Nx supports input entries for both, but the syntax has changed over time, so check the docs for your installed version.

Every implicit dependency needs a recorded reason. JSON may not accept comments, so keep a short note beside project.json, such as a section in the project README. Alternatively, adopt a naming convention that makes the link's purpose obvious.

# Workspace-wide defaults live in nx.json; JSON has no comments, so intent is noted here.
# production = default minus specs, test dirs and test-runner config.
# env/runtime entries hash things outside the file tree (verify syntax for your Nx version).
cat > nx.json <<'EOF'
{
  "namedInputs": {
    "sharedGlobals": [],
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "production": [
      "default",
      "!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)",
      "!{projectRoot}/**/__tests__/**",
      "!{projectRoot}/tsconfig.spec.json",
      "!{projectRoot}/jest.config.[jt]s"
    ]
  },
  "targetDefaults": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["production", "^production", { "env": "API_BASE_URL" }, { "runtime": "node --version" }]
    }
  }
}
EOF

# Exception only: web reads design-tokens at build time without importing it.
cat > apps/web/project.json <<'EOF'
{
  "name": "web",
  "implicitDependencies": ["design-tokens"]
}
EOF

# Record why the implicit dependency exists, next to project.json.
cat >> apps/web/README.md <<'EOF'
## Implicit dependencies
- design-tokens: build script copies its generated CSS variables; no import edge exists.
EOF

Illustrative setup with production/^production inputs, env and runtime inputs in nx.json, a single implicitDependencies entry in project.json, and a README note recording why the link exists; confirm the env/runtime input syntax against the docs for your installed Nx version.

Alternatives for shared files at the workspace root

Take a root-level schema/ folder that several services read but no project owns. There are three ways to model it, and they differ in how much of the workspace each change invalidates.

Workspace-level inputs. You add {workspaceRoot}/schema/**/* to a named input such as sharedGlobals. This is quick, but every target using that input now hashes the folder, including projects that never read it. The graph also gets no edge, so affected treats a schema edit as a global change rather than one tied to specific consumers.

Implicit dependency on a consuming project. One project includes the folder in its inputs, and the other consumers declare implicitDependencies on it. This creates an edge, but it points at the wrong thing. Consumers now rebuild whenever that project's own source changes, not only when the schema changes.

A dedicated project. You give schema/ its own project.json, and consumers depend on it. The edge now names the real producer, so affected reaches only those consumers. Their cache keys change only when schema files change.

Option Graph accuracy Affected behavior Cache precision Maintenance
Workspace-level input No edge Broad, global-style Coarse Lowest
Implicit dep on consumer Edge to wrong owner Over-triggers on consumer edits Mixed Low
Dedicated project Edge to real owner Scoped to consumers Narrow Highest

The dedicated project has costs. It is one more project to name, tag and keep in the graph. Someone has to own it, and shared schemas often sit between teams. You also need to update your module boundary lint rules and tags so consumers are allowed to depend on it.

// schema/project.json: promote the root folder to its own project
{
  "name": "schema",
  "root": "schema",
  "projectType": "library",
  // Tag it so module boundary rules can allow consumers to depend on it
  "tags": ["scope:shared", "type:schema"],
  "namedInputs": {
    // Only schema files count toward this project's hash
    "default": ["{projectRoot}/**/*.json", "{projectRoot}/**/*.graphql"]
  }
}

// apps/orders/project.json: a consumer that reads schema files at build time
{
  "name": "orders",
  "root": "apps/orders",
  "tags": ["scope:orders"],
  // No import statement links the two, so declare the edge explicitly
  "implicitDependencies": ["schema"],
  "targets": {
    "build": {
      // '^default' pulls in the default inputs of dependencies, including schema,
      // so this cache key changes only when orders or schema files change
      "inputs": ["default", "^default"]
    }
  }
}

The root-level schema folder becomes a tagged project with its own inputs, and a consumer declares an implicit dependency on it. A schema edit then affects and re-hashes only the projects that consume it.

Verifying the model with graph, affected and cache checks

A config change that looks right in nx.json can still be wrong in the graph, so check each layer directly.

Graph

Run nx graph and find the project that owns the hidden link. An implicitDependencies entry should appear as an edge to the target project. Nx draws it the same way it draws an edge it found by analyzing source code. A namedInputs change should add no edge at all. If an edge shows up that you meant to be cache-only, you have modeled a structural link by mistake. That edge will widen every affected run from now on.

Affected

On a clean branch, modify only the hidden file and nothing else, commit, and run nx affected against the base branch with an explicit --base and --head. Compare the projects listed with the ones you expect. Touching a single file isolates the question. If you also change source files, you can't tell which change pulled in which project.

Cache

Run the target once so the cache is populated. Then change the input you declared and run it again. You should get a cache miss. Revert the change and run it a third time. You should get a cache hit, because the hash should be the same as it was before the edit. A miss after the revert means something nondeterministic, such as a timestamp or a generated file, is part of the hash.

Automate and test both

Put these steps in a short CI script. Someone who remembers the setup will check it once. A script checks it on every change to nx.json.

Run the affected checks and the cache checks separately, because they can give different answers. Affected detection relies on file ownership and graph edges. Cache hashing relies on the inputs you declared. A file can mark a project as affected and still produce a cache hit. A file can also change a hash while never showing up in the affected list.

Impact on cost and engineering time

Those mismatches are where the money goes. CI minutes and debugging hours both depend on how closely the graph and inputs match what the build reads.

Illustrative estimate (assumed numbers, not a benchmark). Assume a 40-project workspace. One hub project lists 30 implicit dependents. Every cache miss costs an average of 4 minutes, and the hub changes 10 times a week.

Formula: weekly CI minutes = changes per week × (1 + dependents rebuilt) × average build minutes

  • Implicit dependency on the hub: 10 × (1 + 30) × 4 = 1,240 minutes.
  • Input-based model where only 6 projects read the changed file: 10 × (1 + 6) × 4 = 280 minutes.

Plug in your own counts. The gap grows linearly with fan-out.

Failure mode Symptom Cost
Inputs too narrow Silent false cache hits, stale artifacts deployed Debugging in production. Trust in the cache is lost.
Inputs too broad Nearly every task misses the cache CI minutes scale with workspace size, not change size
Hand-maintained edges Edges drift as code moves, with no error raised Both of the above, found late

The first two failure modes feed each other. After a stale-artifact incident, teams often widen inputs to the whole workspace. Months later they narrow them again to recover CI time, and the cycle repeats. Hand-maintained implicitDependencies drift for a simpler reason: nothing checks them.

A periodic ownership review breaks the cycle. Have each project owner confirm which shared files their builds actually read. Accurate edges remove the wasted rebuilds from the CI bill, and precise inputs make cache hits trustworthy, which cuts debugging time.

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.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by 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.