# API and Interface Design

Course: [Low-Level Design](https://www.devtutorials.ai/learn/system-design/lld) · Section: LLD Foundations
Source: https://www.devtutorials.ai/learn/system-design/lld/foundations/api-design

> Design APIs other code can rely on: clear contracts, one answer shape, input checks, stable error codes and safe versioning, checked by contract tests.

A power socket is a promise. Any plug with the correct shape will work, no matter where it is used. People do not need to open the wall and understand the wiring behind it.

An **API** (Application Programming Interface) works in a similar way. The QuickBite app, the restaurant tablet, and the rider app all communicate with QuickBite through its API. They depend on the API behaving the same way tomorrow as it does today.

**In simple words:** API design is about clearly defining what callers send, what they receive in return, and what happens when something goes wrong, and then consistently following that agreement.

## What You Will Learn

In this lesson, you will learn:

1. What an **API contract** is and why clear names and data types are important.
2. How to return a consistent response structure for both success and error cases.
3. How to validate input as soon as it enters the system.
4. How to use stable and meaningful error codes.
5. How to introduce a new API version without breaking existing applications.
6. How **contract tests** help ensure that an API continues to follow its defined contract.

This lesson uses simple TypeScript functions as API endpoints. The same principles also apply to web APIs (such as `POST /v1/coupons/apply`), library methods, and interfaces used between classes.

## The Contract

An **API contract** is the promise an API makes. It defines what input is accepted, what output is returned, and which errors can occur. A well-designed contract should be easy to understand just by looking at the names and types.

The figure compares a vague method with a clear one:

Here is the same idea in code:

```ts runnable title="A vague method versus a clear contract"
// Vague: what is "data"? What comes back? What happens on bad input?
function addItemVague(data: any): any {
  return data.cart.concat(data.item);
}

// Clear: names and types say exactly what goes in and what comes out.
type Item = { dish: string; qty: number; price: number };
type Cart = { items: Item[] };

function addItem(cart: Cart, item: Item): Cart {
  return { items: [...cart.items, item] };
}

const cart = addItem({ items: [] }, { dish: "Dosa", qty: 2, price: 120 });
console.log(cart.items.length, cart.items[0].dish);
```

Expected output:

```text
1 Dosa
```

`addItemVague` can accept any type of input and return any type of output. In contrast, `addItem` clearly defines what it expects and what it returns. Adding one Dosa to an empty cart gives a cart with 1 item. TypeScript can automatically verify that every caller follows the contract correctly.

## One Answer Shape

Callers should not have to guess what kind of response they received. Use a single **response envelope** for every result: either `ok: true` with the requested data, or `ok: false` with error information.

### The Example

`findRestaurant(id)` knows two restaurants: 1 is Saravana and 2 is Paradise. Step through three calls with the ids 1, 7 and -3:

Here is the same idea in code:

```ts runnable title="One answer shape: ok with data, or an error with a code"
type Result<T> =
  | { ok: true; data: T }
  | { ok: false; error: { code: string; message: string } };

function findRestaurant(id: number): Result<{ name: string }> {
  const all: Record<number, string> = { 1: "Saravana", 2: "Paradise" };
  if (!Number.isInteger(id) || id < 1) return { ok: false, error: { code: "INVALID_ID", message: "id must be a positive whole number" } };
  const name = all[id];
  if (!name) return { ok: false, error: { code: "NOT_FOUND", message: `no restaurant ${id}` } };
  return { ok: true, data: { name } };
}

for (const id of [1, 7, -3]) {
  const r = findRestaurant(id);
  console.log(r.ok ? `found ${r.data.name}` : `${r.error.code}: ${r.error.message}`);
}
```

Expected output:

```text
found Saravana
NOT_FOUND: no restaurant 7
INVALID_ID: id must be a positive whole number
```

### Why Not Just Throw?

Throwing exceptions is fine inside a single application. However, at an API boundary, a thrown exception may reach the caller as a crash or a generic "500 error". Returning a response envelope with an error code clearly tells the caller what went wrong and allows them to handle the error correctly.

## Check Input at the Door

Never assume incoming data is valid. A mobile app might be outdated, contain bugs, or be developed by another team. Validate the input first, before doing any actual work, and provide clear information about any problems.

A place-order request needs a `restaurantId` (a number) and at least one item, where each item has a `dish` and a `qty` of 1 or more. Step through two requests:

Here is the same idea in code:

```ts runnable title="Check input at the door"
type PlaceOrder = { restaurantId: number; items: { dish: string; qty: number }[] };

function validate(body: any): string[] {
  const problems: string[] = [];
  if (typeof body?.restaurantId !== "number") problems.push("restaurantId must be a number");
  if (!Array.isArray(body?.items) || body.items.length === 0) problems.push("items must have at least one item");
  else body.items.forEach((it: any, i: number) => {
    if (typeof it?.dish !== "string") problems.push(`items[${i}].dish is required`);
    if (!(Number.isInteger(it?.qty) && it.qty > 0)) problems.push(`items[${i}].qty must be 1 or more`);
  });
  return problems;
}

console.log(validate({ restaurantId: 1, items: [{ dish: "Idli", qty: 2 }] }));
console.log(validate({ items: [{ dish: "Idli", qty: 0 }] }));
```

Expected output:

```text
[]
[
  "restaurantId must be a number",
  "items[0].qty must be 1 or more"
]
```

The first request passes validation and returns an empty list, `[]`. The second request returns a clear list of problems: `restaurantId must be a number` and `items[0].qty must be 1 or more`. The caller can display each problem next to the correct form field.

Report all validation problems you find instead of returning a generic "bad request" response. A vague error forces the application developer to guess what went wrong, and guessing often leads to confusion and support requests.

## Stable Error Codes

An error should contain two parts:

- a **code**, such as `SOLD_OUT`, which is used by programs and should never change;
- a **message**, such as "Biryani is sold out", which is shown to people and can be updated, reworded, or translated.

Step through three errors reaching the app:

Here is the same idea in code:

```ts runnable title="Clients switch on the error code, never on the message"
type ApiError = { code: "SOLD_OUT" | "CLOSED" | "INVALID_INPUT"; message: string };

function screenFor(err: ApiError): string {
  // Messages can be reworded or translated. Codes never change.
  switch (err.code) {
    case "SOLD_OUT": return "Show similar dishes";
    case "CLOSED": return "Show opening hours";
    case "INVALID_INPUT": return "Highlight the form fields";
  }
}

console.log(screenFor({ code: "SOLD_OUT", message: "Biryani is sold out" }));
console.log(screenFor({ code: "SOLD_OUT", message: "बिरयानी खत्म हो गई" }));
console.log(screenFor({ code: "CLOSED", message: "Opens at 11:00" }));
```

Expected output:

```text
Show similar dishes
Show similar dishes
Show opening hours
```

The second message is written in Hindi, but the application still behaves correctly because it checks only the error code, not the message text.

## Versioning Without Breaking Anyone

Mobile apps on users' devices are not updated at the same time. Some users may continue using an older version of the app for several months. Because of this, a new API version should not break existing callers.

### The Safe Rule

- **Adding** a new field is usually safe because older apps simply ignore fields they do not recognize.
- **Removing** or **renaming** a field can break older applications that still expect it.
- **Changing** the meaning of a field can quietly break older applications and may be harder to detect.

### The Example

Version 1 answers `{ orderId: 881, total: 480 }`. Version 2 answers `{ orderId: 881, total: 480, etaMinutes: 25 }`. Step through an old app reading both:

Here is the same idea in code:

```ts runnable title="Versioning: add fields, never take them away"
type V1 = { orderId: number; total: number };
type V2 = V1 & { etaMinutes: number }; // v2 = everything in v1, plus one field

function orderResponse(version: 1 | 2): V1 | V2 {
  const base: V1 = { orderId: 881, total: 480 };
  return version === 2 ? { ...base, etaMinutes: 25 } : base;
}

// An old app written for v1 still works with a v2 answer:
function oldAppShows(r: V1): string {
  return `Order ${r.orderId}: Rs ${r.total}`;
}

console.log(oldAppShows(orderResponse(1)));
console.log(oldAppShows(orderResponse(2)));
console.log(JSON.stringify(orderResponse(2)));
```

Expected output:

```text
Order 881: Rs 480
Order 881: Rs 480
{"orderId":881,"total":480,"etaMinutes":25}

```

The older app shows `Order 881: Rs 480` for both responses because version 2 only added a new field, `etaMinutes`. The existing fields remained unchanged.

**Easy way to remember:** an API contract is a promise. Use the **same response shape** every time, use **error codes** for programs, use **messages** for people, and in new versions, **add instead of changing or removing**.

## Contract Tests

A **contract test** verifies an API from the outside. It checks what input is sent and what output is returned. It does not depend on how the API is implemented internally. The same contract tests can be used for different versions of the API or even for another implementation that follows the same contract.

Step through one contract test run against two versions of the coupon API:

Here is the same idea in code:

```ts runnable title="A contract test runs the same checks against any version"
type Api = (body: { code: string; orderTotal: number }) => { ok: boolean; data?: { total: number } };

// The contract, written once as checks on what goes in and what comes out.
function contract(name: string, api: Api): void {
  const r = api({ code: "TENOFF", orderTotal: 400 });
  const pass = r.ok === true && typeof r.data?.total === "number";
  console.log(`${name}: ${pass ? "keeps the contract" : "BREAKS the contract"}`);
}

const v1: Api = (b) => ({ ok: true, data: { total: b.orderTotal - 40 } });
// Someone "cleans up" v2 and renames total to amount:
const v2Broken = (b: { orderTotal: number }) => ({ ok: true, data: { amount: b.orderTotal - 40 } }) as any;

contract("v1", v1);
contract("v2", v2Broken);
```

Expected output:

```text
v1: keeps the contract
v2: BREAKS the contract
```

The test printed `v1: keeps the contract` and `v2: BREAKS the contract`. The renamed field may have looked correct during a quick code review, but the contract test detected the issue immediately because the API no longer followed the expected contract.

## Lab: The Coupon Endpoint

QuickBite's coupon handler currently throws errors and returns only a plain number. The client applications need it to follow the contract shown below:

| Situation | Answer |
|---|---|
| Valid coupon | `{ ok: true, data: { discount, total } }` |
| Bad input (missing code or total is not a positive number) | `{ ok: false, error: { code: "INVALID_INPUT", message } }` |
| Unknown coupon code | Error code `COUPON_UNKNOWN` |
| Expired coupon code | Error code `COUPON_EXPIRED` |
| `version: 2` | Same response as v1, plus `savedPercent` |

Keep the function name `applyCoupon(request)` unchanged. The hidden contract tests call this function with both valid and invalid requests for version 1 and version 2.

1. Never throw exceptions. Always return the response envelope.
2. Validate the input first, then validate the coupon. Use a different error code for each failure. Every error must include a message.
3. Add `savedPercent` only when `version: 2` is requested, and keep all existing v1 fields unchanged.

## Predict the Output

Here is a C# question about adding a field in a new version. Read the code, then type what it prints.

<CsPredict id="api-1" code={"var v1 = new { orderId = 881, total = 480 };\nvar v2 = new { orderId = 881, total = 480, eta = 25 };\nConsole.WriteLine(Show(v1.orderId, v1.total));\nConsole.WriteLine(Show(v2.orderId, v2.total));\n\nstatic string Show(int id, int total) => $\"Order {id}: {total}\";"} expected={"Order 881: 480\nOrder 881: 480"} why="The old Show method only reads orderId and total. v2 still has both, so it prints the same line. The new eta field is simply not used by old code. That is why adding a field is safe." />

## Common Mistakes

| Mistake | Wrong | Correct | Why it happens |
|---|---|---|---|
| Vague types | `function apply(data: any): any` | Use clearly named input and output types. | Using `any` is faster and easier at first. |
| Multiple response shapes | Sometimes returning a number and sometimes throwing an error. | Always return either `{ ok, data }` or `{ ok, error }`. | Different parts of the code were often written at different times. |
| Checking the message text | `if (err.message === "Sold out")` | `if (err.code === "SOLD_OUT")` | During testing, people usually see the message first. |
| Breaking changes in a new version | Renaming `total` to `amount` in v2. | Keep `total` and add any new fields alongside it. | The new name may seem clearer or more meaningful. |
| No retry protection | Sending the same request twice creates two orders. | Use an idempotency key to prevent duplicates. | Retries are uncommon during testing on a stable network. |

## Practice

## What to Remember

- An API is a **contract** that defines what goes in, what comes out, and what errors can occur.
- Use **one consistent response envelope** for both success and error responses.
- **Validate input first** and report all problems clearly.
- **Error codes** are for applications to process; **messages** are for people to read.
- New versions should only **add** functionality, not rename or remove existing fields.
- **Contract tests** verify that the API continues to keep its promise from the caller's point of view.

## Further Reading

These are the sources used to check this lesson:

- [Google Cloud: API design guide, errors](https://cloud.google.com/apis/design/errors): error codes and messages.
- [Microsoft: REST API guidelines](https://github.com/microsoft/api-guidelines/blob/vNext/azure/Guidelines.md): versioning and breaking changes.
- [Stripe: Idempotent requests](https://docs.stripe.com/api/idempotent_requests): safe retries with an idempotency key.
- [Martin Fowler: Contract test](https://martinfowler.com/bliki/ContractTest.html): testing a service from the outside.

Next: **Concurrency Basics**. You will see what goes wrong when two customers order at the same moment, and how to fix it.
