Contract-First API Design

Contract-First API Design

Comparing different approaches to modeling state and enforcing schemas across API boundaries

A variety of signs in a crowded downtown neighborhood.

One point of friction that often slows down engineering teams is a misunderstanding about API contracts.

Whether it’s hotels or online dating, as engineering teams learn more about the domain they’re working in, they’ll need to adjust parts of the application’s data model. Sometimes, this is to correct misconceptions that they originally baked into their APIs. Other times, it’s to add new areas of the domain to that they haven’t encountered before.

Bit by bit, the team’s original mental model of the domain will shift, especially as APIs are deprecated or become bloated with old data.

When this happens, having a shared API contract across the engineering team is very valuable.

As requirements change and the application grows, the contract serves as a guidepost for engineers on both sides of the stack.

This blog outlines three possible approaches to enforcing and sharing a schema in order to enforce consistency in API design.

OpenAPI (aka Swagger)

Swagger, recently renamed to OpenAPI, is an open source API specification.

When using OpenAPI, engineers can write these contract documents first, often as YAML or JSON, before implementing them in code.

This lets engineers use those contracts in code generation tools to build common boilerplate, validation middleware, cross-language type safety, and tests. It also frees engineers to focus the application’s business logic.

In a full-stack Typescript application, a contract for a users endpoint might look like this:

openapi: 3.0.0
info:
  title: Typescript Full Stack OpenAPI Template
  version: 0.0.1
paths:
  /api/v1/users/{userId}:
    get:
      summary: Get a user by their ID
      parameters:
      - in: path
        name: userId
        schema:
          type: integer
        required: true
        description: Numeric ID of the user
      responses:
        '200':
          description: A JSON object of a single user
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
  /api/v1/users:
    get:
      summary: Returns a list of users.
      responses:
        '200':
          description: A JSON array of user names
          content:
            application/json:
              schema: 
                type: array
                items:
                  $ref: '#/components/schemas/User'
components:
  schemas:
    User:
      type: object
      required:
        - id
        - first_name
        - last_name
      properties:
        first_name:
          type: string
        last_name:
          type: string
        id:
          type: number

This schema can then be fed into a code generation tool like openapi-ts to generate clients automatically. The types generated by this library can also be shared across the stack.

For instance, here is part of the automatically generated types.gen.ts file from running openapi-ts to build the application:

export type User = {
    first_name: string;
    last_name: string;
    id: number;
};
 
export type GetApiV1UsersByUserIdResponse = User;

These types can be used across the stack to improve developer tooling. This tool can also generate client code directly, like this:

import { getApiV1Users } from "@/api"
const { data } = await getApiV1Users()

In this example, the data variable is explicitly typed as GetApiV1UsersByUserIdResponse from the generated types. On the server, we can use a library like express-openapi-validator to validate that our API responses adhere to this schema:

import * as OpenApiValidator from 'express-openapi-validator'
import { fileURLToPath } from 'url'
import path from 'path'
 
const __filename = fileURLToPath(import.meta.url)
const spec = path.join(path.dirname(__filename), '..', 'api.yaml')
 
export default OpenApiValidator.middleware({
  apiSpec: spec,
  validateRequests: true,
  validateResponses: true,
})

This middleware validates that responses from the server conform to the schema.

When to Use It

The OpenAPI specification and tooling around it is very stable, and it’s a great default choice since many different languages can interface with OpenAPI specifications and generate code from it.

It’s also a great choice if you anticipate that the application’s APIs will be exposed externally, and you want to generate documentation automatically.

When not to Use It

Writing YAML or JSON schemas directly instead of typing APIs in your language of choice can be a bit clunky. Approaches that use Remote Call Procedures, like TRPC, are schema-first alternatives that may provide a better developer experience if your entire application is written in one language.

RPCs and TRPC

TRPC, or TypeScript remote procedure call, is a newer technology than OpenAPI, and a good option for engineering teams that are working exclusively in Typescript and want to define their API contracts in the same language. The approach here is also applicable with GRPC and other RPC based approaches, but TRPC has an advantage in that the client code can be written in the same language as the schema files.

Like other RPCs, TRPC defines contracts at the method level, rather than in an external schema file like OpenAPI.

This makes the developer tooling top notch, as TPRC integrates seamlessly in real-time (no build step) with LSPs and other editor tools. This can make iterating on API designs faster than when using an OpenAPI specification.

To use TRPC, we can put our schema and the router code in the same file:

import { z } from 'zod';
import { trpc } from 'trpc';
 
const User = z.object({
  id: z.number(),
  first_name: z.string(),
  last_name: z.string(),
});
 
export const usersRouter = trpc.router({
  getSingleUser: trpc.query
    .query('getSingleUser')
    .param('userId', z.number())
    .queryFn(({ userId }) => {
      // Your logic to fetch a user by ID
      return { id: userId, first_name: 'John', last_name: 'Doe' };
    }),
 
  getUsers: trpc.query.query('getUsers').queryFn(() => {
    // Your logic to fetch a list of users
    return [{ id: 1, first_name: 'Alice', last_name: 'Smith' }, { id: 2, first_name: 'Bob', last_name: 'Johnson' }];
  }),
});

Defining our API contracts and server logic in one place improves the locality of our code. We could layer additional tooling on top of TRPC to export an OpenAPI schema too, to get the best of both worlds.

When to Use It

When building a full-stack application that uses the same language on both sides of an API.

When not to Use It

It might not be the right fit if when working in multiple languages and the schema needs to be language agnostic. By defining the specification in Typescript using Zod, we require engineers to have specific language expertise when domain modeling, which may not always be desirable, particularly if your backend engineers are well-versed in a different language. In those cases, you may prefer to use YAML or JSON for it’s language-agnostic flexibility.

AsyncAPI

AsyncAPI is an offshoot of the OpenAPI specification focused specifically on event-driven architectures.

Like OpenAPI, AsyncAPI expects you to define a schema file for your APIs. However, since AsyncAPI is focused on event-driven architectures, it provides better primitives for dealing with asynchronous code, such as different protocols and support for producers and consumers, rather than servers and clients.

Here’s what an AsyncAPI YAML file looks like:

asyncapi: 3.0.0
info:
  title: Some example API
  version: 0.1.0
channels:
  user:
    address: 'users.{userId}'
    title: Users channel
    description: This channel is used to exchange messages about user events.
    messages:
      userSignedUp:
        $ref: '#/components/messages/userSignedUp'
      userCompletedOrder:
        $ref: '#/components/messages/userCompletedOrder'
    parameters:
      userId:
        $ref: '#/components/parameters/userId'
    servers:
      - $ref: '#/servers/production'
    bindings:
      amqp:
        is: queue
        queue:
          exclusive: true
    tags:
      - name: user
        description: User-related messages
    externalDocs:
      description: 'Find more info here'
      url: 'https://example.com'

In this example, we define a user channel used to exchange messages about users. The different types of messages that the channel expects and their shapes are defined within. The AsyncAPI project has tooling that you can use to convert these files into client libraries and boilerplate code.

When to Use It

For enforcing type safety in event-driven architectures. It’s a handy extension of the OpenAPI specification.