Part of The Coercion Saga — making AI write quality code.
The uncomfortable situation
Backend tests pass. Frontend tests pass. Each side works in isolation. But do they work together?
Backend changes API. Frontend breaks. Nobody notices until production.
You rename a field from userName to username. Backend tests pass. Frontend tests pass—they mock the API anyway. Everything is green. You deploy. Production breaks because frontend expects userName but backend sends username.
This happens more often than anyone wants to admit.
The Problem With Mocks
Frontend tests mock the API. They have to—you can’t run the real backend in every test. But mocks lie. They return what you told them to return, not what the backend actually returns.
Backend changes. Mocks don’t. Tests pass. Production fails.
The solution: a single source of truth that both sides must follow.
OpenAPI as the Contract
FastAPI generates OpenAPI specs automatically from your type hints:
@router.post("/users", response_model=UserResponse)
async def create_user(user: UserCreate) -> User:
...
No extra work. Your types become the spec. The spec becomes the contract.
{
"paths": {
"https://dev.to/users": {
"post": {
"requestBody": { "$ref": "#/components/schemas/UserCreate" },
"responses": {
"200": { "$ref": "#/components/schemas/UserResponse" }
}
}
}
}
}
This file lives in the repo. Both sides must match it.
Orval: Generated TypeScript Client
Orval reads the OpenAPI spec and generates TypeScript. Not just types—full API clients with the HTTP layer baked in.
Configuration lives in orval.config.ts:
export default defineConfig({
api: {
input: '../shared/openapi.json',
output: {
target: './src/api/generated/endpoints.ts',
schemas: './src/api/generated/models',
client: 'react-query',
mode: 'tags-split',
},
},
});
Run npx orval and you get:
// Generated - don't edit
export const useCreateUser = (
options?: UseMutationOptions<UserResponse, Error, UserCreate>
) => {
return useMutation({
mutationFn: (userCreate: UserCreate) => createUser(userCreate),
...options,
});
};
export const createUser = (userCreate: UserCreate): Promise<UserResponse> => {
return customFetch<UserResponse>({
url: '/users',
method: 'POST',
data: userCreate,
});
};
React Query hooks out of the box. Mutations, queries, cache invalidation patterns. All typed.
You can also generate:
-
Axios clients —
client: 'axios' -
Fetch clients —
client: 'fetch' -
SWR hooks —
client: 'swr' - Zod schemas — for runtime validation on top of compile-time types
The mode: 'tags-split' option generates one file per API tag. Clean separation. users.ts, products.ts, orders.ts. Import only what you need.
No manual API client. No type drift. The types always match the spec because they’re generated from it.
Backend renames userName to username? The generated types change. TypeScript screams at every place in the frontend that still uses the old name. You fix it before it ships.
The Workflow
- Backend changes the API
- OpenAPI spec updates (FastAPI does this automatically)
- Run
npm run codegenin frontend - Generated types update
- TypeScript catches breaking changes
- Fix the frontend
- CI validates everything matches
No coordination meetings. No “hey did you update the frontend?” The types enforce the contract.
The Gate
Two checks. Backend spec must match reality. Frontend types must match spec.
validate:openapi:
stage: contract
image: python:3.12-slim
services:
- name: postgres:16
alias: db
variables:
DATABASE_URL: postgresql://postgres:postgres@db:5432/test
before_script:
- pip install uv && cd backend && uv sync --frozen
script:
- cd backend && uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 &
- sleep 5
- curl -s http://localhost:8000/openapi.json > /tmp/live-spec.json
- diff shared/openapi.json /tmp/live-spec.json
allow_failure: false
validate:codegen:
stage: contract
image: node:lts-slim
before_script:
- cd frontend && npm ci --prefer-offline
script:
- npm run codegen
- git diff --exit-code src/api/generated
allow_failure: false
validate:openapi — Starts the backend, fetches the live spec, compares to committed spec. Different? CI fails. Someone changed the API without updating the spec.
validate:codegen — Regenerates the TypeScript client, checks if it differs from what’s committed. Different? CI fails. Someone updated the spec without regenerating the client.
Copy, paste, adapt. It works.
The Point
Backend and frontend speak different languages. Python here, TypeScript there. Without a contract, they drift apart. Small changes accumulate. Production breaks.
OpenAPI is the contract. Orval generates the types. CI validates the match.
Breaking changes are impossible to miss. Not “unlikely.” Impossible. The pipeline catches them before they ship.
That’s the deal.
Next up: [E2E Tests] -coming soon- — The contract is enforced. Now test the real thing: a browser hitting the full stack.