MOVGRANTO

One API Contract for Backend, Web and Mobile App

BusiKM is three repos and one API contract: schema.yaml in the backend, generated types in web and mobile, and CI that blocks drift before develop and release.

Published: 6 min read

Author: Łukasz Głowacz, Full-Stack Engineer · Czytaj po polsku

A schema.yaml file in the backend, from which types and hooks are generated in the web app and types in the mobile app.
Contents

BusiKM is three repositories: a Django backend, a Next.js web panel and a React Native driver app. One person and one API contract between them. When the backend changes a field in a response, web and mobile need to find out before the change reaches users, not after someone reports that a screen crashed. In this post I describe how I keep that contract in a single file and how CI makes sure the repositories don't drift apart.

One file as the source of truth

The August rollout plan has two short decisions on this. First: one shared API contract, the schema.yaml file in the backend repository. Second: day to day we take the schema locally, and from staging only when moving onto staging. The order of work is also fixed: backend, then web, then mobile.

Previously schema.yaml was in .gitignore. Each repository fetched the schema from a running backend, local or staging, and there was no single version to refer to. Since September 1 the file has been versioned in the backend repository and today it is 47,996 lines long. drf-spectacular generates it from serializers, views and routing.

API contract: the backend code generates schema.yaml, which is the source of truth in the repository. Web generates types and hooks from it with orval and openapi-typescript, the driver app generates types with openapi-typescript. The sync-api.sh script fetches a copy from three sources: the local backend, the file in the repository, or the staging API

Web and mobile don't rewrite types by hand. Web generates types from the file (openapi-typescript) along with ready-made data-fetching hooks (orval); the driver app generates types only. If the backend renames a field, the TypeScript compiler in web and mobile points to every place that needs fixing.

The same code must produce the same file

A versioned file only makes sense if it generates deterministically. My first attempt showed it didn't. Two generations from identical code produced files that differed by 3,294 lines, all of it reordered operations. drf-spectacular iterates over structures whose order depends on Python's random hash seed. Without a fixed PYTHONHASHSEED=0, every drift check would have been random from day one.

The second cause showed up in CI on the same PR. The dependency was declared as a version range. CI installed drf-spectacular 0.30.0, I had 0.29.0 locally, and the schema came out different. The CI check rightly flagged it. Now the generator is pinned to exactly one version, the only dependency in the project whose output we keep in the repository. Bumping its version is a separate commit together with regenerating the schema.

A pre-commit hook that won't let a change through without the schema

It's easy to forget to update the file in the repository. That's why the backend has a pre-commit hook that checks whether a commit touches anything that shapes the API contract:

# Files that shape the API contract.
API_SHAPING=$(git diff --cached --name-only --diff-filter=ACMR | grep -E \
  '^(apps/.+/(serializers|views|urls|models|filters|enums|permissions)[^/]*\.py|apps/.+/(serializers|views)/.+\.py|config/urls\.py|config/settings/base\.py)$' || true)
 
if [[ -z "$API_SHAPING" ]]; then
  exit 0  # nothing that changes the contract
fi
# ...regenerate schema.yaml and block the commit if it differs from the staged one

If a commit changes a serializer, view, routing, model or filter, the hook regenerates the schema into a temporary file, compares it with schema.yaml and blocks the commit if they differ. The message lists the differences and the command that fixes it. I write most of the code with Claude Code, so a hook like this matters more to me than to a team that regenerates out of habit. The agent doesn't have to remember, because a commit without the schema simply won't pass.

Three sources for the same copy

Web and mobile share the same sync-api.sh script, which fetches a copy of the schema from one of three sources:

  • local (default): the local backend, for everyday work, because it gives instant feedback after a backend change,
  • repo: the file from the backend repository, no network needed, for comparisons in CI and offline work,
  • staging: the staging API, only when moving onto staging, after the backend is deployed.

There is one source of truth, and the script only chooses where to take its copy from. After fetching, it generates the types right away.

CI blocks drift where it matters

The most interesting decision is about when contract drift is an error. On a working branch it's normal: since work goes in order, the backend is often ahead of the frontend. Blocking every PR from a feat/* branch would only get in the way. So the contract-drift job in web and mobile has a rule that depends on the target branch:

# Rules per target branch:
#   feat/*         -> warn  (the backend is often ahead, local work)
#   develop        -> BLOCK, source: schema.yaml from the backend repo (no network)
#   staging, main  -> BLOCK, source: api-staging (the contract actually deployed)
target="${{ github.base_ref || github.ref_name }}"
case "$target" in
  develop)       source=repo;    blocking=true  ;;
  staging|main)  source=staging; blocking=true  ;;
  *)             source=repo;    blocking=false ;;
esac

Drift check rules in CI: on feat branches it warns, on develop it blocks and compares against the file in the backend repository, on staging and main it blocks and compares against the staging API, i.e. the contract actually deployed

Before develop, the comparison is made against the file in the repository, with no network, so it doesn't depend on whether staging happens to be up. Before staging and main, the comparison is made against the staging API, because what counts there is the contract that is actually deployed, not the one written in a file.

What it costs

The first cost is cross-repository access. For web CI to fetch the file from the backend repository, it needs a separate token. Without it, the job doesn't fail the build; it just notes in the summary that drift on develop is not being detected. It's a deliberate choice so that missing configuration doesn't block all work, but it's also a place where the check can silently stop working.

The second cost is an older mechanism that still runs alongside. After every merge to develop, the backend sends an event to the web repository, and after 30 seconds web fetches the schema from staging and opens an automatic PR with the update. Those 30 seconds are an assumption that the staging deployment will have finished by then. If it hasn't, the PR gets the old schema. The drift check will catch it anyway, but it's a good example that waiting via sleep is guessing, not synchronization.

Of this whole setup, one thing paid off the most: the contract is a file, not the state of a running server. A file can be compared in CI, versioned, reviewed in a PR and reproduced exactly. If you're building a SaaS with a separate frontend and a mobile app, start with where the contract lives before you start generating types from it. For how the same driver app reacts to live changes to orders, see my post on WebSocket as a signal. If you want to talk about your system's architecture, get in touch.

Łukasz Głowacz

About the author

Łukasz Głowacz

Full-Stack Engineer · MOVGRANTO

Full-Stack Engineer at MOVGRANTO. Builds systems from architecture to production: backend, web, mobile, payments and CI/CD. Author of BusiKM, a SaaS for managing van fleets.

LinkedInGitHub

BusiKM

Fleet management for 2.5-3.5 t vans: orders, live fleet map, driver working time and costs in one place. 14 days free.

Try BusiKM ↗

Building a similar system?

We join teams as full-stack engineers or build products from scratch. Let's talk about your project.

Book a call →

Newsletter

One real architecture decision from building a production SaaS, every week.