COMP 4299|System Design

API Design

Resource: Neetcode β€” System Design for Beginners, Background Video 9

These notes cover the fundamentals of API design: how to model entities, structure endpoints, handle backwards compatibility, and apply pagination. The focus is on the surface area of an API, meaning what it exposes, what it accepts, and what it promises to return, independent of any implementation detail.

Contents

  1. What Is API Design?
  2. Modelling a Notes API
  3. Backwards Compatibility
  4. Versioning
  5. Pagination
  6. Idempotency & Safe Methods
  7. Rate Limiting
  8. Request & Response Examples
  9. Real-World API Documentation
  10. Summary

1. What Is API Design?

API design is concerned exclusively with the surface area of an API, which is the contract between a service and the developers who consume it. It answers three questions: what operations are available, what inputs do they require, and what will they return?

The underlying implementation (how data is stored, which database is queried, what internal services are called) is entirely out of scope. A well-designed API hides all of that behind a stable, predictable interface.

For any given resource (a note, a user, an order), there are four fundamental operations, collectively known as CRUD:

OperationHTTP VerbWhat It Does
CreatePOSTAdds a new resource
ReadGETRetrieves an existing resource
UpdatePUT / PATCHModifies an existing resource
DeleteDELETERemoves a resource

These operations act on entities, which are the nouns of a system. In a notes app, Note and User are entities. The API's job is to give callers a clean way to create, read, update, and delete them. REST uses HTTP verbs as a convention for expressing intent; other paradigms like gRPC can enforce these semantics more strictly at the schema level.

πŸ’‘Design the Interface, Not the Implementation

When designing an API, resist the urge to think about databases or internal services. Think only about what a caller needs to send and what they should receive back. A clean separation here is what makes an API genuinely reusable.

2. Modelling a Notes API

To make this concrete, we'll design a simple notes application. A note has the following fields:

FieldTypeSourceNotes
noteIdstringServer-generatedUnique identifier; assigned on creation
userIdstringProvided by callerIdentifies the note's owner
titlestringProvided by callerShort display name for the note
contentstringProvided by callerBody text of the note
tagsstring[]Provided by callerOptional; defaults to []
sectionIdstringProvided by callerOptional; groups the note under a section
createdAttimestampServer-generatedSet on creation; never modified
updatedAttimestampServer-generatedUpdated on every write

Fields like noteId, createdAt, and updatedAt are assigned by the server, so the caller never sends them. Fields like userId, title, and content are supplied by the caller. Optional fields like tags and sectionId carry sensible defaults, so a caller can omit them without breaking anything.

Rendering diagram…
Creating a note: the caller provides the required fields, the server assigns identifiers and timestamps, then returns the complete note object

The two core read endpoints follow a resource-oriented URL pattern:

  • Single note β€” GET /v1.0/note/:noteId retrieves a specific note by its ID, typically used when a user opens a note to view or edit its contents.
  • All notes for a user β€” GET /v1.0/users/:userId/notes retrieves every note belonging to a given user, used to populate a list or sidebar view.
GET https://api.awesome-notes.com/v1.0/note/:noteId
GET https://api.awesome-notes.com/v1.0/users/:userId/notes

3. Backwards Compatibility

Public APIs have a constraint that internal services don't: once a contract is published, developers build software that depends on it. Breaking that contract, even for a good reason, breaks their software.

Rendering diagram…
Adding an optional field is backwards-compatible. Existing callers who don't send it continue to work without any code change.

Optional parameters are backwards-compatible. If a new field carries a sensible default (or is simply ignored when absent), any existing caller who doesn't send it will continue to receive valid responses. No code change required on their side.

Rendering diagram…
Adding a required parameter to an existing endpoint breaks all callers who haven't updated. They can't satisfy the new contract without a code change.

Required parameters are not backwards-compatible. Every caller who doesn't send the new field will receive an error and their code breaks until they update it. This is why introducing a required field to an existing endpoint is one of the most disruptive changes an API can make.

The solution to both cases, but especially required fields, is versioning.

⚠️Never Add Required Fields to an Existing Endpoint

Once an endpoint is public, treat its required parameters as immutable. New required fields belong on a new version of the endpoint, not the existing one. Optional fields with defaults are safe to add in-place.

4. Versioning

When a change is significant enough that it can't be made backwards-compatible (a new required field, a renamed parameter, a restructured response), the right approach is to publish the change under a new version rather than modifying the existing endpoint.

# Original endpoint, still works for existing callers
POST https://api.awesome-notes.com/v1.0/note

# New version, introduces sectionId as a required field
POST https://api.awesome-notes.com/v2.0/note
Rendering diagram…
Versioning lets breaking changes land on a new endpoint. Existing callers stay on v1.0 without modification while new integrations target v2.0.

By maintaining both versions simultaneously, existing integrations continue to function while new callers can opt into the updated contract at their own pace. At some point, the older version is marked deprecated, which is a formal signal that it will eventually be removed and that developers should migrate. Deprecation notices are typically accompanied by a sunset date and migration documentation.

Versioning serves another purpose beyond breaking changes: security. When an API key or authentication mechanism is compromised, providers can issue a new version with updated key schemes, deprecating the old ones and prompting all clients to rotate their credentials.

πŸ“What Counts as a Breaking Change

Removing a field, renaming a parameter, changing a field's type, and adding a new required parameter are all breaking changes. Adding an optional field, adding a new endpoint, and adding new optional response fields are generally safe and don't require a version bump.

5. Pagination

Retrieving all notes for a user with GET /v1.0/users/:userId/notes works fine when a user has a handful of notes. It does not work when they have tens of thousands. Returning the entire dataset in a single response is slow, expensive, and often larger than any client can reasonably process.

The solution is pagination, which means returning a fixed-size slice of the dataset and letting the caller request subsequent slices as needed. Two query parameters control this:

  • limit β€” the maximum number of items to return in a single response. Acts as a cap; the server should also enforce its own maximum to prevent callers from requesting unlimited data.
  • offset β€” the number of items to skip before starting the current page. An offset of 0 returns the first page; an offset of 10 (with a limit of 10) returns the second.
# First page, items 1 through 10
GET /v1.0/users/:userId/notes?limit=10&offset=0

# Second page, items 11 through 20
GET /v1.0/users/:userId/notes?limit=10&offset=10

# Third page, items 21 through 30
GET /v1.0/users/:userId/notes?limit=10&offset=20
Rendering diagram…
Paginated requests retrieve a fixed-size slice of the dataset. The client advances the offset by the limit value to walk through subsequent pages.

Both limit and offset should be optional, with sensible defaults (e.g. limit=20, offset=0). A caller who doesn't specify them should still get a reasonable response, not an error, and not every record in the database.

πŸ’‘Always Set a Server-Side Maximum for limit

Treating limit as unbounded lets callers request millions of records with a single query. Set a hard ceiling on the server (e.g. limit ≀ 100) and return an error, or silently clamp the value, if a caller exceeds it. Never let user input drive unbounded database queries.

6. Idempotency & Safe Methods

A GET request should never create or modify data. This isn't just a convention; it has practical implications for caching, proxies, and retry behaviour.

A method is idempotent if calling it multiple times with the same inputs produces the same outcome as calling it once. GET, PUT, and DELETE are all idempotent. POST is not: calling POST /note twice with the same body creates two separate notes.

A method is safe if it produces no side effects at all and only reads data. GET is the only commonly safe method.

VerbSafe?Idempotent?
GETβœ… Yesβœ… Yes
POST❌ No❌ No
PUT❌ Noβœ… Yes
PATCH❌ Noβœ… Yes
DELETE❌ Noβœ… Yes

This matters for caching: because GET is safe and idempotent, browsers, CDNs, and reverse proxies can cache its responses freely. A GET /note/42 that returns the same note every time can be served from cache without hitting the origin server. If a GET endpoint secretly mutates data, that caching assumption breaks, and the mutation may never be executed at all on a cache hit.

πŸ”‘Never Modify Data in a GET Request

Using a GET endpoint to trigger a state change (logging a view count inline, marking a record as read, or generating a resource) is one of the most common API design mistakes. It breaks caching, violates the HTTP contract, and creates non-obvious side effects. Mutations belong in POST, PUT, PATCH, or DELETE.

7. Rate Limiting

Rate limiting caps the number of requests a caller can make to an endpoint within a given time window, sometimes called an epoch. It prevents any single client from overwhelming the server and ensures fair access across all users of a shared API.

Each epoch defines a rolling or fixed period (commonly one minute, one hour, or one day) and a maximum call count that resets when the epoch expires. A typical rate limit might allow 100 requests per minute per API key. If a caller exhausts their quota before the epoch resets, the server returns a 429 Too Many Requests response, often with a Retry-After header indicating when the next epoch begins and requests can resume.

Rate limits are usually documented per-endpoint. A search endpoint that triggers heavy database queries may have a much stricter limit than a lightweight GET on a cached resource. Public API documentation (Stripe and Reddit are good examples) always lists rate limits clearly alongside each endpoint.

8. Request & Response Examples

The following examples use the notes API designed throughout this chapter. Each shows the HTTP verb, the full endpoint URL, and either the request body or the response shape.

Create a note

POST https://api.awesome-notes.com/v1.0/note
Content-Type: application/json

Request body:

json
{
  "userId": "usr_8f3kd92",
  "title": "Meeting notes Q3 planning",
  "content": "Discussed roadmap priorities for Q3...",
  "tags": ["work", "planning"],
  "sectionId": "sec_4j2lp01"
}

tags and sectionId are optional. A minimal valid request body is just userId, title, and content.

Response (201 Created):

json
{
  "noteId": "note_7x9qm44",
  "userId": "usr_8f3kd92",
  "title": "Meeting notes Q3 planning",
  "content": "Discussed roadmap priorities for Q3...",
  "tags": ["work", "planning"],
  "sectionId": "sec_4j2lp01",
  "createdAt": "2024-09-14T10:32:00Z",
  "updatedAt": "2024-09-14T10:32:00Z"
}

The server assigns noteId, createdAt, and updatedAt. The full note object is returned so the caller doesn't need a follow-up GET to discover the generated ID.

Response (400 Bad Request) (missing required field):

json
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Request body is missing required fields.",
    "details": [
      {
        "field": "title",
        "issue": "Field is required and cannot be empty."
      }
    ]
  }
}

A 400 is returned whenever the caller sends a malformed or incomplete request: wrong types, missing required fields, or values outside an allowed range. The error body should always identify which field failed and why, so the developer can fix it without guesswork. A bare status code with no body is not enough.

Get a single note

GET https://api.awesome-notes.com/v1.0/note/note_7x9qm44

No request body; the note ID is a path parameter. Returns 200 OK with the same note object shape shown above, or 404 Not Found if no note exists at that ID.

Get all notes for a user (paginated)

GET https://api.awesome-notes.com/v1.0/users/usr_8f3kd92/notes?limit=10&offset=0

limit and offset are optional query parameters. Defaults apply if omitted.

Response (200 OK):

json
{
  "notes": [
    {
      "noteId": "note_7x9qm44",
      "title": "Meeting notes Q3 planning",
      "updatedAt": "2024-09-14T10:32:00Z"
    }
  ],
  "total": 47,
  "limit": 10,
  "offset": 0
}

The response echoes limit and offset back to the caller and includes a total count, giving the client everything it needs to calculate how many pages remain and whether to request more.

πŸ“List Responses Don't Always Return Full Objects

The paginated list response above returns a summary shape for each note (just the ID, title, and timestamp) rather than the full note with its entire content body. This keeps list responses fast and small. Full content is fetched separately when the user actually opens a note.

9. Real-World API Documentation

The best way to develop an instinct for good API design is to read APIs that are widely considered exemplary. Two in particular are worth studying closely:

Stripe Stripe's API reference is the gold standard for developer documentation. Every endpoint lists its required and optional parameters with types and descriptions, shows exactly what the response object looks like, and includes working code examples in multiple languages. Error codes are documented with their own dedicated page, listing every possible code string and what it means. Versioning is surfaced prominently: the API version is sent as a header, not embedded in the URL, and the changelog is public.

Reddit Reddit's API documentation is a useful counterpoint: it covers a large, real-world surface area across many resource types (posts, comments, subreddits, users) and shows how pagination is handled in practice through before and after cursor parameters rather than numeric offsets. Rate limiting rules and OAuth scope requirements are listed per-endpoint.

Reading these alongside this chapter is useful not just for seeing correct usage of the concepts above, but for noticing the design decisions that make an API pleasant or painful to work with as a consumer.

Summary

ConceptKey Takeaway
API designConcerns only the surface area (parameters, responses, and the contract), not implementation
CRUDCreate, Read, Update, Delete: the four fundamental operations on any entity
EntitiesThe nouns of a system (Note, User) that API operations act upon
Backwards compatibilityOptional fields are safe to add; required fields on existing endpoints break all existing callers
VersioningBreaking changes belong on a new version (v2.0), not the existing endpoint
DeprecationA formal signal that a version will be removed; prompts callers to migrate
Paginationlimit and offset query parameters slice large datasets into manageable pages
IdempotencyThe same inputs always produce the same outcome. GET, PUT, and DELETE are idempotent; POST is not
Safe methodsGET has no side effects and must never modify data, as caching depends on this guarantee
Rate limitingCaps the maximum number of calls per epoch (time window) per caller; exceeding it returns 429 Too Many Requests