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 team stopped writing and babysitting request types and started trusting the SDK. Feature work got faster because an entire category of bug stopped existing.

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.