How Nx sees an OpenAPI spec and its generated client

Nx decides what is affected by walking a project graph, not by diffing individual files against each other. A changed file marks its owning project as affected, and that status propagates to every project that depends on it. The pattern therefore treats the OpenAPI document as a first-class project. For example, libs/api-specs/orders owns openapi.yaml and nothing else. The generated client, say libs/clients/orders, is a separate project that owns a generate-client target and the emitted TypeScript.

The split matters because Nx builds most graph edges by analyzing source imports. When apps/checkout imports from the client library, Nx records that edge automatically. Nothing imports a YAML file, so Nx has no way to infer that the client is derived from the spec. Without help, editing openapi.yaml marks only the spec project as affected, and the stale client never regenerates.

You close that gap by declaring the relationship. Add the spec project to the client's implicitDependencies. To also control task ordering, configure dependsOn on the client's targets, for example to run a validation target on the spec first.

libs/api-specs/orders  (openapi.yaml)
        │
        │  DECLARED: implicitDependencies / dependsOn
        ▼
libs/clients/orders  ── target: generate-client
        │
        │  INFERRED: TypeScript imports
        ▼
apps/checkout   apps/admin

Once the declared edge exists, a spec change cascades through the inferred edges to every consuming app.

┌──────────────────┬───────────────────┐
│ libs/api-specs/orders                │
│   owns openapi.yaml only             │
└──────────────────┬───────────────────┘
                   │ DECLARED edge (client's project config):
                   │   implicitDependencies -> spec project
                   │   optional dependsOn -> e.g. spec validate
                   │ Nx cannot infer this: nothing imports YAML
                   ▼
┌──────────────────────────────────────┐
│ libs/clients/orders                  │
│ ┌────────────────┬─────────────────┐ │
│ │ target: generate-client          │ │
│ │ reads openapi.yaml, emits TS     │ │
│ └────────────────┬─────────────────┘ │
│                  ▼                   │
│   generated TypeScript client        │
└──────────────────┬───────────────────┘
                   │ INFERRED edges: apps import the client
         ┌─────────┴─────────┐
         ▼                   ▼
┌─────────────────┐ ┌─────────────────┐
│ apps/checkout   │ │ apps/admin      │
└─────────────────┘ └─────────────────┘

DECLARED = written in config   INFERRED = found by Nx import analysis
A change to openapi.yaml flows down every arrow via nx affected.

The spec-to-client edge must be declared because Nx cannot infer it from imports, while the client-to-app edges are inferred, so a spec change marks the client and every consuming app as affected.

Defining the generate-client target with precise inputs and outputs

Start with two projects. libs/api-specs/orders holds openapi.yaml and its $ref'd fragments, and its project.json declares no targets. libs/clients/orders owns the generator config, any custom templates, and a generate-client target that uses the nx:run-commands executor. Its command option invokes your generator against the spec path and writes to {projectRoot}/src/generated. If you prefer, a generate-client script in the client's package.json works the same way. Nx infers it as a target, and you can put inputs and outputs in the nx property of that file.

In nx.json, define a named input called openapi. It should include:

  • {projectRoot}/**/*.yaml and {projectRoot}/**/*.json, which pick up the spec, its fragments, and the generator config
  • {projectRoot}/templates/**/*
  • an externalDependencies entry naming the generator package, so a version bump changes the hash

If the generator runs through a downloaded JAR or a container image, the lockfile may not capture its version. In that case pin the version explicitly in the config file, where it already counts as an input. Exclude !{projectRoot}/README.md and !{projectRoot}/CHANGELOG.md.

Set the target's inputs to ["openapi", "^openapi"]. The ^ form hashes the spec project's matching files through the declared dependency. Set outputs to ["{projectRoot}/src/generated"] so that a cache hit restores the files and doesn't just replay the logs.

In targetDefaults, give build, typecheck, and test a dependsOn of ["^generate-client"].

The most dangerous mistake here is a missing input. If you forget the templates directory, Nx replays a stale client, the build passes, and the drift only shows up at runtime. Making the inputs too broad has the opposite cost. {projectRoot}/**/* brings the README back in and regenerates on every typo fix, which defeats the point of this setup.

{
  "namedInputs": {
    "openapi": [
      "{projectRoot}/**/*.yaml",
      "{projectRoot}/**/*.json",
      "{projectRoot}/templates/**/*",
      { "externalDependencies": ["@openapitools/openapi-generator-cli"] },
      "!{projectRoot}/README.md",
      "!{projectRoot}/CHANGELOG.md"
    ]
  },
  "targetDefaults": {
    "generate-client": {
      "cache": true,
      "inputs": ["openapi", "^openapi"],
      "outputs": ["{projectRoot}/src/generated"]
    },
    "build": { "dependsOn": ["^generate-client"] },
    "typecheck": { "dependsOn": ["^generate-client"] },
    "test": { "dependsOn": ["^generate-client"] }
  }
}

nx.json with an 'openapi' named input covering specs, $ref fragments, generator config, templates and the generator package version (README and CHANGELOG excluded), generated-directory outputs, and build/typecheck/test depending on ^generate-client.

Verifying cache behavior and determinism

Before wiring this into CI, prove the cache does what you think. Run the target, for example nx run api-client:generate-client, then run it again unchanged. The second run should be reported as restored from cache, with no generator process started. If it runs again, something in your inputs changes between runs. A common cause is an output directory that is also matched by an input glob.

Next, edit a file outside the declared inputs, such as the project README, and run again. The task should still come from cache. Then change a field in a $ref fragment that the root spec pulls in, not the root file itself. This must trigger regeneration. If it doesn't, the fragment directory is missing from your inputs, and you have reproduced the silent-drift bug from the previous section on purpose rather than in production.

Determinism matters just as much. Many generators stamp a timestamp, generator version banner or random ordering into file headers. The Nx hash of generate-client won't notice, because hashes are computed from inputs. But every consumer that lists the generated files as inputs gets a new hash on every real run, so downstream builds and tests miss the cache. Turn these banners off through generator options, or strip them in a post-processing step inside the same target.

With a remote cache, misconfigured outputs become a trust problem. If two different specs can produce the same hash because inputs are too narrow, one machine's stale client gets served to everyone. Restrict write access to the remote cache to CI, and keep local machines read-only.

#!/usr/bin/env bash
# Verify caching and determinism of the client generator before wiring it into CI.
# Run from a clean git working tree; probe edits are reverted with git checkout.
set -euo pipefail

TARGET="api-client:generate-client"
OUT="libs/api-client/src/generated"
FRAGMENT="libs/api-client/openapi/components/schemas/user.yaml"

# Nx's cache-hit wording can differ between versions; adjust the pattern if needed.
expect_cached() { npx nx run "$TARGET" | tee /tmp/nx.log; grep -q "from the cache" /tmp/nx.log || { echo "FAIL: $1"; exit 1; }; }
expect_rerun()  { npx nx run "$TARGET" | tee /tmp/nx.log; ! grep -q "from the cache" /tmp/nx.log || { echo "FAIL: $1"; exit 1; }; }

# 1. Warm the cache, then an unchanged rerun must be a cache hit.
npx nx run "$TARGET" > /dev/null
expect_cached "unchanged rerun executed the generator"

# 2. An edit outside the declared inputs must not invalidate the cache.
echo "<!-- cache probe -->" >> libs/api-client/README.md
expect_cached "README edit invalidated the cache (inputs too broad)"
git checkout -- libs/api-client/README.md

# 3. An edit to a $ref fragment (not the root spec) must force regeneration.
echo "# cache probe" >> "$FRAGMENT"
expect_rerun "fragment edit was served from cache (fragment dir missing from inputs)"
git checkout -- "$FRAGMENT"

# 4. Determinism: two forced runs on identical inputs must produce identical files.
#    Differences here usually mean timestamps or version banners in file headers.
npx nx run "$TARGET" --skip-nx-cache > /dev/null
rm -rf /tmp/gen-a && cp -r "$OUT" /tmp/gen-a
sleep 2  # make a timestamp-based header change visible
npx nx run "$TARGET" --skip-nx-cache > /dev/null
diff -r /tmp/gen-a "$OUT" || { echo "FAIL: generator output is not deterministic"; exit 1; }

echo "OK: cache hits, misses and output determinism behave as expected"

A shell check that confirms an unchanged rerun and an unrelated README edit are served from the Nx cache, that a $ref fragment edit forces regeneration, and that two forced runs produce byte-identical output.

Running nx affected in CI with correct base and head

Locally, nx affected falls back to defaults that rarely match what CI needs. In a pipeline, pass both ends of the comparison explicitly: nx affected -t generate-client build test --base=$BASE_SHA --head=$HEAD_SHA. Nx diffs the files between those two commits, maps them to projects, and walks the project graph. A changed spec marks the spec project, its generated client, and every consumer of that client as affected.

Choosing the base

On a pull request, the base should be the merge base of the PR branch and its target branch, which git merge-base origin/main HEAD returns. If you diff against the current tip of main instead, unrelated commits that landed on main since the branch was cut get attributed to the PR, and a spec change someone else merged triggers client regeneration in your build.

On main itself, use the SHA of the last successful pipeline run on that branch. Diffing against HEAD~1 is simpler, but if a run fails and the next push succeeds, the changes from the failed run are never verified. Track the last green SHA so the range covers everything that has not yet passed.

Shallow clones

Many CI checkouts fetch only the latest commit by default. If the base SHA is not in the local history, Nx cannot compute the diff. Depending on how the error is handled, the pipeline either treats every project as affected or treats nothing as affected and passes. Both failures are quiet. The first appears as slow builds that people stop questioning. The second lets a spec change ship with a stale client. Fetch enough history to include the base commit, and add a step that fails the job when git cat-file -e $BASE_SHA does not succeed.

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

# Inputs supplied by the pipeline (placeholder names, map from your CI's variables):
#   IS_PULL_REQUEST=true|false
#   TARGET_BRANCH=main
#   LAST_GREEN_SHA=<SHA of the last successful run on main>

HEAD_SHA=$(git rev-parse HEAD)

# CI checkouts are often shallow; fetch full history so the base commit is reachable.
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
  git fetch --no-tags --unshallow origin
fi
# Explicit refspec so origin/$TARGET_BRANCH exists even with single-branch checkouts.
git fetch --no-tags origin "+refs/heads/${TARGET_BRANCH}:refs/remotes/origin/${TARGET_BRANCH}"

if [ "$IS_PULL_REQUEST" = "true" ]; then
  # PR: diff from where the branch diverged, not the moving tip of main.
  BASE_SHA=$(git merge-base "origin/${TARGET_BRANCH}" HEAD)
else
  # main: cover everything since the last green run, not just HEAD~1.
  BASE_SHA="${LAST_GREEN_SHA:?LAST_GREEN_SHA must be set for main builds}"
fi

# Fail loudly instead of letting Nx silently run everything or nothing.
if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
  echo "Base commit ${BASE_SHA} not in local history; refusing to compute affected" >&2
  exit 1
fi

echo "Affected range: ${BASE_SHA}..${HEAD_SHA}"
npx nx affected -t generate-client build test --base="$BASE_SHA" --head="$HEAD_SHA"

A CI-agnostic shell step that picks the merge base on pull requests or the last green SHA on main, ensures that commit exists despite shallow checkouts, and passes explicit --base and --head to nx affected.

Committed clients, build artifacts, code-first specs and external specs

Whether the generated client lives in Git changes what "affected" protects you from.

Committed client Client as build artifact
Review Spec changes show up as readable client diffs Reviewers see only the spec
Without Nx Consumers compile straight from checkout Fresh clones and IDEs show errors until generation runs
Noise Large diffs, merge conflicts in generated files Clean history
Required guard CI regenerates and fails on git diff Generation must precede typecheck, lint and test

Committing works well when other teams consume the client outside the workspace. It is only safe with the regenerate-and-diff check, because a hand edit or a forgotten regeneration otherwise ships silently. Artifacts suit monorepos where every consumer already runs through Nx. In that case, wire generation as a dependency of every target that reads the client, so no one has to remember to run it first.

For code-first specs, make spec emission its own target and have client generation depend on it. Any server change now marks the client affected, which is correct but broad. The saving comes only when the emitted spec is byte-identical to a cached one. Emission must therefore be deterministic, with stable key order and no timestamps.

For third-party or separately versioned specs, vendoring the file gives you reproducible builds and explicit update PRs. A fetch step whose output becomes a hashed input stays current, but the same commit can produce different clients on different days. Prefer pinning to a version or digest, and schedule a job that proposes updates.

Limits of file-based change detection and when to avoid this setup

Change detection here works on bytes, not meaning. Reformatting the spec, reordering keys, or fixing a typo in a description field changes the file hash, so the client project and every consumer downstream of it are marked affected. The rebuild produces an identical client, and the cache cannot help because the input really did change. If your specs are edited often for documentation, normalize them before hashing: parse, sort keys, strip descriptions and examples, and write a canonical file. Make that canonical file the input to generate-client instead of the raw spec.

The opposite failure is worse. If a handler starts returning a different shape and nobody updates the spec, the graph has no edge to follow. Nothing is affected, and the client is silently wrong. This setup only works if the spec is the contract and drift is caught elsewhere, for example by contract tests against the running service.

Breaking-change detection belongs in its own target that also runs under nx affected. That target compares the canonical spec at the base commit with the one at head and fails on removed paths, newly required fields, or narrowed types. Keep it separate from generation so a breaking change blocks the PR whether or not the client is cached.

Each API adds a project definition with inputs, outputs, and dependencies to maintain. For a handful of stable APIs owned by one team, a single script that regenerates every client, with its outputs cached, is usually simpler and fast enough. The graph earns its upkeep once many teams consume many specs and over-triggering costs real CI time.

Who benefits, alternatives, and impact on cost and engineering time

generators are slow enough that skipping one is noticeable. Platform and backend teams in monorepos with dozens of specs, several consuming apps per spec, or generators that spin up a JVM or container per run see the clearest return.

Approach Setup cost CI time Staleness risk Maintenance
Manual regeneration None Lowest High: depends on memory Low, until a stale client ships
Always-regenerate pre-build script Low Highest: every client, every run Low Low
Single cached workspace-wide target Low All-or-nothing: any spec change regenerates everything Low Low
Per-spec Nx projects with affected Moderate Proportional to what changed Low if inputs are correct Ongoing input upkeep

The time savings come from three places. Regeneration happens automatically, so nobody forgets it. Clients whose specs didn't change drop out of the CI graph entirely, along with their downstream builds and tests. When a client does need regenerating, a cache hit replaces a generator run with a file restore.

Illustrative estimate (assumed numbers, not a benchmark). Assume 30 specs, a 40-second generator run per client, 200 CI runs per week, and 2 specs touched per run on average.

Weekly generator time saved = (specs − specs changed per run) × seconds per run × runs per week = (30 − 2) × 40 × 200 = 224,000 s, about 62 hours of CI compute.

Plug in your own counts. Cache hits on the remaining runs widen the gap further.

The gain has an ongoing cost. Every new generator config file, template override, or shared schema directory has to be added to inputs. If one is missed, the cache serves a stale client and nothing fails to warn you. Someone has to own that list.

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 openapi client generation, 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.