What a shared API contract is, and what it is not

A shared API contract is the part of a service's interface that other teams are allowed to depend on. The concept is useful when services deploy on their own schedules. If a provider ships a change on Tuesday and its consumers ship whenever they want, the contract is the only thing both sides can count on staying stable in between.

Three layers are easy to mix up, and they change for different reasons:

Layer Owner Who may depend on it Changes when
Internal domain model One service That service only Business logic or storage changes
DTOs (request/response shapes) One service That service's handlers and serializers The service's code is refactored
Published contract Provider, reviewed with consumers Any consumer The provider makes a deliberate, versioned decision

Only the contract crosses a service boundary. Domain models hold invariants, persistence details and fields that no consumer should see. DTOs are closer to the edge, but they still belong to the provider's code. A rename made for internal reasons changes them right away. If a consumer imports either one, a private refactor turns into an unannounced breaking change.

A contract also needs to be readable by services written in other languages. OpenAPI meets that need. It defines a "standard, language-agnostic interface to HTTP APIs." An OpenAPI Description is meant to drive documentation, generate server and client code, and feed testing tools (source). One file can therefore produce the provider's server stubs and each consumer's client without either side importing the other's source.

Why sharing a DTO package breaks consumers

The tempting shortcut is to skip the schema and publish the provider's request and response classes as an internal package that every consumer imports. In a monorepo this looks safe, because one atomic commit can change the type and every call site at once. The type checker sees a single, consistent world and passes.

That consistency ends at merge time. Services build and deploy on their own schedules, so production always runs a mix of versions. The compiler checks the repository, but not what is actually running.

A rename that compiles and still breaks

Suppose the orders service renames customerId to buyerId in its shared DTO. The same commit updates the billing service, CI is green, and the change merges. During the rolling deploy, new orders pods start emitting buyerId while some billing pods are still on the previous build and expect customerId. Depending on the deserializer, those pods either reject the payload or silently read a null customer. Rolling back the provider does not fully help if new billing pods have already shipped.

Mobile apps and third-party integrators make this worse. They never update in lockstep, and an app version installed months ago may still be in use.

A DTO package couples consumers to the provider's internal model and assumes everyone deploys together. A shared contract handles the opposite case: it belongs wherever a deploy boundary or team boundary separates the producer from its consumers.

// packages/orders-dto/src/index.ts, after the rename commit.
// The same commit updated billing, so the type checker passed.
export interface OrderCreated {
  orderId: string;
  buyerId: string; // was: customerId
  totalCents: number;
}

// billing/src/handler.ts, as compiled into the PREVIOUS billing build.
// Old pods still run this until the rolling deploy replaces them.
interface OrderCreatedV1 {
  orderId: string;
  customerId: string;
  totalCents: number;
}

function handleOrderCreated(body: string): void {
  // The cast is checked only at compile time. Nothing validates
  // the payload that actually arrives over the wire.
  const event = JSON.parse(body) as OrderCreatedV1;

  // A new orders pod sent { orderId, buyerId, totalCents }.
  // event.customerId is undefined, yet the code compiles and runs.
  chargeCustomer(event.customerId, event.totalCents);
}

function chargeCustomer(customerId: string, amountCents: number): void {
  if (!customerId) {
    // Without this guard, the failure is a silent bad write
    // instead of an error.
    throw new Error("order event missing customer id");
  }
  console.log(`charging ${customerId} ${amountCents}`);
}

// What a new orders pod emits during the rollout:
handleOrderCreated('{"orderId":"ord_123","buyerId":"cus_456","totalCents":4200}');

A shared DTO rename passes the type checker in a single commit, but a billing pod still running the previous build reads customerId as undefined when a new orders pod sends buyerId during a rolling deploy.

How it works: repository layout and dependency boundaries

Give each service exactly one contract package, such as contracts/orders, owned by the provider team. It holds the OpenAPI document and the artifacts generated from it: clients, server interfaces, validation schemas. The implementation lives in services/orders and is never an import target for other services. Consumers depend on contracts/orders. The provider depends on it too, implementing the generated server interface. Both sides then read the same document, and the provider's internal model stays free to change.

Two mechanisms keep this from eroding:

  • Directory ownership rules. A code-owners file maps each contracts/<service> path to its provider team. No contract change merges without the provider's review, so a consumer cannot reshape an API to suit itself.
  • Dependency boundary checks. A lint rule or build-graph visibility constraint runs in CI and fails any build where services/billing imports from services/orders. Without the check, the rule is only a convention, and conventions lose to deadlines.

A small package for universal primitives

Some shapes are identical everywhere: the error envelope, pagination parameters and metadata, ID formats, and timestamp formats. Put these in one package that each OpenAPI document references through $ref. Keep it small and slow to change, because every edit reaches every service.

Guard its scope. Once types land there because two services happen to share them, the package collects domain concepts and forces lockstep upgrades. At that point you have rebuilt the shared DTO library under a new name. Before adding a type, ask whether every service, including ones not yet written, needs this exact shape. If not, it belongs in a service's own contract.

                        +--------------------------------------+
                        | contracts/common                     |
                        | error envelope, pagination, ID and   |
                        | timestamp formats (small, stable)    |
                        +--------------------------------------+
                                           ^
                                           | $ref
                                           |
+--------------------+  +--------------------------------------+
| CODEOWNERS         |  | contracts/orders  (owner: orders)    |
| contracts/orders/  |->| openapi.yaml                         |
|   -> orders team   |  | generated: clients, server           |
| (review required)  |  |   interfaces, validation schemas     |
+--------------------+  +--------------------------------------+
                                 ^                           ^
               implements server |                           | depends on
                       interface |                           | client
                        +------------------+        +------------------+
                        | services/orders  |<--X----| services/billing |
                        | (provider impl)  |        | (consumer)       |
                        +------------------+        +------------------+
                          X = direct import fails CI boundary check

The provider and its consumers both depend on the provider-owned contracts/orders package, which references a small shared primitives package through $ref. Code-owner rules gate contract changes, and a CI boundary check blocks direct imports between services.

Schema-first vs code-first: choosing the source of truth

Each contract needs exactly one authoritative artifact. You can pick either the OpenAPI document or the code, and each fails in predictable ways.

Schema-first means engineers write the OpenAPI document by hand and review it like code. Server stubs and clients are generated from it. Because the document is the contract by construction, there is nothing for it to drift from. The cost is ceremony: every change touches the spec before the handler, and generated code is often verbose or unidiomatic. Generator support for newer OpenAPI releases also varies by language, so confirm what your generators handle before you adopt new spec features.

Code-first means annotated handlers emit the document. It is faster to start and keeps engineers in one language. The risk is that the emitted document knows only what the annotations say. Nullability, unions, and error responses are the usual gaps. For example, a handler returns a 409 with an error body, but the spec lists only 200. Consumers then generate code that cannot represent that response.

Schema-first Code-first
Source of truth OpenAPI document Annotated handlers
Time to first endpoint Slower Faster
Main failure mode Unidiomatic generated code, generator gaps Spec drifts from runtime behaviour
Review surface Spec diff Code diff, spec diff is derived

Either approach works if everyone knows which artifact wins when they disagree.

Separately, decide how much code to generate. A full generated client comes with its own transport, retry, timeout, and auth behaviour, which can conflict with each service's existing HTTP stack. If you generate only the types and wrap them in a thin hand-written client, the contract stays shared and each consumer keeps control of that behaviour.

Implementing it: code generation and CI compatibility gates

Reference shared schemas, don't copy them

Keep cross-cutting definitions in their own file in the contract package, for example contracts/shared/errors.yaml. That file holds an Error schema under components/schemas with code, message and an optional details array. Each service's OpenAPI document then points to it with $ref: '../shared/errors.yaml#/components/schemas/Error' in its 4XX and 5XX responses instead of defining its own copy. When the error shape changes, it changes in one place, and every generator that reads the document gets the change.

Treat generated code as build output

You can handle generated code in one of two ways, and you should pick one. You can commit it: CI runs the generator, then runs git diff --exit-code on the output directory, and the build fails if anything changed. This catches specs that were edited without regenerating, and it catches generator version drift. Or you can generate during the build and add the output directory to .gitignore, which means generated code never shows up in review. Either way, never edit generated files by hand. The next regeneration silently overwrites those edits.

Gate merges on compatibility

For each OpenAPI document a pull request touches, CI checks out the version on the main branch and runs a contract diff tool on the old and new documents. The tool flags breaking changes, such as removed fields, newly required request properties or narrowed enums. Fail the build on any of them unless the change goes into a new versioned path or document. The goal is to make every breaking change a deliberate decision.

Test only what is affected

Use the monorepo's dependency graph to find the services that depend on the changed contract package. Run contract and consumer tests for those services only. Feedback stays fast, and you still test every consumer that could break.

# contracts/shared/errors.yaml: the single definition of the error shape
openapi: 3.0.3
info: { title: Shared definitions, version: 1.0.0 }
paths: {}
components:
  schemas:
    Error:
      type: object
      required: [code, message]
      properties:
        code: { type: string, example: ORDER_NOT_FOUND }
        message: { type: string }
        details:            # optional, for field-level problems
          type: array
          items: { type: string }
---
# contracts/orders/openapi.yaml: references the shared schema instead of copying it
openapi: 3.0.3
info: { title: Orders API, version: 1.4.0 }
paths:
  /orders/{orderId}:
    get:
      parameters:
        - { name: orderId, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: The order
        '4XX':
          description: Client error
          content:
            application/json:
              schema: { $ref: '../shared/errors.yaml#/components/schemas/Error' }
        '5XX':
          description: Server error
          content:
            application/json:
              schema: { $ref: '../shared/errors.yaml#/components/schemas/Error' }

A shared Error schema lives in contracts/shared/errors.yaml, and the Orders API points to it with $ref in its 4XX and 5XX responses, so the error shape changes in one place for every generator.

Evolving the contract without breaking consumers

Every schema change is either compatible on the wire or breaking for at least one consumer. Write the rules down so reviewers don't have to argue each case.

Change Effect Why
Add optional response field Safe Consumers that ignore unknown fields keep working
Add optional request field Safe Old clients leave it out and the server uses a default
Add required request field Breaking Existing clients don't send it
Remove or rename a field Breaking Readers lose data they rely on
Change a field's type Breaking Deserialization fails or silently converts the value
Tighten validation (narrower enum, lower maxLength) Breaking Requests that used to be valid are now rejected

The first row only holds if consumers ignore unknown fields. Check that generated clients aren't set to reject extra properties during deserialization.

Renames need expand-and-contract. Say you want to replace customerName with fullName. First, add fullName as an optional property and have the server fill in both fields. Second, move each consumer to read fullName. Third, deploy every service and confirm nothing reads the old field. Then mark customerName as deprecated: true and remove it in a later change. A single monorepo commit doesn't deploy everything at once, because services roll out on their own schedules. That's why the two fields have to exist side by side for a while.

Sometimes a resource's shape changes so much that you can't do it by adding fields. Then use versioning. Path versioning (/v2/orders) is coarse and easy to route. Media-type versioning, negotiated through the Accept header, lets you version a single representation without duplicating the routes.

Automated breaking-change detection is heuristic, because it compares structure only. It won't catch an amount that switched from cents to dollars, a timestamp that moved to a different time zone, or a default that flipped from false to true. Those changes still need human review and consumer tests that check real values.

# OpenAPI 3.1 schema during the expand phase of renaming customerName -> fullName.
# Both fields exist side by side until every deployed consumer reads fullName.
components:
  schemas:
    Order:
      type: object
      required: [id, status]
      properties:
        id:
          type: string
        status:
          type: string
          # Adding a value here is safe for requests but can break consumers
          # that switch exhaustively over the enum; removing one is breaking.
          enum: [pending, paid, shipped]
        customerName:
          type: string
          # Contract step: mark deprecated only after all consumers have moved.
          # Remove it in a later change, never in the same release as fullName.
          deprecated: true
          description: Deprecated. Use fullName. The server still populates it.
        fullName:
          # Expand step: new field is optional so old payloads stay valid.
          # The server fills in both fields during the migration window.
          type: string
          description: Customer's full name. Replaces customerName.
        totalCents:
          # Integer minor units. Changing this to dollars would pass most
          # breaking-change checks but would still break every consumer.
          type: integer
      # Keep the schema open: leaving additionalProperties unset (or true)
      # allows future optional fields without failing strict clients.
      additionalProperties: true

An OpenAPI schema mid-migration that adds an optional fullName next to a deprecated customerName and notes where a semantic change such as units would slip past automated checks.

When to avoid it, alternatives, and impact on cost and engineering time

Machinery can't judge semantics, and it has its own cost. When producer and consumer belong to one team and ship as one deployable unit, the compiler already rejects a breaking change in the same pull request. Sharing language-level types directly is often the right call there, because a schema and generator would add a build step that guards against nothing.

Approach Catches breaks across deploys Main cost Fits
Shared language types Only at compile time, same release Fan-out rebuilds One team, one deployable
Shared DTO package Rarely; versions drift Lockstep upgrades Short-lived internal tools
Code-first generation Yes, if the emitted spec is diffed in CI Annotation discipline Existing services
Schema-first contract Yes Codegen setup, review Many teams, independent deploys

OpenAPI only describes HTTP APIs. Events, queues and RPC protocols need their own schema and compatibility tooling, so you maintain a second contract system with its own gates.

A bloated common package has a cost too. Every edit to it triggers rebuilds and tests in every service that imports it. Keeping each contract narrow lets CI run only the tests that a change actually affects.

Illustrative estimate (assumed numbers, not a benchmark)

Net monthly hours = (incidents avoided × hours per incident) − (upkeep + PRs × review hours)

Assumed inputs:

  • 2 incidents avoided per month, at 8 hours each
  • 6 hours of monthly upkeep
  • 20 contract PRs per month, at 0.25 review hours each

Net = 16 − (6 + 5) = 5 hours/month.

With an assumed 40-hour setup, payback is 40 ÷ 5 = 8 months. This estimate excludes the time saved by generated clients and docs.

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 shared api contracts 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.
  • Changing a route's URL or method automatically notifies the services that consume it — no hunting through repos for callers.

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.
  • Consumers learn about a breaking change at review time rather than from production errors.
  • 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.