Why one shared type is not one contract at runtime
An Nx shared library for DTOs is a library project, usually under libs/, that holds the TypeScript interfaces and classes describing request bodies, responses and message payloads. The API and the workers import it through a path alias. When someone renames a field, every consumer fails to compile until it is updated. That compile error is the main reason teams use a shared library: it shows mismatches in the editor before they reach production.
That guarantee applies only to a single build. At runtime, the API and each worker are separate artifacts with their own build and deploy schedules. A rolling deploy runs old and new instances side by side for minutes or hours. Queues, retry backlogs and dead-letter queues hold JSON that was serialized by code that may no longer be running anywhere. The type that described those messages no longer exists in the repo, but the messages still have to be processed.
Producer v1 ──┐ ┌──> Worker v1 (expects userId)
├──> [ queue / DLQ ] ────┤
Producer v2 ──┘ {userId:..} v1 └──> Worker v2 (expects accountId)
{accountId:..} v2
{userId:..} v1 (retry from yesterday)
During the rollout shown above, either worker can receive either payload shape.
The most common failure is editing a type in place and fixing every compile error in one PR. The change merges atomically, so it looks safe. It does not deploy atomically, and the type system cannot check payloads that are already in the queue.
COMPILE TIME: one repo, one PR, every consumer type-checks together
+-----------------------------------+
| libs/dto: UserEvent { accountId } |
+-----------------+-----------------+
| imported via path alias
+----------------+----------------+
v v
+------------+ +------------+
| api build | |worker build|
+------------+ +------------+
===========================================================================
RUNTIME: separate artifacts, rolling deploy, old and new side by side
+-------------+ +-----------------------+
| Producer v1 |---+ +--->| Worker v1 (old pods) |
| old API pod | | | | reads msg.userId |
+-------------+ | +---------------+ | +-----------------------+
+->| queue / DLQ |--+
+-------------+ | |---------------| | +-----------------------+
| Producer v2 |---+ | {userId} v1| +--->| Worker v2 (new pods) |
| new API pod | | {accountId} v2| | reads msg.accountId |
+-------------+ | {userId} v1| +-----------------------+
| (retry, 1d) |
+---------------+
Either worker can pull either shape; no compiler checked these bytes.
One shared DTO type keeps both builds consistent at compile time, but during a rolling deploy the queue holds v1 and v2 payloads, including old retries, that old and new workers can each receive.
Who needs a versioned contract library and when to avoid it
The versioned contract library pays off when producers and consumers run different builds of the same type at the same time. That happens in two common setups. The first is an API and its workers deploying on separate pipelines. The second is any queue, stream or retry table that holds messages longer than one deploy. A job enqueued by yesterday's API can be picked up by today's worker. If the queue retains messages for days, it can also be picked up by next week's worker. Backend and platform teams in these setups need explicit versions and compatibility rules, because the compiler only checks code that runs together.
The process is overkill when both sides always ship as one unit. Internal request and response types between an API and a frontend, built and deployed together with nothing persisted in between, can be edited in place. Versioning them adds ceremony without removing any risk.
Non-TypeScript consumers and external clients change the question. A Go worker or a partner integration cannot import your Nx library. They need a language-neutral schema, such as JSON Schema, OpenAPI or Protobuf. That schema then competes with TypeScript as the source of truth. Pick one and generate the other, or the two will drift.
| Situation | Approach |
|---|---|
| API and frontend, deployed together | Shared types, edit in place |
| API and workers, deployed independently | Versioned contract library |
| Durable queue between services | Versioned payloads plus fixtures |
| Non-TS or external consumers | Schema-first, generated TS types |
Checklist for each DTO
- Can the producer and consumer run different versions at the same time?
- Can a payload outlive a deploy?
- Is any consumer outside this TypeScript workspace?
If you answer yes to any of these, treat the DTO as a versioned contract.
Structuring the contract library and enforcing boundaries
Give contracts a dedicated home where that discipline can be enforced. Create one library such as libs/contracts, or one per domain such as libs/orders/contracts and libs/billing/contracts. It should contain only wire types and the runtime schemas that validate them. It must not import from app code, ORM entities or framework decorators. If a contract imports an entity or a decorated class, it carries that framework into every consumer, including a worker that never uses it. It also ties the wire shape to a model that changes for unrelated reasons.
Tag each project in project.json: type:contracts for the contracts lib, type:app for apps and type:data-access for persistence libs. Then configure depConstraints in the @nx/enforce-module-boundaries rule. Projects tagged type:app may depend on type:contracts. Projects tagged type:contracts may depend only on other type:contracts projects, so imports from apps, data-access libs and persistence models fail lint.
The rule only knows what you tell it. If a project has no tag, or every project shares a broad tag like scope:shared, lint passes while the structure decays. A deep import into another lib's internal files or a hand-added path alias in the TypeScript config can also get around the intended boundaries. Run lint in CI and review changes to tags and path mappings like any other code.
Choosing the layout is a trade-off. A single contracts lib couples every domain. Any edit marks every consumer as affected, so CI runs grow. Per-domain libs keep affected runs narrow and ownership clear, but you have more libraries to tag, version and maintain.
// eslint.config.mjs at the workspace root (ESLint flat config).
// Each project.json declares its tags, for example:
// libs/orders/contracts/project.json -> "tags": ["type:contracts", "scope:orders"]
// apps/orders-api/project.json -> "tags": ["type:app", "scope:orders"]
// libs/orders/data-access/project.json -> "tags": ["type:data-access", "scope:orders"]
import nx from '@nx/eslint-plugin';
export default [
...nx.configs['flat/base'],
...nx.configs['flat/typescript'],
{
files: ['**/*.ts'],
rules: {
'@nx/enforce-module-boundaries': [
'error',
{
allow: [],
depConstraints: [
// Apps sit at the top and may use contracts and persistence libs.
{ sourceTag: 'type:app', onlyDependOnLibsWithTags: ['type:contracts', 'type:data-access'] },
// Persistence code can map to wire types, never the reverse.
{ sourceTag: 'type:data-access', onlyDependOnLibsWithTags: ['type:contracts', 'type:data-access'] },
{
// Contracts are leaves: only other contracts, no apps or data-access.
sourceTag: 'type:contracts',
onlyDependOnLibsWithTags: ['type:contracts'],
// Keep ORMs and framework decorators out of the wire types.
bannedExternalImports: ['typeorm', '@prisma/client', '@nestjs/*'],
},
// Cross-domain rule: orders contracts cannot reach into billing.
{ sourceTag: 'scope:orders', onlyDependOnLibsWithTags: ['scope:orders', 'scope:shared'] },
],
},
],
},
},
];
An ESLint flat config that uses @nx/enforce-module-boundaries depConstraints to let apps depend on contracts and keeps contracts libraries from importing apps, data-access libs or ORM and framework packages.
Separating wire DTOs, domain models and persistence models
Whatever the layout, the contracts lib should hold only one of three type layers. Wire DTOs describe the bytes that cross a process boundary. Domain models are what the service reasons about. Persistence models mirror tables. Only wire DTOs belong in the shared library. The other two stay private to each app, and explicit mapping functions such as toOrderDto and fromOrderMessage translate at the API controller and the worker handler.
Skipping the mapper is how accidental breaks happen. Suppose the API returns its ORM entity directly. Someone renames the total column to total_cents and updates the entity. The compiler is satisfied and the migration runs. But the serialized payload now carries total_cents, and every worker reading total gets undefined. Nothing in the contracts lib changed, so nothing in review flagged it as a contract change.
Types are erased at runtime, so validate at HTTP ingress and at message consumption. Define each schema once and derive the TypeScript type from it, or generate validators from the types. Either way, the check and the type cannot disagree.
Choose a policy at each boundary:
- Strict rejection catches producer bugs early. During a rollout, though, a producer that adds a field before consumers deploy can push valid messages into a dead-letter queue.
- Lenient parsing, which strips or ignores unknown fields, tolerates additive changes. It is usually the right default for consumers.
Validation also costs CPU per message. Measure it on hot queues rather than assuming it is negligible.
If your types are generated from OpenAPI or a similar spec, make CI regenerate them and fail on any diff. Generated code that is not checked this way drifts from the spec it came from.
// libs/contracts/src/order.ts: the schema is the single source; the type is derived from it
import { z } from 'zod';
export const OrderMessageSchema = z.object({
orderId: z.string(),
totalCents: z.number().int(),
currency: z.string().length(3),
});
export type OrderMessage = z.infer<typeof OrderMessageSchema>;
// apps/api: the ORM entity stays private and is never serialized directly
interface OrderEntity { id: string; total_cents: number; currency: string }
// Renaming a column now breaks compilation here, not the payload on the wire
export function toOrderDto(e: OrderEntity): OrderMessage {
return { orderId: e.id, totalCents: e.total_cents, currency: e.currency };
}
// HTTP ingress: strict, so unknown keys are rejected and client bugs surface early
const StrictOrderInput = OrderMessageSchema.strict();
export function parseOrderRequest(body: unknown): OrderMessage {
return StrictOrderInput.parse(body); // throws ZodError; map it to a 400
}
// apps/worker: the domain model is private to the worker
interface Order { id: string; total: { cents: number; currency: string } }
// Lenient: z.object strips unknown keys by default, so a producer that adds
// a field ahead of this deploy does not push messages to the DLQ
export function fromOrderMessage(raw: unknown): Order | null {
const result = OrderMessageSchema.safeParse(raw);
if (!result.success) return null; // genuinely invalid: dead-letter it
const m = result.data;
return { id: m.orderId, total: { cents: m.totalCents, currency: m.currency } };
}
A Zod schema in the contracts lib defines the wire DTO and its derived type, with explicit mappers at the API and worker edges, strict validation at HTTP ingress and lenient, key-stripping validation at message consumption.
Versioning message payloads and compatibility rules for review
Messages outlive deployments. A job enqueued by yesterday's API can be picked up by a worker built from today's commit, so the worker has to know which shape it is reading. TypeScript types are erased at runtime and cannot answer that question. Give every envelope an explicit version field with a literal type, such as 1 or 2, rather than a plain number. Keep each version as its own exported type in the contract library, for example OrderPlacedV1 and OrderPlacedV2, and export a union of all supported versions next to them. Old versions stay in the library until no queue, retry topic or dead-letter store can still hold them.
Consumers narrow on version with a switch over that union. The default branch assigns the value to never. When someone adds V3 to the union, every consumer that has not handled it stops compiling. The producer change therefore cannot merge until the workers are ready to read the new version.
Compatibility review rules
| Change | Classification | Why |
|---|---|---|
| Add an optional field | Usually safe | Old readers ignore it, and new readers must already handle it being absent |
| Remove a field | Breaking | Readers that depend on it get undefined |
| Rename a field | Breaking | To an existing reader, a rename is a removal plus an addition |
Narrow a type (e.g. string to a union) |
Breaking | In-flight messages may hold values that are now invalid |
| Add, remove or rename enum members | Breaking | Exhaustive switches and validators reject values they do not recognise |
| Make an optional field required | Breaking | Messages already queued may not include it |
A breaking change gets a new version. Do not edit an existing version in place.
// libs/contracts/src/order-placed.ts (shared contract lib)
// Each version is a separate type with a literal `version` discriminator.
export interface OrderPlacedV1 {
version: 1;
orderId: string;
totalCents: number;
}
// V2 split the total into currency + amount: a breaking change, so a new version.
export interface OrderPlacedV2 {
version: 2;
orderId: string;
amount: { currency: string; cents: number };
couponCode?: string; // optional additions are usually safe within a version
}
// Every version that may still be sitting in a queue, retry topic or DLQ.
export type OrderPlaced = OrderPlacedV1 | OrderPlacedV2;
export function assertNever(value: never): never {
throw new Error(`Unsupported message: ${JSON.stringify(value)}`);
}
// apps/worker/src/handle-order-placed.ts (consumer)
export function handleOrderPlaced(msg: OrderPlaced): void {
switch (msg.version) {
case 1:
// Narrowed to OrderPlacedV1; legacy messages are assumed to be USD.
charge(msg.orderId, 'USD', msg.totalCents);
return;
case 2:
charge(msg.orderId, msg.amount.currency, msg.amount.cents);
return;
default:
// Adding OrderPlacedV3 to the union makes this line a compile error.
assertNever(msg);
}
}
declare function charge(orderId: string, currency: string, cents: number): void;
Versioned envelopes kept side by side in the contract library, with a worker that switches on the literal version discriminator and uses a never check so the compiler flags any unhandled version.
Rolling out changes with expand/contract, nx affected and contract fixtures
A breaking change ships as four pull requests, each deployed and verified before the next one merges:
- Teach consumers both shapes. Add the v2 schema to the contract library and update every worker to accept v1 and v2. Deploy all consumers first, because a producer cannot safely emit something no running consumer can read.
- Switch producers to v2. Only after every consumer is live does the API start publishing the new version.
- Drain. Leave both readers in place while queues, dead-letter queues and retry backlogs empty out. Nothing merges in this step except monitoring.
- Remove v1. Delete the old schema and its handler.
Running nx affected against the base branch lists every project that depends on the changed contract library. Run your build and test targets that way, and use nx graph to show reviewers which APIs and workers the change reaches. Both commands work from the static dependency graph. They tell you what to rebuild and test. They cannot tell you whether a change is compatible on the wire, or which versions are still running in production.
To close that gap, commit serialized fixtures for every supported message version into the contract library. Store real JSON payloads, not objects built in tests. Write contract tests that deserialize each fixture with the current code. If an edit breaks v1 parsing, CI fails, so the problem never reaches a worker holding a queued message.
Step 4 needs evidence, not a calendar date. Before removing v1, confirm that:
- No producer still emits it.
- No queue or retry backlog still holds it.
- No replay or archive job will feed it back into the system.
Alternatives and impact on cost and engineering time
Each of those checks costs engineering time, and that cost belongs in the comparison with other approaches.
| Approach | Upfront effort | Runtime safety | Multi-language | CI cost |
|---|---|---|---|---|
| Plain shared-interfaces lib | Low | None; types are erased at build time | TypeScript only | Lowest |
| Versioned contract lib with runtime schemas | Medium | Payloads validated at the boundary | TypeScript only | Wider affected graph |
| Schema-first from OpenAPI or similar | Medium–high | Depends on the generated validators | Good for HTTP, weaker for queues | Adds a generation step |
| Language-neutral schema with codegen | High | Strong when every consumer validates | Best | Generation plus checks for each language |
The versioned library has ongoing costs. Old versions look like dead code but cannot be deleted until the checks above pass. Each consumer carries extra dispatch branches. The schema library becomes a dependency that every app shares. A change to the contract library marks every producer and worker as affected, so nx affected runs more jobs.
The savings come from the other side. Contract fixtures catch incompatible payloads in CI instead of during a deploy. Written compatibility rules turn a review into a checklist rather than a debate.
Illustrative estimate (assumed numbers, not a benchmark)
Net hours per quarter = (incidents avoided × hours per incident) + (reviews × minutes saved ÷ 60) − (extra CI minutes × cost factor) − maintenance hours
Assume 2 incidents avoided at 6 hours each, 40 reviews with 10 minutes saved each, and 8 hours of maintenance. That gives 12 + 6.7 − 8 ≈ 10.7 hours, before CI cost. Replace these inputs with your own numbers. If your team has few incidents, the result can be negative.
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 shared library dto, 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.