Engineering Atlas › System Design Fundamentals

What an API is and what it promises

16 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

A contract, not a URL

People often say "API" when they mean "an HTTP endpoint that returns JSON". That is one kind. The general idea is older and broader: an API is the agreed boundary between code that provides a capability and code that uses it. sort(list) in a standard library, write(fd, buf, n) in the Linux kernel, and POST /v1/payments at a payment provider are all APIs. Each has inputs, outputs, error behaviour and promises about side effects.

For the caller the boundary is a promise; for the provider it is a wall behind which everything may change without asking anyone. A good API lets the provider change a lot behind the wall without breaking anyone in front of it.

The parts of a contract

A complete contract says more than the request and response shapes. It also covers semantics (side effects, ordering, whether repeating a call is safe), errors (how failure is reported and which failures are worth retrying), limits (rate, page size, payload size, latency), identity and permissions, and lifecycle (how long it will be honoured and how retirement is announced). Leaving one out does not remove it from the contract. Callers will infer it from behaviour, and you will discover what they inferred when you change it.

Local, system and remote APIs

A local API is a function or class in the same process. A call costs nanoseconds, failures are exceptions, and the compiler checks the types. A system API crosses a privilege boundary, such as a system call into the kernel: it costs hundreds of nanoseconds and its rules are fixed by a binary convention, not by a language. A remote API crosses a network: it costs milliseconds, it can fail halfway, and nothing checks types unless you add a schema.

That last jump changes design. A local API can be chatty because calls are free. A remote API must be coarse, returning what a screen needs in one round trip, because each call can time out with an unknown outcome. This is the partial-failure problem shape, and it looks the same when an AI agent's tool call that sends an email times out: did it send?

The audience matters as much as the distance. A private API serves one team, so caller and provider change together in one pull request. A public API serves callers you will never meet, so every change must be backward compatible or versioned. The audience sets how expensive change is, and therefore how much care the first design deserves.

Hyrum's law

Hyrum Wright at Google observed that with enough users, every observable behaviour of an API will be depended on by somebody, whatever the documentation says (hyrumslaw.com). Someone relies on the order your list happens to come back in, or parses your error text. So make undocumented behaviour unobservable where you can, document what you do promise, and assume any visible change breaks somebody. Go 1 did the first of these on purpose: it defined map iteration order as unpredictable, even across two runs of the same loop, so no program could come to rely on it (Go 1 release notes).

Down the stack

Layer 1The contract

State the contract precisely enough to test: input schemas with required fields, output schemas with which fields are always present, every error code and whether it is retryable, whether each operation is safe to repeat, read-after-write consistency, limits, and the compatibility policy. The usual policy for a stable API is that callers written against version N keep working against every later release of version N. The provider may add optional request fields, response fields, operations and error detail. It may not remove or rename fields, change types, make an optional input required, or stop returning a field promised to be present (Google AIP-180).

The policy also needs a time bound: how long an old version is honoured once a new one exists. Large public APIs now state it as a number. GitHub's REST API names versions by release date in an X-GitHub-Api-Version header, falls back to 2022-11-28 when the header is missing, keeps each superseded version for at least 24 months, and answers 410 Gone after that (GitHub docs).

Layer 2The mechanism

Compatibility is enforced by how the contract is encoded. In Protocol Buffers each field has a number, and on the wire a field is tagged with (field_number << 3) | wire_type. Names never appear on the wire, so renaming a field is safe and reusing a number is catastrophic; the reserved keyword exists to stop a deleted number being reused (proto3 guide). Parsers skip fields with numbers they do not know. Early proto3 dropped them; since release 3.5 in November 2017 most implementations keep them as unknown fields by default, so a middle service passes them through (protobuf changelog). Newer .proto files declare an edition (edition = "2023" or "2024") instead of syntax = "proto3"; editions merge the proto2 and proto3 behaviours into per-field feature settings, and the wire rules above do not change (Protobuf editions). JSON APIs reach a similar place by convention: clients ignore unknown fields (a tolerant reader), and an OpenAPI schema lets a CI job diff two versions and flag breaking changes before release. When a breaking change is unavoidable, a translation layer can hide it. Stripe pins each account to the version current at its first request, writes every response in the newest shape, then runs a chain of version-change modules that rewrite it backwards to the caller's version; by 2017 that had absorbed almost a hundred incompatible changes since 2011 without breaking an integration (Stripe). Since 2024-09-30.acacia Stripe ships a version every month with no breaking changes and batches breaking ones into two major releases a year (Stripe docs). For libraries, the mechanism is the symbol table: glibc uses symbol versioning, so a program linked against memcpy@GLIBC_2.2.5 keeps calling the old behaviour after memcpy@@GLIBC_2.14 was introduced. For retirement, HTTP has the Deprecation header (RFC 9745, March 2025), a structured date such as Deprecation: @1688169599, and the older Sunset header (RFC 8594), an HTTP date that must not be earlier than the deprecation date (RFC 9745). Together they let clients and monitoring see both dates in every response.

Layer 3The machine

The Linux system call interface is the clearest example of a contract at machine level. On x86-64 a program puts the call number in register rax (for example 1 for write, 0 for read), the arguments in rdi, rsi, rdx, r10, r8 and r9, and executes the syscall instruction (syscall(2)). The kernel returns a result in rax, with values from -4095 to -1 meaning an error number. Those numbers have not changed in decades, because a binary compiled in 2008 must still run. The kernel's first rule, set by Linus Torvalds, is "we don't cause regressions": userland APIs may change only if no existing application breaks. The rule covers only what the kernel offers to user space; the in-kernel module API changes freely (kernel docs). That split is the public versus private distinction again. A system call costs roughly 100 ns to 1 µs depending on CPU and mitigations, against about 1 ns for a local function call. A remote call adds more orders of magnitude: about 0.2 to 1 ms inside one data centre, and 60 to 150 ms across continents, plus the bytes. A small JSON request with headers is often 500 bytes to 2 KB; the same message in Protobuf may be a third of that, because field names are replaced by one-byte tags for field numbers 1 to 15.

Why it's built this way

  1. Teams and programs evolve at different speeds, so they need a boundary that lets one side change without the other.
  2. Callers you do not control cannot be upgraded on your schedule, so the boundary must be stable and change only by addition.
  3. Addition only works if old readers ignore what they do not understand, so encodings tag fields by number or name and parsers skip unknown ones.
  4. Every boundary crossing has a cost (a mode switch, a network round trip), so the further away the provider is, the coarser and more failure-aware the API must be.
  5. Removal is still sometimes necessary, so it is done slowly and visibly: deprecation headers, usage metrics per caller, and a sunset date.

Diagram

flowchart LR
  App["Your code"] -->|"function call, ~1 ns"| Lib["Library API"]
  Lib -->|"syscall, ~0.1-1 us"| Kernel["Kernel ABI"]
  Kernel --> NIC["Network card"]
  NIC -->|"HTTP request, ~1-100 ms"| Remote["Remote API"]
  Remote --> Impl["Provider implementation"]
  Impl --> DB["Provider database"]

Worked example

A food delivery company has a public GET /v1/restaurants/{id} endpoint used by its own mobile apps and by 40 partner integrations. The response has "price_level": 2 as an integer. The team wants to change it to a string, "$$", to match a new design.

First, count the callers. App store data shows 18% of active users run an app version older than six months, and those versions parse price_level as an integer, so a type change would crash the restaurant screen for roughly one user in five. The partners deploy on their own schedules, some quarterly.

The compatible path is to add, not change: ship "price_display": "$$" alongside price_level. The team cannot see which fields a client reads, so it assumes every field is used. If price_level must eventually go, they send Deprecation and Sunset headers for six months, email the partners, and wait until traffic from old clients falls below an agreed threshold, say 0.5%, before removing it in /v2.

Keeping the extra field costs about 20 bytes per response: at 3,000 requests per second that is 60 KB/s, about 5 GB a day before compression. Breaking 18% of mobile users costs an incident. That asymmetry is why stable APIs grow by addition.

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
Change in place, no versioningSimplest, one code pathBreaks unknown callersPrivate APIs where you can update every caller in one change
Additive evolution onlyNo client migration, one version to runFields accumulate, naming gets messyMost APIs, most of the time
Version in the path (/v2)Clear, easy to route and measureTwo versions to run, big-bang migrationsRare, deliberate breaking redesigns
Dated versions per caller (Stripe style)Small breaking changes without forking the APINeeds a translation layer per versionLarge public APIs with many slow-moving integrators
Release trains (Stripe since 2024)Monthly additive releases, breaking changes batched twice a yearIntegrators still face periodic migrationsFast-moving APIs that want a predictable upgrade calendar
Schema-first with generated clientsTypes checked at build time, breaking changes caught in CITooling and code generation to maintainInternal RPC and partner SDKs

Pitfalls in production

Leadership lens

Check yourself

  1. You add a new optional field to a JSON response and a partner's integration breaks. What went wrong, and whose fix is it?

    Show answer

    The partner's client parses strictly and rejects unknown fields. Adding response fields is compatible under the usual contract, so the client should be tolerant, but you should have documented that rule explicitly and can roll back while they fix it.

  2. Why is renaming a field safe in Protobuf but breaking in JSON?

    Show answer

    Protobuf puts field numbers, not names, on the wire, so a rename changes only generated code. JSON carries field names in every message, so old readers no longer find the field.

  3. Why must a remote API be coarser than a local one?

    Show answer

    Each remote call costs a network round trip and can fail with an unknown outcome, so many small calls multiply latency and failure points. Returning what a use case needs in one call keeps both low.

  4. What does Hyrum's law imply for a change that "only touches undocumented behaviour"?

    Show answer

    Some caller probably depends on it anyway, so it still needs the caution of a contract change: measure, announce, and roll out gradually.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →