API and Interface Design
By the Syncfusion DevTutorials team · Last updated
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:
- What an API contract is and why clear names and data types are important.
- How to return a consistent response structure for both success and error cases.
- How to validate input as soon as it enters the system.
- How to use stable and meaningful error codes.
- How to introduce a new API version without breaking existing applications.
- 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:
addItemVague takes and returns any. addItem says exactly what goes in and what comes out.
Here is the same idea in code:
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:
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:
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:
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:
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:
[]
[
"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:
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:
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:
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:
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:
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:
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 |
Predict the Output
Here is a C# question about adding a field in a new version. Read the code, then type what it prints.
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: error codes and messages.
- Microsoft: REST API guidelines: versioning and breaking changes.
- Stripe: Idempotent requests: safe retries with an idempotency key.
- Martin Fowler: Contract test: 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.