What a proto contract library is and who it is for
A proto contract library treats your .proto files as a first-class Nx project rather than a folder that services copy from. The project owns three kinds of targets: lint, which enforces naming and style rules; breaking, which compares the current schemas against a known-good baseline; and codegen, which produces client and server stubs. Generated output goes into separate per-language libraries, so each service depends only on the code for its own language.
Without this setup, a team usually copies .proto files into each repository or maintains client stubs by hand. Both approaches let services drift apart without anyone noticing. A field renamed in one copy and not in another compiles fine and then fails at runtime as a decoding error or a silently dropped value. A single contract library removes that drift because every consumer is built from the same schema. Nx also records the dependency explicitly, so a schema change shows up as affected work in the project graph.
libs/contracts/proto
(lint, breaking, codegen)
/ \
v v
libs/contracts/ts libs/contracts/go
| |
v v
apps/orders-api apps/billing-svc
(Node.js) (Go)
This approach helps most if you are a backend or platform engineer running several gRPC services in one Nx workspace, especially when those services are written in more than one language. If you have one service in one language, you get less from it.
+------------------------------+
| libs/contracts/proto |
| lint | breaking | codegen |
+--------------+---------------+
|
+-----------------+-----------------+
| |
v v
+-----------------------+ +-----------------------+
| libs/contracts/ts | | libs/contracts/go |
| generated TS code | | generated Go code |
+-----------+-----------+ +-----------+-----------+
| |
v v
+-----------------------+ +-----------------------+
| apps/orders-api | | apps/billing-svc |
| Node.js gRPC service | | Go gRPC service |
+-----------------------+ +-----------------------+
The proto project owns the lint, breaking and codegen targets and feeds per-language generated libraries, and each service depends only on the library for its own language, so a schema change marks both services as affected in the Nx project graph.
Laying out the proto library and configuring buf
Put the .proto files under libs/contracts/proto and make every directory path match its package name. A file declaring package acme.orders.v1 lives at acme/orders/v1/orders.proto. With that layout, an import such as acme/common/v1/money.proto resolves the same way whether buf or plain protoc -I libs/contracts/proto compiles it, and editors and language plugins find files without extra include paths. buf's STANDARD lint category includes a rule that enforces this match, so the layout stays consistent without anyone policing it.
buf.yaml
Declare libs/contracts/proto as the module root. Enable STANDARD lint rules and FILE-level breaking checks; the FILE level is the strictest of the built-in breaking categories, and it catches changes that break generated code, not only wire-format changes. buf already bundles the well-known types (google/protobuf/timestamp.proto and similar), so you don't declare them. For googleapis annotations, add the googleapis module as a dependency rather than copying its files into the repo. buf.lock pins the exact commit, so you get reproducible resolution without vendored files that slowly go stale.
buf.gen.yaml
Give each plugin an explicit version and an out path that points into a generated library, such as libs/contracts/generated/ts or libs/contracts/generated/go. Pinning matters for two reasons. An unpinned plugin can change its output between runs, which produces noisy diffs. Those diffs also change the files Nx hashes, so you lose cache hits across machines.
Remote plugins and deps need no per-language toolchain on the machine, but every codegen run then needs network access to the registry. Local plugins work offline. The cost is that you install and pin protoc-gen-go, the TypeScript generator, and any others yourself, on every developer machine and CI image.
# buf.yaml (repo root): one module rooted at the proto library
version: v2
modules:
- path: libs/contracts/proto
deps:
# googleapis comes from the registry; buf.lock pins the exact commit
- buf.build/googleapis/googleapis
lint:
use:
- STANDARD # includes the rule that directories match package names
breaking:
use:
- FILE # strictest built-in category; catches generated-code breaks
---
# buf.gen.yaml (repo root): pinned plugins writing into generated libs
version: v2
managed:
enabled: true
override:
# Derive Go import paths from the output location
- file_option: go_package_prefix
value: github.com/acme/monorepo/libs/contracts/generated/go
disable:
# Keep googleapis' own go_package instead of rewriting it
- module: buf.build/googleapis/googleapis
plugins:
# Remote plugins: version pinned after the colon, no local toolchain needed
- remote: buf.build/protocolbuffers/go:v1.34.2
out: libs/contracts/generated/go
opt: paths=source_relative
- remote: buf.build/bufbuild/es:v2.2.0
out: libs/contracts/generated/ts
opt: target=ts
# Offline alternative: a local binary on PATH, pinned in package.json
# - local: protoc-gen-es
# out: libs/contracts/generated/ts
# opt: target=ts
A buf.yaml declaring the proto module, STANDARD lint, FILE breaking checks and a googleapis dependency, plus a buf.gen.yaml with version-pinned remote plugins writing into the generated Go and TypeScript libraries and a commented-out local-plugin alternative.
Wiring buf into Nx targets, dependencies and caching
Each buf command becomes an Nx target on the proto project, using the nx:run-commands executor. Define lint to run buf lint, breaking to run buf breaking against your main branch, and generate to run buf generate. Nx then handles scheduling, caching and nx affected for these commands the same way it does for any other target.
Caching only works if inputs and outputs are complete. Nx hashes the declared inputs and replays the stored outputs when the hash matches. For generate, the inputs should be every .proto file in the project plus buf.yaml, buf.gen.yaml and buf.lock. The outputs should be the generated directories inside the TypeScript and Go libraries.
If you leave an input out, the cache returns stale code without any warning. Suppose buf.gen.yaml is missing from the inputs and someone changes a plugin option. The hash stays the same, so Nx restores the previous output. That output compiles cleanly and is wrong.
Next, connect the consumers. Add the proto project to implicitDependencies in each generated library, because no import statement links them. Then give those libraries' build targets a dependsOn entry on the proto project's generate target. With this in place, a .proto change marks the libraries as affected and regenerates code before anything compiles.
A single proto project has a real cost: editing one message marks every consumer as affected. Splitting the protos into one Nx project per domain package narrows the affected set. The trade-off is more project.json files and more dependency edges to keep accurate.
{
"name": "proto",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/proto",
"targets": {
"lint": {
"executor": "nx:run-commands",
"cache": true,
"inputs": ["{projectRoot}/**/*.proto", "{projectRoot}/buf.yaml", "{projectRoot}/buf.lock"],
"options": { "command": "buf lint", "cwd": "libs/proto" }
},
"breaking": {
"executor": "nx:run-commands",
"options": {
"command": "buf breaking --against '../../.git#branch=main,subdir=libs/proto'",
"cwd": "libs/proto"
}
},
"generate": {
"executor": "nx:run-commands",
"cache": true,
"inputs": [
"{projectRoot}/**/*.proto",
"{projectRoot}/buf.yaml",
"{projectRoot}/buf.gen.yaml",
"{projectRoot}/buf.lock"
],
"outputs": [
"{workspaceRoot}/libs/proto-ts/src/gen",
"{workspaceRoot}/libs/proto-go/gen"
],
"options": { "command": "buf generate", "cwd": "libs/proto" }
}
}
}
A libs/proto/project.json defining lint, breaking and generate targets with nx:run-commands, where generate lists every proto and buf config file as inputs and both generated library directories as outputs.
Consuming generated clients from Node and Go services
…edges to keep accurate. Once the library builds, the consumers should know as little about its internals as possible.
On the Node side, the libs/contracts/ts project exposes a single entry point, typically an index.ts that re-exports the generated message types and service clients. A path alias in tsconfig.base.json, such as @org/contracts, maps to that entry point, so an order service imports @org/contracts and never libs/contracts/ts/src/gen/orders/v1/orders_pb. Nx detects the import and adds the edge to the project graph, which means nx affected picks up the service when a proto changes.
The Go side follows the same rule with a different mechanism. libs/contracts/go has its own go.mod with a module path such as github.com/org/monorepo/libs/contracts/go. Services resolve it through a go.work file or a replace directive and import packages by that path. Nx does not infer Go imports without a plugin, so declare the dependency explicitly in the service's project.json.
The barrel file and the module path decouple consumers from the generator's output layout. If you change buf plugins, rename output directories or switch from protoc-gen-go-grpc to another generator, you update one entry point instead of every service.
The cost lands in CI. A pipeline that builds both languages needs Node, Go, buf and every protoc plugin installed on each runner, and those plugin versions must match what developers run locally. Pin them, cache the toolchain layer, and let Nx's computation cache skip codegen when the inputs have not changed.
// libs/contracts/ts/src/index.ts
// The only public entry point of the contracts library.
// Consumers never import from ./gen directly, so the generator's
// output layout can change without touching any service.
export * from './gen/orders/v1/orders_pb';
export * from './gen/payments/v1/payments_pb';
// tsconfig.base.json (excerpt, shown as a comment)
// "paths": {
// "@org/contracts": ["libs/contracts/ts/src/index.ts"]
// }
// apps/orders-service/src/orders.ts
// Import through the alias, not through a deep path such as
// 'libs/contracts/ts/src/gen/orders/v1/orders_pb'.
// Nx sees this import and adds orders-service -> contracts-ts
// to the project graph, so `nx affected` includes this service
// whenever a .proto file changes.
import { create } from '@bufbuild/protobuf';
import { GetOrderRequestSchema, type GetOrderRequest } from '@org/contracts';
export function buildGetOrderRequest(orderId: string): GetOrderRequest {
// Message construction relies only on exported schema and type names,
// not on where the generator put the files.
return create(GetOrderRequestSchema, { orderId });
}
// The Go service gets no inferred edge. Without a Go plugin,
// declare it in apps/billing-service/project.json:
// "implicitDependencies": ["contracts-go"]
The TypeScript contracts library exposes one barrel file behind a path alias, so services import @org/contracts and not generated folders. The Go consumer instead declares its dependency on the contracts project explicitly in project.json.
Commit generated code or generate in CI?
The choice between committing generated code and generating it in CI comes down to where you want the toolchain cost to land.
| Concern | Commit generated code | Generate in CI |
|---|---|---|
| Build simplicity | Consumers build with plain go build or tsc; no codegen step |
Every build depends on the codegen target running first |
| Reviewability | Reviewers see the exact client changes a .proto edit causes |
Reviewers infer impact from the .proto diff alone |
| Diff noise | Large, mechanical diffs; needs linguist-generated or similar to collapse |
Diffs contain only intent |
| Drift risk | Checked-in output can fall out of sync with the source | None; output always matches source |
| Toolchain needs | Only contributors who edit protos need buf and plugins | Every developer and every runner needs them |
If you commit, drift is the real risk, and a CI check closes it. Run the same Nx codegen target developers use, then run git diff --exit-code scoped to the generated directories. A non-zero exit means someone edited a .proto without regenerating, or regenerated with a different plugin version. Also check for untracked files (git status --porcelain), because a new message file appears as an untracked file, which a plain diff does not report.
Small teams with uneven local setups should commit. The CI check makes drift visible, and most contributors never need the plugins installed. Larger teams with a pinned, containerised toolchain can generate in CI. Their builds already pay the setup cost, Nx caching absorbs the repeat runs, and generated code stays out of every diff.
Breaking-change detection and versioning across deploy schedules
Whichever generation strategy you pick, the contract itself needs a gate. Run buf breaking in CI against the main branch so that a renumbered field, a changed scalar type or a removed enum value fails the pull request instead of surfacing after deploy. Pointing the check at main's git ref, rather than at the last release tag, catches the problem on the PR that introduces it.
It is tempting to skip this because the monorepo makes changes atomic: one commit updates the .proto file, the server and every client together. That atomicity exists only in git. Services still deploy on their own schedules, so during a rollout an old client talks to a new server, or the reverse, and the bytes on the wire must still decode. A rename is harmless in binary protobuf, but it breaks JSON transcoding. A field number reused with a new type silently corrupts data. The breaking check enforces wire compatibility, which matters at runtime, not just source compatibility.
When a change genuinely cannot be compatible, add a new package (orders.v2 alongside orders.v1) instead of forcing one. Serve both until every consumer has migrated. This matters most for clients outside your deploy cadence, such as mobile apps, partner integrations or teams that release monthly. Delete v1 only once traffic to it has stopped.
Breaking checks only see structure. If you reinterpret a field's units, tighten validation or change a default's meaning, buf will pass it. Catch those in review and in contract tests.
When to avoid it, alternatives, and impact on cost and engineering time
The setup pays off only when several projects in the same repo consume the same contracts. It is a poor fit in three cases:
- One service in one language with few consumers. The project graph and multi-language codegen add machinery without removing much work.
- Contracts shared mainly with external teams in separate repos. Affected-project detection stops at the repo boundary, so those teams still need published artifacts.
- No one owns multi-language toolchains in CI. Every new generator plugin becomes another tool to keep pinned in the CI image.
| Approach | Coupling | Release overhead | Drift risk |
|---|---|---|---|
| Proto library in the monorepo | High (atomic changes) | Low | Low |
| Separate proto repo, published packages | Medium | High (publish, then bump) | Medium |
| Schema registry | Low | Medium | Low if checks are enforced |
| Copying protos per repo | None | Low | High |
| Hand-written stubs | None | Low at first | Highest |
The cost is mostly upfront: buf config, Nx targets, and a CI image with every generator. In return you stop syncing stubs by hand, catch breaking changes at PR time instead of in staging, and rebuild only the affected projects.
Illustrative estimate (assumed numbers, not a benchmark). Assume 3 days of setup, 2 contract changes per week, and 1.5 hours of manual stub syncing saved per change.
Break-even (weeks) = setup hours ÷ (changes per week × hours saved per change) = 24 ÷ (2 × 1.5) = 8 weeks.
Plug in your own numbers. If changes are rare, the setup may never pay for itself.
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 protobuf nx monorepo, 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.