Skip to content
All case studies
Payments / Fintech·Senior Full-Stack Developer·2024–present

Killing API schema drift with a generated, type-safe client

Two codebases, one API contract, and a constant tax of "the frontend thinks this field is a string." I made the OpenAPI spec the single source of truth and wired a pipeline that turns it into a typed TypeScript SDK - so drift becomes a compile error, not a production surprise.

1
source of truth - the OpenAPI spec, generated from code
0
hand-written API types on the frontend
Auto
SDK regenerated, versioned & published on every change

Stack

OpenAPITypeScript@hey-api/openapi-tszircote/swagger-phpSpectralGitHub ActionsPHP 8.4Mezzio

In one line

I made the backend's OpenAPI spec the single source of truth for the API and built a pipeline that compiles it into a typed TypeScript SDK, published automatically on every change - so a breaking API change becomes a red build in the frontend instead of a bug a merchant finds.

The problem

When a backend and a frontend live in separate repositories, they drift. A field gets renamed on the server, the frontend doesn't hear about it, and you find out in production - or in a long afternoon of "why is this undefined?"

On a payments platform under active development across two repos, that isn't a nuisance, it's a steady tax: hand-written request/response types that quietly lie, runtime errors that should have been compile errors, and a frontend team that can't fully trust the API it's building against. Multiply that by dozens of endpoints under active migration and it becomes a real drag on velocity.

The insight

The backend already knew the exact shape of every endpoint. Handlers are annotated with OpenAPI attributes, and a generator turns those into an openapi.yml spec. The spec was an accurate contract. The problem was that nothing forced the frontend to stay aligned with it - alignment was a manual, human, forgettable step.

So I removed the human from the loop and made the contract flow downhill, automatically, from one source.

What I built

A code-first OpenAPI pipeline, end to end.

1. The spec is generated from the code that serves the request

Handlers carry OpenAPI attributes; a command (zircote/swagger-php) scans them and emits openapi.yml. The spec is a build output, not a document someone maintains by hand and forgets to update.

#[OA\Get(
    path: '/customers',
    operationId: 'fetchAllCustomer', // ← becomes the SDK function name
    tags: ['Customers'],
    responses: [
        new OA\Response(
            response: 200,
            description: 'OK',
            content: new OA\JsonContent(
                type: 'array',
                items: new OA\Items(ref: '#/components/schemas/Customer')
            )
        ),
    ],
)]

That operationId is not a detail - it's the contract for the generated client. With it you get a clean fetchAllCustomer(); without it the generator emits hash-based garbage like getA3f8b2c1(). Which is exactly why I didn't leave it to discipline (see below).

2. The contract is linted

The spec is checked with Spectral against a custom ruleset before it's allowed downstream - every operation must have an operationId, schemas must live on DTOs (not entities), responses must be typed. The contract stays machine-consistent instead of slowly rotting.

3. The spec compiles to a typed SDK

@hey-api/openapi-ts turns the spec into a fully typed TypeScript client - every endpoint, every request and response type, an Axios-based client with auth interceptors. No interfaces written by hand. The frontend imports functions and types straight from the package:

import { fetchAllCustomer, findCustomerById, type Customer } from '@org/api-client'

// Fully typed: params, query shape, and the Customer return type all come
// from the backend's OpenAPI spec. Rename a field server-side and this stops
// compiling.
const { data } = await fetchAllCustomer({
  query: { pageIndex: 1, resultsPerPage: 10, search: 'acme' },
})
const customer: Customer = await findCustomerById({ path: { id: 123 } })

4. Publishing is automated on change

A GitHub Actions pipeline regenerates the client and diffs it against what's committed; if it differs, it publishes a new version to a private registry, with the version bump derived from conventional commits. The frontend consumes it like any other dependency - no one runs a manual "regenerate the types" ritual, because there isn't one.

Closing the last gap: enforcing the rule that makes it work

The whole pipeline hinges on every endpoint having a correct operationId - a thing humans forget. So rather than rely on review to catch it, I made it a custom static-analysis rule in the backend: a handler without a proper operationId fails CI. The convention that the automation depends on is itself automated. (More on that approach in Making architecture enforce itself.)

The payoff

  • Drift becomes structurally impossible. The frontend's types are the backend's contract, so it cannot silently fall behind.
  • A breaking change becomes a red build. Rename a field on the server and the next client regen turns every affected call site into a TypeScript error - caught before merge, not by a merchant.
  • The boring, error-prone work disappeared. The frontend stopped hand-writing and babysitting request types and consumed the generated SDK instead. An entire category of bug - the API returns X, the frontend expects Y - stopped being possible, because the types are generated from the same spec the API serves.

This is the kind of infrastructure that never shows up in a demo and quietly pays off on every endpoint added after it. I wrote about the general approach in Auto-generating a TypeScript API client

  • this is what it looks like wired into a real platform, with the linting and CI that make it trustworthy.

Let’s build something that lasts

Hiring a senior engineer or architect for a remote team? I work across EU and US time zones - tell me what you're building.

Newsletter

I write about software architecture, PHP, Vue, TypeScript, and developer experience. No spam, unsubscribe anytime.

Copyright © 2026. All rights reserved.