Why nx affected misses proto changes by default

nx affected decides what to rebuild by comparing changed files against the project graph. Nx builds that graph from two main sources: project configuration (each project.json or package.json defines a project and the directory it owns) and static analysis of source files, mostly JavaScript and TypeScript imports, plus dependencies declared in package manifests. When file A in project X imports a module that resolves into project Y, Nx records an edge from X to Y. A change inside Y then marks X as affected.

A .proto file has no part in that process. Nx's built-in analysis does not parse protobuf import statements, and a codegen tool such as protoc or buf receives the schema directory as a command-line argument. Nx never sees that argument as a dependency.

The generated code hides the link further. A typical gRPC client imports something like ./generated/orders_pb, a path inside its own project. The import resolves locally, so the graph records an edge from the client to itself and nothing points back to protos/. When someone edits orders.proto, Nx maps the change either to a schema project that nothing depends on or to no project at all. The client projects are not listed as affected. CI skips them and reports success, while the generated stubs are stale relative to the schema they claim to implement.

This mostly affects backend and platform teams that keep a shared schema directory in one monorepo, with several services or client libraries generating stubs from the same files. The more consumers a schema has, the more of them can fall out of sync without CI noticing.

 changed file: protos/orders.proto
              |
              v
 +------------------------+  protoc/buf get schema dir as a CLI arg
 | protos/                |- - - - - - - - - - - - - - - - - - - +
 |   orders.proto  [edit] |  (not an Nx graph edge)              :
 +------------------------+                                      :
              |                                                  :
              v                                     +-----------v-----------+
 +------------------------+                          | orders-client         |
 | schema project or none |  no incoming edge        | src/client.ts         |
 | nothing depends on it  |<----------- X ---------- |   | imports gen/      |
 +------------------------+                          |   v                   |
              |                                     | gen/orders_pb.ts      |
              v                                     | edge: self -> self    |
 +------------------------+                          +-----------------------+
 | nx affected:           |                          +-----------------------+
 |   orders-client  SKIP  |                          | billing-service       |
 |   billing-svc    SKIP  |                          | gen/orders_pb.ts      |
 +------------------------+                          | edge: self -> self    |
              |                                     +-----------------------+
              v
   CI passes; generated stubs are stale vs. orders.proto

The schema directory reaches each client only through a codegen CLI argument, and generated imports resolve inside the client itself, so no graph edge connects clients to protos/ and nx affected skips them when orders.proto changes.

How the fix works: proto projects, named inputs and target dependencies

The fix is to give Nx the information it is missing: the proto files belong to something, and other projects depend on it.

Make the schema directory a project

Add a project configuration to the shared proto directory so it becomes a node in the project graph. It needs no build output of its own. Its targets are schema checks, such as linting and breaking-change detection against the main branch, using whichever of buf or protoc your repo already uses.

Declare consumers with implicitDependencies

Nx infers dependencies from imports in languages it understands. A Go service that compiles generated stubs has no import pointing at a .proto file, so Nx cannot infer the link. Listing the proto project under each consumer's implicitDependencies adds an edge to the graph. Once that edge exists, a change to the proto project marks every consumer as affected.

Define a protos named input

Create a protos named input that covers **/*.proto plus the buf or protoc configuration files. A change to lint rules or plugin options can alter the generated output, so those files must count as schema changes too. The consumer's codegen target then lists ^protos in its inputs. This makes the cache key include the dependency's schema files, so a cached result is never replayed after a schema change.

Order tasks with dependsOn

implicitDependencies decides which projects are affected. It does not decide the order in which tasks run. To control order, set dependsOn on the codegen target to the proto project's check target, and make compile depend on generate.

Concern implicitDependencies alone Plus target dependsOn
Consumer shows up in nx affected Yes Yes
Schema checked before stubs are generated No Yes
Generate always runs before compile No Yes
Enough for CI that only lists affected projects Yes Not needed

Implementing grpc code generation with Nx step by step

Step 1: Give the protos a project of their own

Create a project.json in the proto directory (say libs/protos) with a lint target that runs buf lint, or buf breaking against your main branch. Because this is a real Nx project, proto edits now map to a node in the project graph instead of loose files that nothing owns.

Step 2: Define a protos named input

In nx.json, add a protos entry under namedInputs. Include {workspaceRoot}/libs/protos/**/*.proto and the generator configuration, such as buf.yaml, buf.gen.yaml or your protoc wrapper script. Pinned plugin versions belong in hashed files. A plugin upgrade then changes the hash, and stale stubs are not restored from cache.

For tools installed outside the lockfile, add a runtime input such as { "runtime": "protoc --version" }. For codegen flags that come from the environment, add { "env": "..." } entries. Nx also supports an externalDependencies input for npm-installed plugins, but its availability depends on your Nx version, so confirm it before you rely on it.

Step 3: Add the consumer codegen target

On each gRPC client, define a codegen target with these settings:

  • inputs: ["protos"], plus any local templates.
  • outputs: the generated directory, for example {projectRoot}/src/generated. This lets cache hits restore the files.
  • dependsOn: { "projects": ["protos"], "target": "lint" }, so invalid schemas fail before stubs are produced. The object syntax for dependsOn has changed across Nx releases, so check the documentation for your version.

Then make build depend on codegen.

Step 4: Declare the graph edge

Add "implicitDependencies": ["protos"] to the consumer's project.json. Nx cannot infer this edge from TypeScript or Go imports of generated code. Without it, nx affected will not list the client when only a proto file changes.

# nx.json (merge into existing): one named input for protos and generator config
cat > nx.json.fragment <<'EOF'
{
  "namedInputs": {
    "protos": [
      "{workspaceRoot}/libs/protos/**/*.proto",
      "{workspaceRoot}/libs/protos/buf.yaml",
      "{workspaceRoot}/libs/protos/buf.gen.yaml",
      { "runtime": "buf --version" },
      { "env": "GRPC_CODEGEN_FLAGS" }
    ]
  }
}
EOF

# apps/orders-client/project.json: codegen keyed on protos, lint runs first
cat > apps/orders-client/project.json <<'EOF'
{
  "name": "orders-client",
  "implicitDependencies": ["protos"],
  "targets": {
    "codegen": {
      "command": "buf generate libs/protos --template libs/protos/buf.gen.yaml --output apps/orders-client/src/generated",
      "inputs": ["protos"],
      "outputs": ["{projectRoot}/src/generated"],
      "dependsOn": [{ "projects": ["protos"], "target": "lint" }],
      "cache": true
    },
    "build": {
      "dependsOn": ["codegen"]
    }
  }
}
EOF

# Edit a .proto or bump a plugin pin in buf.gen.yaml, then rebuild only what is affected
npx nx affected -t codegen build

A shared protos named input with runtime and env entries, plus a consumer codegen target that declares inputs, outputs, dependsOn and implicitDependencies. Check the dependsOn object form and the cache flag against your Nx version.

Verifying the project graph and affected output

Before trusting CI with this, confirm that Nx sees the relationships you declared. Each check below catches a different kind of mistake.

Check the edges in the project graph

Run nx graph and select one of the client projects. There should be an edge from each consumer to the protos project. If an edge is missing, the dependency declaration is not being picked up. Common causes are a misspelled project name or a project.json that Nx did not load. Look at the direction of the arrow too. The edge must point from the client to protos. If it points the other way, a proto change will not mark the client as affected.

Inspect the resolved configuration

The graph only shows that projects are connected. nx show project <name> shows what Nx actually resolved for one project: its targets, each target's dependsOn, and the expanded inputs. Check that the codegen target's inputs include the proto file globs from your named input. If they don't, the cache can replay stale generated code even when the project is correctly marked as affected.

Exercise it with a real change

Edit a field in a .proto file and commit the change on a branch. Then run nx affected with explicit --base and --head values, or nx show projects --affected with the same flags. Every consumer of that proto file should appear in the list.

Make CI compare against the right commit

In CI, the graph is only half the requirement. nx affected computes a git diff between base and head. If the clone is shallow, the base commit may not exist locally. If the base SHA points at the wrong commit, such as the branch tip instead of the last successful build on main, the diff misses the proto edit and the clients are silently skipped.

To avoid this:

  • Fetch enough history to include the base commit.
  • Derive the base SHA deliberately rather than relying on defaults.
#!/usr/bin/env bash
set -euo pipefail

# 1. Graph edges: open the graph focused on one consumer.
#    Expect an arrow from billing-client -> protos.
npx nx graph --focus=billing-client

# 2. Resolved config: confirm the codegen target's inputs
#    include the proto globs from the named input.
npx nx show project billing-client --json | grep -n 'proto' || \
  echo 'WARNING: no proto inputs resolved for billing-client'

# 3. Real change: edit a field on a throwaway branch and commit it.
git switch -c verify-proto-affected
sed -i 's/string customer_id = 1;/string customer_ref = 1;/' \
  libs/protos/src/billing/v1/billing.proto
git commit -am 'test: rename billing field'

# 4. CI-safe base/head: never diff against a commit that isn't local.
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
  # A shallow clone may not contain the base commit at all.
  git fetch --unshallow origin
fi
git fetch origin main

# Diff against where this branch left main, not the branch tip.
BASE=$(git merge-base origin/main HEAD)
HEAD_SHA=$(git rev-parse HEAD)

# Every consumer of billing.proto should be listed here.
npx nx show projects --affected --base="$BASE" --head="$HEAD_SHA"

# Same comparison, running the actual codegen targets.
npx nx affected -t codegen --base="$BASE" --head="$HEAD_SHA"

This script checks the graph edge and resolved inputs for one client project, commits a proto field change, and runs nx affected against an explicit merge-base after making sure the clone is not shallow.

Scoping proto dependencies per service and handling proto imports

A single proto project that owns every .proto file has the same flaw as the default setup in reverse. Every client depends on it, so editing billing.proto regenerates the inventory and auth clients too. The cache still works, but affected reports far more than changed, and CI runs codegen and tests for services whose contracts never moved.

Split along package boundaries

Give each proto package its own project, such as proto-billing, proto-inventory and proto-common, each with a project.json in its directory. Its named input then covers only its own files, for example {projectRoot}/**/*.proto. A client depends on the proto projects it actually consumes, so a billing edit reaches only billing consumers. Per-directory named inputs on one shared project can get partway there, but separate projects keep the boundaries visible in the project graph.

Mirror imports by hand

Nx does not parse import statements in .proto files. If billing.proto imports common/money.proto, a change to money.proto alters billing's generated code, yet Nx sees no edge. Declare proto-common in proto-billing's implicitDependencies so the change propagates to billing's consumers. These lists drift as imports change. A small CI check that compares declared dependencies against actual imports is cheap insurance.

Non-JavaScript consumers

Go or Python services often have no Nx plugin. Add a minimal project.json to each service directory so it appears in the graph. Declare its proto dependencies explicitly, and define a generate target that wraps protoc or buf. Nothing infers these edges for you, so treat them as code and review them in pull requests.

#!/usr/bin/env python3
# CI drift check: each proto project's implicitDependencies must match its .proto imports.
# Assumed layout: libs/proto/<pkg>/project.json, project named "proto-<pkg>",
# and protoc include root at libs/proto (so imports look like "common/money.proto").
import json, re, sys
from pathlib import Path

PROTO_ROOT = Path("libs/proto")
IMPORT_RE = re.compile(r'^\s*import\s+(?:public\s+|weak\s+)?"([^"]+)"\s*;', re.M)

failed = False
for project_json in sorted(PROTO_ROOT.glob("*/project.json")):
    pkg_dir = project_json.parent
    config = json.loads(project_json.read_text())
    name = config["name"]
    # Only compare proto-* entries; other implicit deps are out of scope here.
    declared = {d for d in config.get("implicitDependencies", []) if d.startswith("proto-")}

    imported = set()
    for proto in pkg_dir.rglob("*.proto"):
        for path in IMPORT_RE.findall(proto.read_text()):
            top = path.split("/")[0]
            # Skip well-known types, unrooted paths and the package's own files.
            if "/" not in path or top == "google" or top == pkg_dir.name:
                continue
            imported.add(f"proto-{top}")

    missing = imported - declared   # Nx would miss changes from these
    stale = declared - imported     # extra edges cause needless rebuilds
    if missing or stale:
        failed = True
        print(f"{name}: missing={sorted(missing)} stale={sorted(stale)}")

sys.exit(1 if failed else 0)

A small CI script that compares the import statements in each proto project's .proto files against the proto- entries in its implicitDependencies and fails when they differ.*

Committed versus build-time generated code

Once the generate target exists, you have to decide where its output lives. You can commit the stubs to git, or generate them into a gitignored directory listed in the target's outputs. The choice changes what Nx hashes and what reviewers see.

Concern Committed stubs Build-time generated (gitignored outputs)
Caching Stubs are source files, so there is nothing to restore Cache restores stubs on a hit; protoc/buf runs only on a miss
Code review Diffs show the wire-level effect of a proto edit Reviewers see only .proto changes
Merge conflicts Frequent in large generated files and usually resolved by regenerating None
Broken codegen Hidden, because stale stubs still compile Fails the build immediately
Toolchain Needed only by whoever regenerates Needed on every cache miss, including CI
Affected calculation Consumers are affected by stub files, so a proto edit without a regenerate affects nothing downstream Affected flows from the proto project through the graph edges

If you commit stubs, add a CI check that regenerates them and fails on any diff. Without it, the graph trusts files that may no longer match their protos.

Build-time generation has a quieter failure mode. The cache key covers the declared inputs: the .proto files and the target configuration. It does not cover the plugin binaries unless you add them. When a developer upgrades protoc-gen-go or a grpc plugin, the local or remote cache can still return stubs built by the old version. Usually everything compiles. The breakage appears later as runtime serialization bugs, such as fields that are dropped or decoded incorrectly between services, and no build ever fails.

To prevent this, add the plugin versions to the target's inputs. You can include a lockfile such as buf.lock, or add a runtime input that prints the tool versions. An upgrade then invalidates the cached stubs.

When to avoid this setup, alternatives, and impact on cost and engineering time

Skip this setup when it costs more to maintain than it saves. That is usually the case when only one or two services consume the protos, when a single proto set changes a few times a year, or when most consumers live outside the Nx workspace. Nx cannot rebuild those outside consumers, so precise scoping inside the workspace gains you little.

Alternative Gain Cost
Single monolithic proto project One input definition, trivial to reason about Any change rebuilds every client
Regenerate all clients every run No graph configuration CI time grows linearly with client count
Custom project-graph plugin parsing import statements Edges stay accurate automatically You own and test plugin code
Versioned generated-client packages Works for non-Nx consumers Release step, version skew between services

Hand-kept implicitDependencies drift. Someone adds an import, forgets the matching edge, and affected silently skips a client. Add a CI lint step that extracts proto imports and fails when they don't match the declared dependencies.

Named inputs fail in two directions. Over-broad inputs, such as the whole proto directory, waste CI time on rebuilds that change nothing. Over-narrow inputs are worse. They skip real changes and ship stale clients, and the resulting failures appear at runtime, far from the commit that caused them.

Illustrative estimate (assumed numbers, not a benchmark):

  • Saved minutes = PRs × (total clients − affected clients) × minutes per client
  • Assume 40 proto-touching PRs a month, 20 clients, 3 affected per PR, and 3 minutes per client.
  • 40 × 17 × 3 = 2,040 CI minutes a month.

None of this protects external consumers. You still need a separate breaking-change check against the last released schema.

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 affected protobuf codegen, 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.