Designing APIs That Won't Need a Rewrite
Executive TL;DR
- Protocol choice is not why APIs get rewritten. They get rewritten because the database schema leaked into the response, so every table change became a breaking change. Map every response through an explicit shape your code owns — that single boundary buys most of the longevity.
- Five decisions make later change cheap, and all five are nearly free on day one: an explicit response shape, cursor pagination, idempotency keys on writes, a machine-readable error format (RFC 9457), and field-level usage telemetry so you know who would break.
- Version as a last resort, not a plan. Additive changes need no version at all; a new version is a migration you will run twice, once for the server and once for every client. If you must version, date-based with a translation layer beats a /v2 fork.
No one wants to reimplement an API. What happens is that the change is no longer safe. Someone wants to rename a field, or split a table, or return a list that is suddenly too long, and no one can say which of the nine clients will break, so the change is deferred. And six months later that team has a contract no one is willing to touch, and “we should rebuild the API” is on a planning document.
It is almost never the protocol. Notice what caused that. Teams rewrite REST APIs and GraphQL APIs at roughly the same speed. The usual suspects are less exciting: The response was a serialized database row, so the schema and the contract became the same object. writes weren't idempotent, so retries created duplicates, and clients grew defensive workarounds you can't now break. Offset-based pagination caused results to shift under clients. And someone built a cache you didn't know about. Errors came back as free text, so every client wrote a string match against your error messages.
Each of those is cheap to get right at the start but expensive to retrofit once there are consumers.” So the useful framing is not "which API style is best" but "which style, plus which hygiene, makes the next two years of change cheap."
Versioned REST, evolving REST, GraphQL, or typed RPC — which contract style survives two years of product change?
| Versioned REST | Evolving REST | GraphQL | gRPC / typed RPC | |
|---|---|---|---|---|
| Cost to build | Low — conventional and well understood | Low, plus the discipline to refuse breaking changes | Moderate — schema, resolvers, query-cost controls | Moderate — toolchain and codegen, higher for browser clients |
| Client coupling | Medium — clients pin to a version and drift | Low — clients tolerate new fields and keep working | Lowest — clients ask for the fields they want | Tight but typed; a regenerated client catches breakage at compile time |
| Cost of changing it later | High — each version is a migration run twice, server and clients | Lowest for additive change, but removals still need a deprecation path | Low for adding, hard for removing — a field is live until every consumer stops asking for it | Low within the proto rules: add fields, never reuse tags, never change types |
| Caching and edge behaviour | Strong — plain HTTP GETs cache everywhere | Strong, same reason | Weak by default — one POST endpoint defeats HTTP caching without extra work | Not HTTP-cacheable; you build your own layer |
| Tooling and ops burden | Low | Low | Higher — depth limits, cost analysis, N+1 resolvers, persisted queries | Higher — proxies, browser support via gateway, observability on streams |
| Who can consume it | Anyone with curl | Anyone with curl | Anyone, though partners often ask for REST anyway | Mostly your own services and generated SDKs |
| Fails when | Versions multiply and you maintain three forks forever | The team ships a breaking change anyway without a policy to stop it | Clients are external and unpredictable, or your team has no query-cost budget | Public or partner consumers need to poke it from a terminal |
When to pick each option
Make REST for public and partner APIs the default. The rule is simple enough to enforce in review: you may add fields and endpoints, but you may not remove or repurpose. Clients should ignore unknown fields (say so in your docs), and additive change then costs nothing on either side. “It’s the cheapest contract to run. The easiest contract to cache. And it’s the one a prospective customer can exercise in a terminal in an evaluation call.”
If you really do have to make a breaking change and you have external consumers who are not going to migrate on your schedule, then use versioned REST. Prefer date-based versions with a translation layer—requests come in pinned to a date and get transformed into the current internal shape—over a forked /v2 codebase. The most public example of this is Stripe, and it’s well documented that old integrations keep working for years, because the translation lives in one place, not a parallel copy of the API. A /v1 and /v2 fork means that each bug is fixed twice.
Use GraphQL when many different clients need different slices of the same data and they are mostly yours (several apps, several screens, and one team shipping them). It really cuts down on the API-change treadmill for front-end work. Budget for the ops that come with it: depth and complexity limits, persisted queries for the public edge, and resolver level N+1 monitoring. And get the reverse trap—with GraphQL, nothing is ever safe to remove until field-level usage says it is unused.
Use gRPC or typed RPC between your own services, where both sides are generated from one schema and a compiler catches breakage before deploy. It is the strongest option for internal, high-volume, strongly typed traffic and usually the wrong first choice for partners who want to try your API with curl before signing anything.
Rendering diagram…
// 1. EXPLICIT SHAPE — hand-written, owned by you. Add a column to the table and
// nothing leaks; the mapper is the only place that decides what ships.
type OrderResponse = {
id: string;
status: 'pending' | 'shipped' | 'cancelled';
total: { amount: number; currency: string }; // object, not `total_cents`:
created_at: string; // room to add fields later
};
function toOrderResponse(row: OrderRow): OrderResponse {
return {
id: row.public_id, // never the database primary key
status: row.status,
total: { amount: row.total_cents, currency: row.currency },
created_at: row.created_at.toISOString(),
};
}
export async function listOrders(req, res) {
// 2. CURSOR PAGINATION — opaque cursor, stable under inserts. Offsets skip and
// duplicate rows the moment data changes, and clients cache the damage.
const limit = Math.min(Number(req.query.limit ?? 25), 100);
const cursor = decodeCursor(req.query.cursor); // { created_at, id }
const rows = await db.orders.page({ after: cursor, limit: limit + 1 });
const hasMore = rows.length > limit;
res.json({
data: rows.slice(0, limit).map(toOrderResponse),
next_cursor: hasMore ? encodeCursor(rows[limit - 1]) : null,
});
// 5. TELEMETRY — which consumer read which fields. Without this, every
// deprecation is a guess and nothing ever gets removed.
trackFieldUsage({ endpoint: 'orders.list', consumer: req.auth.clientId });
}
export async function createOrder(req, res) {
// 3. IDEMPOTENCY — a retried POST must not create a second order. Store the
// response against the key and replay it.
const key = req.header('Idempotency-Key');
if (!key) return problem(res, 400, 'idempotency_key_required');
const existing = await idempotency.get(key);
if (existing) return res.status(existing.status).json(existing.body);
const order = await orders.create(req.body, { idempotencyKey: key });
const body = toOrderResponse(order);
await idempotency.put(key, { status: 201, body }, { ttl: '24h' });
res.status(201).json(body);
}
// 4. STABLE ERRORS — RFC 9457 problem details. Clients switch on `type` and
// `code`, never on your prose, so you can reword a message without breaking them.
function problem(res, status: number, code: string, detail?: string) {
res.status(status)
.type('application/problem+json')
.json({
type: `https://api.example.com/problems/${code}`,
title: titleFor(code),
status,
code, // the field clients actually branch on
detail, // human-readable, safe to change
instance: res.locals.requestId,
});
}MVP development planner
Scope, budget, and launch plan
Choose the product, platforms, and modules. The range, phases, and team update as you go. It is a planning figure, not a quote.
Newsletter
One architecture teardown or evaluation, every other week
Practical write-ups on framework choices, migrations, and scaling decisions—written for people who have to defend the call in meetings.
Newsletter
Get the 2026 Tech Stack Guide
Join the KarmaKoders newsletter for architecture notes, stack evaluations, and build playbooks.