Hi Friends,
Welcome to the 191st issue of the Polymathic Engineer newsletter. This week, we are starting a new series of articles on API design.
Let’s be honest: building an API is quite fast with modern frameworks. You can get a working endpoint up and running in minutes. The real nightmare starts a few months later, once consumers rely on the API. Then every name you picked and structure you chose becomes difficult to change or remove. Here, we will explore how to design APIs that are intuitive for consumers and easy for the managing team to evolve.
Whether you are designing APIs as part of your job or preparing for system design interviews, these are concepts you will use time and again.
The outline for this first article is as follows:
- API design and coupling
- What makes an API RESTful
- The Richardson Maturity Model
- Adopting an API standard
- Designing collections
- Error handling
To learn technical skills, you must work on real projects. CodeCrafters is a great platform for that. You can build your own Redis, Kafka, DNS server, SQLite, HTTP server, or Git from scratch using your chosen programming language.
API design and coupling
Much of the discussion surrounding API design gets bogged down in the weeds: “Should the field be called userName or displayName?” “Is PATCH better than PUT?” These topics are important, but they are details. They miss the real elephant in the room: coupling. The real challenge with API design is how strongly consumers are bound to the decisions of the development team.
Suppose that you’re building the booking service of an event platform. Your service owns the data and exposes a Booking API to two consumers: your company’s own legacy system, and a third-party partner reselling tickets on their own site.
The two consumers put different constraints on your design. Coordinating changes with the team managing the legacy system is straightforward: they work in the same office, and any migration can be planned together. In contrast, you have no control over the code of the external partner or their release schedule.
Yet, both consumers call the same API. Once this is published, you can refactor the backend logic as much as you want, but you can’t change the API itself.
The golden rule is to design the API from the outside in, prioritizing the consumer's perspective. An API should model what your service does, not how it implements it. If your internal architecture leaks into the interface, even the slightest backend modification risks becoming a breaking change. If, however, the architecture remains hidden, a database can be replaced or a service split into microservices without consumers even noticing.
This consumer-first mindset is also what sets apart great candidates in system design interviews. Anyone can list a few REST endpoints, but the engineers who get hired are those who start by asking who the consumers are and what level of coupling they can realistically sustain.
What makes an API RESTful
REST is the default architectural style for web APIs these days, but most developers throw around the term "RESTful" pretty loosely. Many so-called RESTful APIs are just HTTP endpoints spitting out JSON. We already compared the main API styles in a previous issue, and HTTP itself in another. Here, we focus on what the REST label actually demands.
REST models an exchange between a producer and a consumer, in which the producer exposes resources that the consumer can interact with. A resource is anything the service can name, such as a booking or a set of bookings. You use HTTP verbs to state your intent: GET to read, POST to create, PUT or PATCH to update, and DELETE to clear it out.
Here is what that looks like for a booking service:
http
GET /bookings/42
Accept: application/json
---
200 OK
Content-Type: application/json
{
"id": 42,
"displayName": "Jim",
"event": "tech-conf-2026"
}The URL names the resource, the method states the intent, and the status code informs the consumer how the operation went on the producer side. The exchange is also stateless: the producer doesn’t retain any state from previous requests, so the consumer must include everything needed to process each request. If a request depends on a prior one, it is the consumer’s job to carry that context forward.
The aspect worth exploring further is the meaning of the methods, because they hide the guarantees consumers rely on. A method is safe if it doesn’t change anything on the server. GET is safe: anyone can invoke it as often as they like without side effects. A method is idempotent if repeating it gives the same result as calling it once. PUT is idempotent because it replaces the entire resource with the payload you send. DELETE is also idempotent since deleting a booking twice leaves the system in the same state as deleting it once. POST is neither safe nor idempotent: each call makes a new resource.
You should now also be able to answer the question of when to use PATCH or PUT. Use PUT when you’re replacing a resource entirely. Use PATCH when you want to modify only some fields. The trade-off for PATCH's convenience is that idempotency is no longer guaranteed.
These properties are not just theoretical, since much of the web's machinery is built around them. For example, a cache can store the response to a GET request, and a client can retry a failed PUT without fear. If your API uses GET for writing or POST for reading, that machinery works against you, and the bugs it generates are painful to track down.
The Richardson Maturity Model
Not every API built on HTTP is equally "RESTful." Back in 2008, Leonard Richardson spent a lot of time reviewing real-world REST APIs and distilled his findings into a four-level model that classifies the extent to which those APIs were built from a REST perspective. The model is still the most practical heuristic to answer the question of how RESTful your API is:
Level 0 just uses HTTP as a transport layer. You have a single URL, such as
/bookingService, and every request goes there. Basically, this is a remote procedure call (RPC) service that happens to speak HTTP.Level 1 introduces the use of resources and the idea of modeling resources as URIs. Individual resources have unique URLs: /bookings/42 identifies a single booking, while /bookings identifies the entire collection.
Level 2 brings in the verbs. GET /bookings/42 reads a booking; DELETE /bookings/42 removes it. APIs at level 2 can make the guarantees we discussed in the previous section.
Level 3 involves navigable APIs, also known as HATEOAS. Each response contains the actions possible on the object returned from the server: the response to GET /bookings/42 would include links to update or cancel that booking.
In practice, level 3 is rarely a target. Although navigation is useful in flexible UI-style systems, it is not so valuable for interservice API calls. Using HATEOAS would only make the exchange more verbose, and it is commonly circumvented, as developers program against a specification of possible interactions that they read up front.
The best compromise for most APIs is level 2. It gives your consumers an understandable resource model, with appropriate actions available for it. In turn, this reduces coupling and keeps the backing service's details hidden.
Adopting an API standard
Reaching level 2 still leaves many everyday decisions open. “What is the best way to report errors?” “How do you avoid accidentally breaking backward compatibility?” “What is the best pagination strategy?” REST doesn’t say anything specific about any of this. Every team answers these questions slightly differently, and every consumer pays the price when integrating a new API.
The good news is that several companies open-sourced their internal API guidelines, and adopting one is the fastest way to shortcut design discussions. The Zalando RESTful API guidelines are a great starting point: actively maintained and organized as a list of MUST and SHOULD rules you can check a design against. Microsoft’s guidelines are another well-known reference. Whichever you pick, it’s important to adopt it early: aligning an API with a standard later means renaming things, and renaming things breaks your consumers' integrations.
A tangible example of how these guidelines might help you is the use of personal data in URLs. It might feel reasonable to look up bookings by email, such as /bookings?email=jim@example.com. Microsoft’s API guidelines warn against it: URLs end up in server logs, proxy caches, and browser histories, where nobody expects sensitive data to show up. The recommendation is to keep personal information in the body or in the headers, and identify resources with opaque IDs.
Naming deserves the same care. Guidelines come with a short list of standard names, but your domain needs its own common data dictionary. Is the person who books a “customer”, an “attendee”, or a “user”? Companies that provide consistency across their APIs are more likely to enable consumers to understand and connect responses.
Standards also cover other topics such as authentication, but we won’t repeat them here. We already dedicated two entire issues to those fundamentals.
Designing collections
Almost every API returns a collection of items, and the natural way to model the response is a raw JSON array:
[
{ "id": 42, "displayName": "Jim", "event": "tech-conf-2026" },
{ "id": 43, "displayName": "Sara", "event": "tech-conf-2026" }
]This works, but the trouble starts when the collection grows. It’s impractical to return thousands of bookings in a single response, so you need pagination: returning partial results along with a pointer the consumer can follow to get the next set of results. But where does that pointer go? It can’t be stored in a raw array. To add it, you have to turn the array into an object, which breaks every consumer that parses the response.
The guidelines recommend wrapping collections in an object from day one:
{
"value": [
{ "id": 42, "displayName": "Jim", "event": "tech-conf-2026" }
],
"@nextLink": "/bookings?page=2"
}This costs nothing when the collection is small, and you can add metadata and links later without touching what consumers already parse. We dedicated an entire issue to pagination strategies, so we won’t detail the technicalities here.
Filtering and searching follow the same logic. You don’t have to support every possible query from the start, but adopting a convention early lets the API evolve without breaking compatibility for consumers. A commonly used one is the OData syntax, used by Microsoft Graph in production: GET /bookings?$filter=event eq ‘tech-conf-2026’.
The rationale is always the same: leave APIs room to grow in predictable ways.
Error handling
Consumers build logic around your status codes, and it’s important to carefully define what should happen when things go wrong:
3xx status codes are for redirects: consumers follow them automatically, which lets you relocate resources
4xx should indicate a client-side error for which the content of the message field is extremely useful
5xx typically indicates a failure on the server side, and some client libraries will retry on these types of failures.
That logic works only if the codes are accurate. If some APIs return a 2xx with an error message hidden in the body, consumers end up writing parsing code that second-guesses every success.
Suppose the booking service charges for a ticket and answers with a 500. The consumer has no way to tell whether the payment went through, since the request may have failed before or after the charge. This is why accurate codes and idempotent retries go hand in hand. A well-designed API makes it safe to ask again.
The guidelines recommend consistency too. Ideally, a consumer should be able to handle all errors with a single code path, which is only possible if they have the same shape with a machine-readable code and a human-readable message:
{
"error": {
"code": "EVENT_SOLD_OUT",
"message": "No seats left for tech-conf-2026."
}
}A specific case worth mentioning is security. Error messages sent back to an external consumer are publicly exposed, so they must not include stack traces or internal details. The details that help you debug are the exact same details an attacker aims to use to compromise the system. Keep the rich details in your logs, and send the consumer only what they can act on.
When a consumer sends more requests than you can handle, respond with a 429 and a Retry-After header indicating when to try again. Explicitly turning down requests is much better than slowing down for everybody.
Wrap Up
In this first article, we started with the observation that APIs are hard to change once published, and we have seen rules to address that concern: model resources at level 2, adopt a standard early, wrap your collections, and report errors consumers can build on.
None of them is rocket science. They just require making the correct decisions early, before the API ships and when changes are still cheap.
In the next article, we will look at the tool that automatically enforces these rules: the OpenAPI specification. We will also see how to catch a breaking change in your pipeline before any consumer sees it.




