system-design

API Design Principles

The API is the one part of your system you can never take back. Once someone integrates against it, it’s a promise — and breaking a promise breaks their code.

Prerequisites: HTTP & TLS, Client–Server Model Time to read: ~18 minutes


Why the API is special

Every other part of your system is yours to change. You can swap the database, refactor a service, rewrite the algorithm — as long as behaviour is preserved, nobody notices.

🚨 The API is different, because other people’s code depends on its exact shape. A mobile app shipped last year, a partner’s integration, a script someone wrote — all encode assumptions about your API. Change it and their code breaks, and you can’t fix their code.

This asymmetry is the whole reason API design matters. An internal function you can rename in seconds; a public field you can be stuck supporting for a decade. Design accordingly.


The principles

1. Design for the consumer, not the database

🚨 The most common mistake: exposing your internal data model as the API.

//  Your database tables, leaked
{"usr_id": 42, "usr_fname": "B", "usr_lname": "A", "addr_tbl_fk": 991, "created_ts": 1735689600}

//  What the consumer actually wants
{"id": 42, "firstName": "Bilal", "lastName": "Aslam",
 "address": {"city": "Lahore"}, "createdAt": "2026-07-22T10:00:00Z"}

The API is a contract designed for its users, not a window onto your schema. Coupling them means you can never refactor your database without breaking clients, and clients inherit your table naming, your foreign keys, and your internal concepts.

Think in terms of the use cases the consumer has, and shape the API around those.

2. Consistency over cleverness

🚨 The single most important quality of a good API is that it’s predictable. If a developer can guess the next endpoint from the pattern of the last one, you’ve succeeded.

Pick conventions and apply them everywhere:

An API where /users returns user_name and /orders returns orderName and /products returns ProductTitle forces the developer to check the docs for every single field. Consistency is a feature.

3. Least astonishment

The API should behave the way a reasonable developer expects. A GET never has side effects. A DELETE on something already deleted returns success (or 404), not an error. A list endpoint returns an empty array [], not null, when there’s nothing. 201 Created returns the created resource.

🚨 Surprises are bugs waiting to happen in your consumers’ code — and you’ll never see them until they file a support ticket.

4. Be conservative in what you send, liberal in what you accept

Postel’s Law, and it has a real subtlety:

🚨 The critical corollary: clients must ignore unknown fields in responses. This is what lets you add fields to your API without breaking existing clients — the single most valuable piece of forward-compatibility. Document it and enforce it in your own client libraries. → Versioning

5. Make the common case easy and the complex case possible

The 90% use case should require minimal effort — sensible defaults, few required parameters. The rare advanced case should be possible without cluttering the common path.

GET /orders                          → sensible defaults (recent, paginated, common fields)
GET /orders?status=shipped&fields=id,total&sort=-createdAt&expand=customer  → power user

6. Explicit over implicit

7. Idempotency and safety by design

Make reads safe and repeatable operations idempotent, and give clients a way to make writes idempotent (an idempotency key on POST). This isn’t optional polish — it’s what makes your API usable over an unreliable network, where clients must retry. → Idempotency

8. Pagination, filtering, and limits from day one

🚨 Never return an unbounded list. GET /users on a table with 10 million rows is a design error that will take down your database and the client. Every collection endpoint needs a default limit and pagination — retrofitting it is a breaking change. → Pagination


Resource naming

The URL structure is the most visible part of your API. Conventions:

GET    /users                 list users
POST   /users                 create a user
GET    /users/42              get one user
PUT    /users/42              replace user 42
PATCH  /users/42              partially update user 42
DELETE /users/42              delete user 42

GET    /users/42/orders       list user 42's orders (nested resource)
GET    /orders/991            get order 991 directly (also accessible at top level)

Rules:

🚨 The actions-that-aren’t-resources problem. How do you model “cancel an order” or “send an email”? Options: a sub-resource state change (PATCH /orders/991 {"status": "cancelled"}), a sub-collection (POST /orders/991/cancellation), or a pragmatic action endpoint (POST /orders/991/cancel). Purists dislike the last one; it’s widely used and perfectly clear. Be pragmatic — a readable action endpoint beats a contrived resource.


Choosing the API style

This is a per-context decision, and each has its own chapter:

Style Best for Chapter
REST Public APIs, CRUD, broad compatibility REST
GraphQL Flexible client-driven data fetching, many clients GraphQL
gRPC Internal service-to-service, high performance gRPC
Webhooks Server-to-client notifications of events Webhooks

🎙️ The default recommendation: “REST/JSON at the public edge for compatibility and debuggability, gRPC internally for performance, GraphQL where clients need flexible data shapes, and webhooks for event notifications to third parties.” Most large systems use all four for different purposes.


Documentation and contracts

🚨 An undocumented API might as well not exist. The documentation is the product, for an API.

Machine-readable specs are the foundation:

Why a machine-readable contract matters beyond docs: it lets you generate clients (so consumers don’t hand-write them), validate requests and responses automatically, catch breaking changes in CI, and run consumer-driven contract tests — where consumers declare what they depend on and the provider verifies it before deploying. That last one is how you safely evolve an API with consumers you can’t coordinate with.


⚖️ Trade-offs

Principle Gain Cost
Consumer-shaped API Clients get what they need; you can refactor internally A translation layer between API and storage
Strict consistency Predictable, guessable Discipline; harder to make exceptions
Accept-liberal/send-strict Forward compatibility Clients must ignore unknown fields
Explicit versioning Safe evolution More surface to maintain
Pagination everywhere Bounded responses; scalable Slightly more complex endpoints
Rich query support Powerful for consumers More to implement, test, and secure

In the real world


🚨 Interview traps


🎙️ Soundbites


🛠️ Try it

1. Redesign a leaked API. Find a real API (or one you’ve built) that exposes internal names — user_id, created_at, foreign keys. Redesign it as a consumer-shaped contract. Notice how the translation layer decouples the two.

2. Write an OpenAPI spec. Take three endpoints and write their OpenAPI definition. Then generate a client and server stub from it. Seeing a working client generated from a spec makes the value of a machine-readable contract concrete, and it takes twenty minutes.

3. Audit for consistency. Take any multi-endpoint API and check: are all collections plural? Are all dates the same format? Is the error shape identical everywhere? Are naming conventions uniform? Most APIs fail at least one, and each inconsistency is friction for every consumer.

4. Model the tricky cases. How would you design: cancel an order, resend a receipt, bulk-import users, search across resources, and a long-running export? Each pushes on “everything is a noun/CRUD” and forces pragmatic decisions.


Check yourself

1. Why shouldn't your API expose your database schema? Because the API is a public contract that consumers' code depends on permanently, while your database schema is an internal implementation detail you want to change freely. Coupling them means you can never refactor storage — rename a column, split a table, switch databases — without breaking every client, and it forces consumers to work with your internal concepts (foreign keys, table-prefixed names, storage-driven structure) that don't match their use cases. Design the API around what consumers actually need, with a translation layer between the contract and the storage, so the two can evolve independently.
2. Why is consistency more important than any individual clever design choice? Because an API's usability is dominated by predictability. If naming, date formats, pagination, error shapes, and resource structure are uniform, a developer learns the pattern once and can then guess the rest — reducing the cognitive load of every subsequent endpoint to near zero. If they're inconsistent, the developer must consult the documentation for every field of every endpoint, and every inconsistency is a place to make a mistake. A slightly awkward but consistent convention beats a locally-elegant but inconsistent one, because the cost of inconsistency is paid on every single call by every single consumer.
3. What does "clients must ignore unknown fields in responses" enable? Forward compatibility — the ability to add fields to your API without breaking existing clients. If clients ignore fields they don't recognize, then adding `loyaltyPoints` to a user response is a non-breaking change: old clients simply don't see it, new clients use it. If clients instead reject or crash on unknown fields (strict parsing), then *every* additive change becomes breaking and you can't evolve the API without a version bump. This is why it's worth enforcing in your own client libraries and documenting for consumers — it's the single most valuable forward-compatibility property, and it costs nothing.
4. How do you model actions that aren't naturally CRUD, like "cancel an order"? Three pragmatic options. **State change on the resource:** `PATCH /orders/991 {"status": "cancelled"}` — clean when cancellation is just a status transition. **Sub-resource creation:** `POST /orders/991/cancellation` — models the cancellation as a thing that gets created, which fits when it has its own data (reason, timestamp, who did it). **Action endpoint:** `POST /orders/991/cancel` — the pragmatic and widely-used choice, disliked by REST purists but perfectly clear. The right answer is readability over dogma: a clear action endpoint beats a contrived resource noun. What you should *not* do is `GET /orders/991/cancel` (a mutation behind a safe method) or an RPC-style `POST /cancelOrder`.
5. Why does a machine-readable contract (OpenAPI, proto, GraphQL SDL) matter beyond documentation? Because it turns the API contract into something tooling can act on. From it you can generate client SDKs (so consumers don't hand-write and mis-implement clients), generate server stubs, validate requests and responses automatically at runtime, produce always-accurate interactive documentation, and — most importantly for evolution — detect breaking changes in CI and run consumer-driven contract tests, where each consumer declares what it depends on and the provider verifies those expectations before deploying. That last capability is how you safely evolve an API when you can't coordinate with all the consumers, which is the fundamental challenge of API design.

Further reading