Skip to main content

REST vs GraphQL vs gRPC Contract Strategies

Choosing a contract strategy is not the same as choosing an API style. The three dominant paradigms — REST described with OpenAPI, GraphQL described with SDL, and gRPC described with Protocol Buffers — each enforce the contract at a different boundary, evolve under different rules, and generate code from different tooling. Picking one without understanding where its schema actually binds leads to gates that pass while consumers break. This guide extends API Contract Fundamentals & Tool Selection and shows how to author, generate, and govern a contract for each paradigm, and how to decide which one fits a given surface.

The recurring theme is the enforcement boundary: the point at which a mismatch between producer and consumer is detected. In REST the boundary is loose (an HTTP document validated at runtime, if at all); in GraphQL it is the queried selection set; in gRPC it is the binary wire format itself. Everything downstream — versioning, codegen, breaking-change detection — follows from that boundary.

When to Use This Approach

Reach for a deliberate, per-paradigm contract strategy when:

  • You operate more than one paradigm in the same platform (for example gRPC internally, GraphQL for the BFF, REST for partners) and need a consistent governance model across them.
  • Multiple teams consume your API and you cannot coordinate every deploy, so the contract must be machine-checkable.
  • You are choosing a paradigm for a new service and want the trade-offs framed by contract enforcement, not just performance or developer ergonomics.
  • A consumer keeps breaking despite “no API changes,” which usually means the enforcement boundary is weaker than the team assumes.

If you only run REST and just need to harden the document itself, start with the OpenAPI Specification Deep Dive instead; this page is about strategy across paradigms.

Prerequisites

Install one toolchain per paradigm you operate. Pin versions to avoid silent rule drift.

# REST / OpenAPI — linting, diffing, type generation
npm install -D @stoplight/spectral-cli@6.11 \
               openapi-typescript@7.4 \
               openapi-diff@0.23

# GraphQL — codegen and schema diffing
npm install -D @graphql-codegen/cli@5.0 \
               @graphql-inspector/cli@5.0 \
               graphql@16.9

# gRPC / Protocol Buffers — compile and breaking-change checks
# buf 1.39 bundles protoc-compatible compilation, lint, and breaking checks
curl -sSL "https://github.com/bufbuild/buf/releases/download/v1.39.0/buf-$(uname -s)-$(uname -m)" \
  -o /usr/local/bin/buf && chmod +x /usr/local/bin/buf

You also need a git repository (the differs compare against a baseline branch) and Node.js 20 or later for the JavaScript tooling.

Step 1: Map the Schema Enforcement Boundary

Before writing any schema, locate where each paradigm catches a contract violation. This determines what your CI gate is actually protecting and where runtime failures will surface if the gate is weak.

The diagram below contrasts the three boundaries: the artifact you author, what binds the producer and consumer, and what slips through if you skip validation.

Contract enforcement boundaries across REST, GraphQL, and gRPC Three columns compare the source artifact, enforcement boundary, and failure mode for REST with OpenAPI, GraphQL with SDL, and gRPC with protobuf. Enforcement boundary by paradigm Artifact Binds at If unchecked REST OpenAPI 3.1 YAML / JSON doc (descriptive) Runtime JSON validation Silent drift doc vs server GraphQL SDL schema Type system (executable) Query against schema Removed field breaks query gRPC .proto (proto3) Binary wire (generative) Wire format by field tag Corrupt decode if tag reused

The key insight: an OpenAPI document is descriptive — nothing forces the server to obey it unless you validate at runtime or contract-test the implementation. A GraphQL SDL is executable — the server cannot resolve a field the schema does not declare. A protobuf definition is generative and binding at the wire level — the encoded bytes are meaningless without the matching .proto. Strategy follows from this: REST needs the most external enforcement, gRPC the least.

Step 2: Author the REST Contract (OpenAPI)

REST’s contract is an OpenAPI document. Because the document is descriptive, your strategy must add enforcement: strict schemas, runtime validation, and a lint ruleset. Declare every response envelope explicitly and forbid undocumented properties.

# openapi.yaml — REST contract for an Orders resource
openapi: 3.1.0
info:
  title: Orders API
  version: 2.1.0           # bump on every breaking change (URL/header versioning)
paths:
  /orders/{orderId}:
    get:
      operationId: getOrder
      parameters:
        - name: orderId
          in: path
          required: true     # removing a required param is breaking
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: The order
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Order' }
        '404':
          $ref: '#/components/responses/NotFound'
components:
  schemas:
    Order:
      type: object
      additionalProperties: false   # reject undocumented fields — closes the drift gap
      required: [id, status, total]
      properties:
        id:     { type: string, format: uuid }
        status: { type: string, enum: [pending, shipped, cancelled] }
        total:  { type: number, format: double }

Lint it with Spectral so structural mistakes fail before review. For consistency across services, define a shared ruleset and extends the OpenAPI baseline:

# .spectral.yaml
extends: ['spectral:oas']
rules:
  operation-operationId: error        # every operation needs a stable id for codegen
  oas3-unused-component: warn
  path-keys-no-trailing-slash: error

Because the document does not bind the server, pair it with runtime validation or consumer-driven contract tests so the implementation cannot drift away from the spec. The structural rules for keeping the document itself clean — $ref reuse, examples, response modeling — are covered in depth in the OpenAPI Specification Deep Dive.

Step 3: Author the GraphQL Contract (SDL)

GraphQL’s contract is the schema itself, expressed in SDL. The server physically cannot resolve a field that the schema does not declare, so the enforcement boundary is much tighter than REST. The strategy shifts from “describe and validate” to “evolve additively and deprecate, never delete.”

# schema.graphql — GraphQL contract for orders
type Order {
  id: ID!
  status: OrderStatus!
  total: Float!
  "Legacy field kept for older clients; superseded by `total`."
  amount: Float @deprecated(reason: "Use `total` instead. Removal after 2026-09-01.")
}

enum OrderStatus { PENDING SHIPPED CANCELLED }

type Query {
  "Fetch a single order by id."
  order(id: ID!): Order
}

Two rules define the strategy. First, add, never remove: clients select exactly the fields they need, so adding a field or type never breaks an existing query. Second, deprecate before deletion: the @deprecated(reason) directive keeps the field resolvable while signaling clients to migrate, which is why GraphQL rarely needs versioned endpoints. Documentation discipline — descriptions on every type and a clear deprecation reason — is what makes evolution legible; see Best Practices for Documenting GraphQL Schemas with SDL for the conventions that keep an SDL contract self-explanatory.

Capture the schema as the baseline so the differ has something to compare against. You can write SDL directly (schema-first) or derive it from resolvers (code-first); the trade-off is covered in Schema-First vs Code-First Workflows.

Step 4: Author the gRPC Contract (Protocol Buffers)

gRPC’s contract is a .proto file, and it binds at the binary wire level. Each field is encoded by its numeric tag, not its name, so the tag — not the field name — is the immutable part of the contract. This makes gRPC the strictest paradigm: a mismatched tag does not produce a 400, it silently misdecodes.

// orders.proto — gRPC contract for the Orders service
syntax = "proto3";
package orders.v1;            // version in the package path, never renumber tags

message Order {
  string id = 1;             // field number 1 is permanent for this message
  Status status = 2;
  double total = 3;
  reserved 4;                // a deleted field's number is reserved forever
  reserved "amount";         // ...and so is its name, to prevent reuse
}

enum Status {
  STATUS_UNSPECIFIED = 0;    // proto3 requires a zero default
  STATUS_PENDING = 1;
  STATUS_SHIPPED = 2;
  STATUS_CANCELLED = 3;
}

service OrderService {
  rpc GetOrder(GetOrderRequest) returns (Order);
}

message GetOrderRequest { string id = 1; }

The strategy is: field numbers are forever, packages carry the version. Adding a field with a new number is safe and backward-compatible. Removing one requires a reserved declaration so the number and name can never be reused, which is the single most important habit for safe protobuf evolution. The full set of conventions — streaming RPCs, well-known types, package layout — is detailed in Defining gRPC Service Contracts with Protocol Buffers.

Step 5: Generate Code From Each Contract

A contract is only worth enforcing if both sides share it. Codegen turns the artifact into typed clients and servers so a drift between the schema and the code is impossible at compile time.

# REST: generate TypeScript types from the OpenAPI document
npx openapi-typescript openapi.yaml -o src/types/orders.d.ts

# GraphQL: generate typed operations and resolver signatures
npx graphql-codegen --config codegen.ts

# gRPC: compile .proto to language stubs (Go shown; add --java_out, --python_out, etc.)
buf generate          # reads buf.gen.yaml, invokes the configured plugins

For REST, the generated types are advisory — TypeScript will flag a misuse at compile time, but nothing stops the running server from returning a different shape, so codegen complements rather than replaces runtime validation. For GraphQL and gRPC, the generated artifacts are load-bearing: the GraphQL resolver map must satisfy the generated signatures, and the gRPC stubs cannot encode a field the .proto never declared. This asymmetry is exactly the enforcement-boundary difference from Step 1, surfacing again in the build.

Step 6: Gate Evolution in CI

Wire one differ per paradigm into the pipeline. Each compares the proposed schema against the main baseline and fails the build on a backward-incompatible change. This is where the contract stops being documentation and becomes a gate.

# .github/workflows/contracts.yml
name: Contract Gates
on:
  pull_request:
    branches: [main]
jobs:
  gate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }          # full history so differs can read main
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: npm ci

      - name: REST — breaking change check
        run: npx openapi-diff origin/main:openapi.yaml openapi.yaml

      - name: GraphQL — breaking change check
        run: npx @graphql-inspector/cli diff "git:origin/main:schema.graphql" schema.graphql

      - name: gRPC — breaking change check
        run: buf breaking --against '.git#branch=main'

The shared pattern across all three — baseline branch, paradigm-specific differ, fail-closed exit code — is the foundation of Breaking Change Detection, which goes deeper into rule customization, allow-lists for intentional breaks, and reporting.

Paradigm Comparison Reference

Dimension REST (OpenAPI) GraphQL (SDL) gRPC (Protobuf)
Contract artifact openapi.yaml (descriptive) schema.graphql (executable) *.proto (generative)
Transport HTTP/1.1 or HTTP/2, JSON HTTP, single POST, JSON HTTP/2, binary protobuf
Enforcement boundary Runtime JSON validation Queried selection set Binary wire format by tag
Versioning model URL path or Accept header Additive + @deprecated Package path; tags immutable
Safe change Add optional field/endpoint Add field/type Add field with new number
Breaking change Remove/retype required field Remove or retype a field Reuse or retype a field number
Codegen tool openapi-typescript 7 graphql-codegen 5 protoc / buf 1.39
Breaking-change tool openapi-diff graphql-inspector buf breaking
Best fit Public/partner APIs Aggregating BFF for UI clients Internal high-throughput RPC

Verification

Confirm the gates actually catch regressions before trusting them. Introduce a deliberate breaking change to each artifact and run the differ — every command should exit non-zero.

# REST: remove a required property, then diff
npx openapi-diff origin/main:openapi.yaml openapi.yaml
# Expected: "Breaking changes found" and exit code 1

# GraphQL: delete a field, then diff
npx @graphql-inspector/cli diff "git:origin/main:schema.graphql" schema.graphql --fail-on-breaking
# Expected: "Detected 1 breaking change" and exit code 1

# gRPC: change a field number, then check
buf breaking --against '.git#branch=main'
# Expected: "Field "1" ... changed ... (FIELD_NO_DELETE / WIRE)" and exit code 100

A green pipeline on a known-breaking change means the gate is misconfigured — most often a missing fetch-depth: 0 so the differ never sees main. Also round-trip the codegen: regenerate types and confirm git diff is empty, proving the committed types match the current schema.

Troubleshooting

buf breaking passes despite a renumbered field

Root cause: buf is comparing against the wrong baseline, usually because the checkout is shallow and .git#branch=main resolves to an empty or stale tree. Fix: set fetch-depth: 0 on the checkout step and confirm with git rev-parse origin/main that the baseline commit exists locally before the check runs.

openapi-diff reports no changes after editing the spec

Root cause: the tool is reading a bundled or cached copy, or additionalProperties is unset so the added field is considered allowed by the old schema. Fix: bundle both versions fresh (npx @redocly/cli bundle) before diffing, and set additionalProperties: false on objects so adding a field is a detectable delta.

GraphQL codegen succeeds but resolvers fail at runtime

Root cause: the generated types describe the schema, but the resolver map does not implement every field, so a queried-but-unresolved field returns null. Fix: enable @graphql-codegen/typescript-resolvers with strictScalars: true and a complete Resolvers type so missing resolvers become compile errors, not runtime nulls.

Generated gRPC stubs are out of date in another language

Root cause: stubs were regenerated for one language but the .proto change shipped without regenerating others, so a polyglot consumer decodes against an old definition. Fix: generate all targets from a single buf.gen.yaml in CI and publish stubs to a package registry rather than committing per-language copies.

REST clients break even though every CI gate is green

Root cause: the OpenAPI document is descriptive, so the gate validated the spec, not the server — the implementation drifted from the contract. Fix: add runtime request/response validation (a validating proxy such as Prism, or middleware) or consumer-driven contract tests so the running service is held to the document.

Frequently Asked Questions

Can I use OpenAPI to describe a gRPC service?

Not natively. OpenAPI describes HTTP/JSON resources, while gRPC contracts live in .proto files compiled by protoc or buf. You can expose a gRPC service over HTTP via grpc-gateway and generate OpenAPI from the gateway annotations, but the protobuf file remains the source of truth.

Does GraphQL need versioning like REST?

GraphQL favors continuous evolution over versioned endpoints. You add fields freely and mark removed ones with @deprecated(reason) instead of cutting a /v2 surface, because clients select only the fields they request.

Which paradigm has the strictest wire-level contract?

gRPC. Protobuf encodes fields by numeric tag, so the binary wire format is fully determined by the .proto definition. Renumbering or retyping a field silently corrupts decoding, which is why field numbers must be treated as immutable.

Can one service expose REST, GraphQL, and gRPC at once?

Yes, and large platforms commonly do: gRPC for internal service-to-service calls, GraphQL for aggregating data to web and mobile clients, and REST for public partner integrations. Each surface needs its own contract artifact and its own breaking-change gate.

How do I detect breaking changes consistently across all three?

Run a paradigm-specific differ in CI against the main branch: openapi-diff for REST, graphql-inspector for GraphQL, and buf breaking for gRPC. Each compares the proposed schema to the baseline and fails the build on incompatible changes.