What this setup is

An Nx affected protobuf setup is a way of organizing .proto contracts and their generated stubs inside an Nx monorepo so that nx affected rebuilds and tests only the gRPC services that consume a changed contract. It has four parts:

  • Proto projects. Each group of .proto files becomes an Nx project with its own project.json.
  • Explicit edges. Proto-to-proto and service-to-proto dependencies are declared with implicitDependencies, because Nx does not infer them from proto imports.
  • Cached codegen targets. Stub generation runs as an Nx target with precise inputs and outputs. Upstream codegen runs before each service build.
  • CI verification. The pipeline computes a correct diff range, checks the declared edges for drift, and runs a periodic full build as a safety net.

Without this structure, Nx either misses proto changes or treats them as global inputs that rebuild everything. The rest of this guide explains why that happens and how to fix it.

How nx affected sees (and misses) proto changes

nx affected answers one question: which projects could be changed by the diff between a base ref and a head ref? It answers in two steps. First, Nx lists the files changed between the two refs and assigns each file to the project whose root contains it. Second, it walks the project graph backwards from those projects and marks every dependent as affected. Targets then run only for that set.

Both steps assume something that proto files often lack. If user.proto sits in a top-level protos/ directory that no project claims, step one maps the change to nothing, and nothing is affected. If the file belongs to a project but its consumers have no edge to that project, step two has nothing to walk.

Edges usually come from inference. Nx plugins read source imports, such as TypeScript import statements or package manifests. They do not parse import "common/money.proto";, so dependencies between proto files are not detected. Generated stubs make it worse. When stubs are produced at build time and gitignored, the consumer's imports either point at a path that does not exist when the graph is built or resolve to files inside the consumer's own directory. Neither creates an edge back to the proto source.

The result is one of two failure modes:

  • Missed consumers. A breaking schema change merges while dependent services stay green and untested.
  • Full rebuilds. Teams patch the gap by marking protos/** as a global input, so every proto edit rebuilds every service.

This guide is for backend and platform teams running several gRPC services in one monorepo who want neither outcome.

  base ref ---- git diff ----> head ref
              |
              v  changed files
+---------------------------+      +----------------------------------+
| STEP 1: file -> project   |      | GAP: protos/user.proto           |
| owner = project whose     |- - ->| sits under no project root       |
| root contains the file    |      | -> maps to nothing, 0 affected   |
+---------------------------+      +----------------------------------+
              |
              v  owning projects
+---------------------------+      +----------------------------------+
| STEP 2: walk graph back   |      | GAP: no edge to proto project    |
| to every dependent        |- - ->| - import "common/money.proto";   |
| (edges from inference:    |      |   not parsed by Nx plugins       |
|  TS imports, manifests)   |      | - gitignored stubs: path missing |
+---------------------------+      |   or local to consumer           |
              |                    +----------------------------------+
              v  affected set                       |
+---------------------------+                       v
| run targets for affected  |      +----------------------------------+
| projects only             |      | FAILURE MODES                    |
+---------------------------+      | 1. Missed consumers: breaking    |
                                   |    change merges, services green |
                                   | 2. Full rebuilds: protos/** as   |
                                   |    global input, rebuild all     |
                                   +----------------------------------+

Nx maps changed files to owning projects and then walks dependency edges, so an unowned .proto file or a consumer with no inferred edge to it leads to missed consumers or, once patched with a global protos/* input, to full rebuilds.*

How the setup works end to end

The fix closes both gaps from the previous section. Each step in the setup corresponds to one of them.

  1. Close the ownership gap. Every .proto file lives under the root of a proto project. When the diff contains libs/proto/billing/billing.proto, step one maps the change to proto-billing instead of to nothing.
  2. Close the edge gap. Each proto project lists the proto projects it imports, and each service lists the proto projects it consumes, under implicitDependencies. Step two can then walk from proto-billing to billing-service. It can also walk from proto-common through proto-billing to billing-service, because Nx follows edges transitively.
  3. Make generation a cached task. A codegen target on each proto project runs the generator. Its inputs cover the schemas, the generator config, and the toolchain version. Its outputs cover the generated directory. If none of those inputs changed, Nx restores the stubs from cache and skips generation.
  4. Order the work. build depends on ^codegen, so every affected service regenerates its upstream stubs before it compiles.
  5. Verify continuously. CI diffs against the merge-base, fails if the base cannot be resolved, compares the declared edges against a reviewed snapshot, and periodically rebuilds everything without cache to expose edges nobody declared.

When a developer edits one field in billing.proto, the affected set is proto-billing plus the services that depend on it. codegen runs for proto-billing, and build and test run for those services. Every other service and every other proto project is left alone.

Structuring proto files as dedicated Nx projects

The first step is to make every contract a project Nx can reason about. A directory of .proto files with no project.json is invisible to the project graph. Nx can only treat it as a global input, or ignore it entirely. Giving each contract a project.json makes it a node in the graph. That node can carry tags, an owner in CODEOWNERS, and its own targets.

The main design decision is granularity. One shared protos library is easy to set up, but it rebuilds every service on every edit. Per-domain projects such as proto-billing, proto-inventory and proto-common limit the rebuild to the services that actually depend on the changed contract.

Layout Blast radius of one edit Config to maintain Ownership
Single protos library Every service that consumes any proto One project.json Shared, often unclear
Per-domain proto projects Only consumers of that domain One project.json per domain One team per contract
Per-domain plus common-types Domain consumers, or everyone for common edits Per-domain plus one shared Clear, plus a stewarded core

The third layout suits most teams. Shared messages such as money, pagination and error details live in proto-common. That project should change rarely and be reviewed carefully.

Nx does not parse import statements in .proto files, so it will not discover proto-to-proto dependencies by itself. You have to declare them, typically through implicitDependencies in each proto project's project.json. If billing.proto imports common/money.proto, then proto-billing declares a dependency on proto-common. Once the edges match the imports, an edit to proto-common marks proto-billing and every service consuming it as affected, transitively. The cost of this is maintenance: these edges are manual, so they drift unless something checks them.

cat > libs/proto/common/project.json <<'EOF'
{
  "name": "proto-common",
  "sourceRoot": "libs/proto/common",
  "projectType": "library",
  "tags": ["type:proto", "scope:shared"]
}
EOF

cat > libs/proto/billing/project.json <<'EOF'
{
  "name": "proto-billing",
  "sourceRoot": "libs/proto/billing",
  "projectType": "library",
  "tags": ["type:proto", "scope:billing"],
  "implicitDependencies": ["proto-common"]
}
EOF

cat >> .github/CODEOWNERS <<'EOF'
/libs/proto/common/   @example-org/platform-api
/libs/proto/billing/  @example-org/billing-team
EOF

npx nx show projects --affected --base=main --head=HEAD

The example adds a project.json for proto-common and one for proto-billing, assigns each to an owning team, and mirrors the billing.proto import of common/money.proto as an implicitDependencies edge, so a change to the common types marks billing and all of its consumers as affected.

Implementing explicit edges and cached codegen targets

Nx does not infer edges from .proto imports or from gitignored stubs, so service-to-proto edges have to be declared by hand. Because they are hand-written, you treat them as code, reviewed in the same pull request that changes an import.

Step 1: Declare the edges

In each service's project.json, list the proto projects it consumes under implicitDependencies. A billing service gets proto-billing. It does not need proto-common listed directly, because Nx follows the graph transitively. Keep each list limited to direct consumption so the graph stays readable.

Step 2: Add a codegen target

On each proto project, define a codegen target that uses the nx:run-commands executor to invoke buf generate or protoc from the project root. Nx can then schedule and cache the target like any other.

Step 3: Declare inputs and outputs precisely

The cache key must change whenever the generated code would change. That means the inputs should cover:

  • the project's .proto files
  • the generator config, such as buf.gen.yaml or the protoc invocation
  • any options files
  • a runtime input that captures the toolchain or plugin version

Set outputs to the generated code directory so Nx can restore it on a cache hit.

Step 4: Wire build ordering

In targetDefaults, give build a dependsOn of ^codegen. Every service then regenerates upstream stubs before it compiles.

The silent failures

If the inputs are too narrow, for example if they omit the plugin version, a plugin upgrade still produces a cache hit, and Nx replays stale stubs without any error. If the inputs are too broad, such as the whole workspace, every unrelated commit invalidates the cache, and you pay for full regeneration on every run.

// libs/proto-billing/project.json (Nx accepts comments in project.json)
{
  "name": "proto-billing",
  "implicitDependencies": ["proto-common"],
  "targets": {
    "codegen": {
      "executor": "nx:run-commands",
      "options": { "command": "buf generate", "cwd": "libs/proto-billing" },
      "inputs": [
        // Schemas: a changed message or rpc changes the stubs
        "{projectRoot}/**/*.proto",
        // Generator config: plugins, options and out paths
        "{projectRoot}/buf.gen.yaml",
        "{projectRoot}/buf.yaml",
        // Toolchain version: an upgrade must miss the cache
        { "runtime": "buf --version" },
        { "runtime": "protoc-gen-go --version" }
      ],
      // Restored on a cache hit; must match the out: paths in buf.gen.yaml
      "outputs": ["{projectRoot}/gen"]
    }
  }
}

// apps/billing-service/project.json: list direct consumption only
{
  "name": "billing-service",
  "implicitDependencies": ["proto-billing"]
}

// nx.json: every build regenerates upstream stubs first
{
  "targetDefaults": {
    "build": { "dependsOn": ["^codegen"] },
    "codegen": { "cache": true }
  }
}

A proto project's cached codegen target with narrowly scoped inputs and outputs, a service's implicitDependencies edge, and nx.json targetDefaults that run upstream codegen before build.

Committed generated code vs build-time generation

The two strategies differ mainly in where the source of truth for "what changed" lives: in git, or in the Nx task configuration.

With committed generated code, the stubs sit inside each service project and get checked in. When someone edits a .proto file and regenerates, the stub changes appear in the diff. nx affected then flags the consuming projects because their own files changed, even if a graph edge is missing. That safety net only holds if the developer actually regenerated. A proto edit without regeneration produces a diff that looks correct and stubs that are stale. To close that gap, CI has to run codegen and fail on any resulting change, for example with git diff --exit-code. You also accept large, noisy diffs in review, merge conflicts in files nobody should hand-edit, and churn whenever the generator version changes.

With build-time generation, stubs are gitignored and produced by a cached target. The repository stays clean, but nothing in git shows that a service depends on a proto. Correctness depends entirely on the explicit edges, on inputs that cover the proto sources and the generator version, and on dependsOn so that generation runs before compile.

Aspect Committed generated code Build-time generation
How affected detection works File diffs in consumers Graph edges only
Main failure mode Forgotten regeneration Wrong inputs or dependsOn
Review cost High, noisy diffs Low
Merge conflicts Frequent in stubs Rare
Required CI guard Regenerate-and-diff check Graph verification

Commit generated code when external consumers or tooling that cannot run your generator need the stubs, or when the team cannot yet maintain accurate edges. Otherwise, prefer build-time generation and treat the graph configuration as code that gets reviewed.

Wiring it into CI and verifying the graph

In CI, compute the base as the merge-base between the branch head and main, then run nx affected -t codegen build test with explicit --base and --head values. Use the merge-base and not the tip of main. If you diff against the tip, commits that landed on main after the branch was cut are counted as part of the branch, and the result describes someone else's changes.

Most false "nothing affected" results come from git history, not from Nx. A shallow clone may not contain the merge-base commit at all, so either fetch full history or fetch enough depth to reach it. A rebased or force-pushed branch can leave CI holding a base SHA that no longer exists in the branch's history. A hardcoded HEAD~1 only covers the last commit of a multi-commit push. In each case Nx diffs the wrong range and reports success. Make the job fail loudly if the base SHA cannot be resolved.

Verify the graph before you trust it. Run nx graph to inspect edges from each proto project. Run it with --affected to see what a given change would rebuild. Then do the touch-one-proto test: change a single field comment in one proto package and confirm that exactly its consuming services appear. If you see more, you have over-broad edges. If you see fewer, an edge is missing.

To catch drift in implicitDependencies, export the project graph to JSON in CI and compare the proto-to-service edges against a committed snapshot or a lint rule. Any unreviewed change should fail the build.

Finally, schedule a periodic full build of every project with the cache disabled. It catches the edges you never declared.

#!/usr/bin/env bash
set -euo pipefail

if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
  git fetch --unshallow --no-tags origin
fi
git fetch --no-tags origin main

HEAD_SHA=$(git rev-parse HEAD)
BASE_SHA=$(git merge-base origin/main "$HEAD_SHA") || {
  echo "ERROR: no merge-base with origin/main; refusing to guess" >&2; exit 1; }
git cat-file -e "${BASE_SHA}^{commit}" || {
  echo "ERROR: base ${BASE_SHA} not in local history" >&2; exit 1; }
echo "Affected range: ${BASE_SHA}..${HEAD_SHA}"

if [ "${FULL_BUILD:-false}" = "true" ]; then
  npx nx run-many -t codegen build test --skip-nx-cache
  exit 0
fi

npx nx graph --file=graph.json
jq -r '.graph.dependencies | to_entries[] | .value[]
  | select(.target | startswith("proto-"))
  | "\(.source) -> \(.target) (\(.type))"' graph.json | sort -u > proto-edges.txt
if ! diff -u ci/proto-edges.snapshot proto-edges.txt; then
  echo "ERROR: proto dependency edges changed; review and update ci/proto-edges.snapshot" >&2
  exit 1
fi

npx nx affected -t codegen build test --base="$BASE_SHA" --head="$HEAD_SHA"

A CI script that unshallows the clone, computes and validates the merge-base with main, checks proto-to-service graph edges against a committed snapshot, and then runs nx affected, with an opt-in uncached full build for scheduled runs.

What affected cannot tell you: compatibility and limits

Affected decides what to rebuild. It does not decide whether a change is wire-compatible. The project graph tracks files and edges, not schema meaning, and that gap shows up in three ways.

Over-triggering on harmless edits

A fixed typo in a comment or a new field that nobody reads still changes the file hash of a widely shared proto. Every consumer is then marked affected. Generated code is identical in many of these cases, so the cache absorbs part of the cost, but tests and deploy steps downstream may still run. If one proto sits under dozens of services, split it by domain so that edits stay local.

Under-reporting real risk

Affected only sees projects in this workspace at this commit. It knows nothing about:

  • clients in other repositories,
  • mobile apps that users have not updated,
  • service versions that are already deployed and will run next to the new build during a rollout.

It also misses behavioral changes that leave the schema untouched. For example, a field whose meaning changes from milliseconds to seconds, or a default that callers relied on, when that contract lives only in a comment.

Supplementing it

Add a breaking-change check, such as buf breaking run against the main branch, as a target on each proto project. Because the target lives on the proto project, affected runs it exactly when the schema changes.

Pair the check with versioning discipline:

  • Never reuse or renumber field tags. Reserve removed numbers and names.
  • Put incompatible changes in a new versioned package such as v2. Do not edit v1 in place.
  • Record semantic contracts in field names and types, not in comments alone.

When to use this setup

This setup pays off when these conditions hold together:

  • Several gRPC services share contracts in one Nx monorepo. Selective rebuilds only help when there is something to skip. With many services, a typical proto edit touches a small fraction of them.
  • Full builds are slow enough to hurt. If a full build and test run keeps PRs waiting, cutting it down to the affected set gives engineers back time on every change.
  • Contracts split cleanly by domain. Billing, inventory and shared types can each become their own proto project with a small, well-defined set of consumers.
  • Teams own specific contracts. Per-domain proto projects line up with CODEOWNERS, so each schema change is reviewed by the team responsible for it.
  • Someone will maintain the graph. Manual edges need review on every import change and a CI drift check. The setup is only as reliable as that discipline.

When to avoid it

Skip or delay this setup in these cases:

  • Few services or fast builds. With a handful of services and a short full build, rebuilding everything on every change is simpler, and it cannot miss a consumer.
  • Contracts that cannot be split. If nearly every service imports nearly every message, every proto edit marks almost everything as affected. You pay for the edge maintenance and get close to a full build anyway.
  • No owner for the graph configuration. Undeclared or stale implicitDependencies produce green builds for broken consumers. If nobody will review edges and keep the drift check current, a full build is safer.
  • Most consumers live outside the monorepo. If the important clients are in other repositories or in shipped mobile apps, affected cannot see them. A separate proto repository with versioned packages, or a schema registry, fits better. Breaking-change checks matter more than selective rebuilds in this case.
  • The repository does not use Nx yet. Adopting Nx only to get affected builds for protos is a large change. Weigh it against the alternatives below before committing.

Alternatives and impact on cost and engineering time

Each alternative to affected-based builds moves the cost to a different place.

Approach Where the cost lands Fits when
Always rebuild everything CI minutes and PR wait time on every change Few services, fast builds, no one to maintain graph edges
Separate proto repo, versioned packages Release steps and version skew between services Many teams with independent deploy cadences, or consumers outside the monorepo
Schema registry, codegen outside the monorepo Running the registry and the generation pipeline Polyglot consumers and centrally enforced compatibility checks

Illustrative estimate (assumed numbers, not a benchmark)

Assumed inputs: 200 PRs per week, a 30-minute full build, an 8-minute average affected build, 40 engineer-hours of setup, and 2 engineer-hours per week of edge maintenance.

  • CI time saved per week = PRs × (full − affected) = 200 × (30 − 8) = 4,400 minutes, or about 73 CI hours.
  • Net weekly value = (CI hours saved × CI cost per hour) + (developer wait reduction × engineer rate) − (maintenance hours × engineer rate).
  • Payback in weeks = setup hours × engineer rate ÷ net weekly value.

Plug in your own rates. Wait time usually matters more than raw CI minutes because engineers are blocked while a PR builds.

Two things erode these savings:

  • Coarse proto projects. A single proto project that every service depends on marks every service affected on any edit. The average affected build then approaches the full build, and the savings go toward zero.
  • Graph drift. A missing edge under-builds, so breakage reaches main. A stale extra edge over-builds and quietly wastes CI time. Audit the graph regularly, or the maintenance line in the estimate will grow.

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