Loading content...
We've established the foundational principles: APIs as contracts, usability, and consistency. But knowing principles isn't the same as applying them. When you're staring at a blank interface definition, where do you start? How do you systematically apply these principles to produce excellent APIs?
This page presents a practical, step-by-step API design process—a methodology that guides you from requirements through design, implementation, and iteration. This process isn't just theory; it reflects how experienced API designers work, making their intuitive approach explicit and repeatable.
Following a structured process doesn't replace design skill, but it ensures you consider all important aspects, avoid common oversights, and produce APIs that serve consumers well.
By the end of this page, you will understand: (1) A complete API design methodology from requirements to iteration; (2) How to gather and analyze requirements from an API perspective; (3) Techniques for designing APIs that prioritize consumer needs; (4) Review and validation strategies before implementation; and (5) How to evolve APIs based on feedback while maintaining quality.
API design isn't a single step—it's a cycle of activities that continues throughout the API's lifetime:
The Design Lifecycle Phases:
The Front-Loading Principle:
API design follows a principle similar to software architecture: fix problems early. The cost of changing an API increases dramatically once consumers depend on it:
Invest heavily in the early phases. Every hour spent on requirements and design can save ten hours of implementation and a hundred hours of migration later.
Resist the temptation to start coding immediately. Implementation details often leak into API design when you think about 'how' before fully understanding 'what.' Complete the design phase entirely before writing production code.
Effective API design begins with deeply understanding consumer needs. This isn't about asking "What endpoints do you want?"—it's about understanding the problems consumers are trying to solve.
Key Questions to Answer:
Use Case Documentation:
Capture requirements as concrete use cases—specific scenarios that the API must support. Each use case should include:
123456789101112131415161718192021222324252627282930313233343536373839404142
## Use Case: [Name] ### SummaryA brief description of what the consumer wants to accomplish. ### ActorWho is performing this action? (External developer, internal service, etc.) ### PreconditionsWhat must be true before this use case begins? ### Basic FlowStep-by-step description of the happy path:1. Consumer does X2. API responds with Y3. Consumer does Z4. ... ### Alternative FlowsWhat happens in alternative scenarios?- If resource doesn't exist: ...- If validation fails: ...- If consumer lacks permission: ... ### Data RequirementsWhat data does the consumer need to provide?What data does the consumer need to receive? ### Non-Functional Requirements- Expected latency: < 100ms- Expected throughput: 1000 requests/second- Availability: 99.9% ### PriorityHow critical is this use case? (P0, P1, P2) ### ExampleConcrete example with sample data:```Consumer sends: { "email": "user@example.com", "name": "Jane Doe" }API returns: { "id": "usr_123", "email": "user@example.com", ... }```Requirements Prioritization:
Not all use cases are equal. Prioritize ruthlessly:
| Priority | Description | API Design Implication |
|---|---|---|
| P0 - Critical | API is useless without this | Must be simple, optimized, thoroughly tested |
| P1 - Important | Significant value, common need | Should be convenient, well-documented |
| P2 - Nice to Have | Adds value for some consumers | Can be more complex, use advanced patterns |
| P3 - Edge Case | Rare need or special case | Can require workarounds, lower priority |
If possible, talk to actual API consumers—not just product managers or architects. Developers who will write integration code have insights about pain points, workflow, and expectations that others lack. Their feedback is invaluable.
With requirements understood, the next phase is modeling the API at an abstract level—identifying resources, operations, and relationships before committing to specific syntax.
Resource Identification:
Start by identifying the core resources (nouns) your API exposes. Resources are the primary abstractions consumers interact with:
1234567891011121314151617181920212223
// Example: E-commerce API Resource Identification // Primary Resources (core domain entities)// - User: represents a customer account// - Product: represents an item for sale// - Order: represents a purchase transaction// - Cart: represents items selected for potential purchase // Secondary Resources (supporting entities)// - Address: shipping/billing location// - Payment: payment method information// - Review: product evaluation by user// - Inventory: stock levels for products // Derived/Computed Resources (not stored, but queryable)// - Recommendation: suggested products for user// - OrderSummary: aggregated order statistics // Questions to ask for each resource:// 1. What uniquely identifies this resource?// 2. What attributes describe this resource?// 3. What are the valid states/lifecycle?// 4. Who can view/modify this resource?Operation Identification:
For each resource, identify the operations (verbs) consumers need to perform:
| Operation Type | Description | Common Semantics |
|---|---|---|
| Create | Bring new resource into existence | Validates input, generates ID, returns created resource |
| Read (Single) | Retrieve one resource by identifier | Returns resource if found, error if not |
| Read (List) | Retrieve collection of resources | Supports filtering, sorting, pagination |
| Update (Full) | Replace entire resource | Requires complete resource data, overwrites all fields |
| Update (Partial) | Modify specific fields | Only specified fields change, others preserved |
| Delete | Remove resource from system | May be soft delete, may cascade |
| Action | Trigger domain-specific operation | Business logic beyond CRUD (e.g., 'submit', 'approve') |
Relationship Mapping:
Identify how resources relate to each other:
12345678910111213141516171819202122232425262728293031
// Relationship types and their API implications // One-to-One: User -> Profile// API Design Options:// - Embedded: User contains profile inline// - Linked: User has profileId, separate Profile endpoint// - Sub-resource: GET /users/:id/profile // One-to-Many: User -> Orders// API Design Options:// - Linked: Order has userId, filter orders by user// - Sub-resource: GET /users/:id/orders// - Both: Support direct /orders?userId=X and /users/:id/orders // Many-to-Many: Product <-> Category// API Design Options:// - Join resources: ProductCategory linking table// - Embedded arrays: Product has categoryIds[]// - Sub-resources: GET /products/:id/categories // Hierarchical: Category -> SubCategory -> SubSubCategory// API Design Options:// - Flat with parent reference: Category has parentId// - Nested paths: GET /categories/:id/subcategories// - Tree operations: GET /categories/:id/ancestors, /descendants // Key questions for relationship design:// 1. How will consumers typically access related resources?// 2. Should related data be embedded or linked?// 3. What navigation paths make sense?// 4. How do permissions flow across relationships?Consumer Mental Model:
The most important aspect of API modeling is ensuring the API matches how consumers think about the domain—not how the system is implemented internally.
Could a domain expert (non-technical) understand your API resources and operations by name alone? If your API references 'users' and 'orders' rather than 'user_records' and 'txn_logs', you're on the right track. Use ubiquitous language from the domain.
With the abstract model defined, the next phase translates it into concrete interface definitions—specific method signatures, types, and contracts.
From Model to Interface:
Each resource and operation from the model becomes a concrete API element:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
// Abstract Model: Order resource with CRUD operations // Concrete Interface Definition:interface OrderApi { /** * Creates a new order from the provided order data. * * @param data - The order data including items and shipping info * @returns The created order with generated ID and status * * @throws ValidationError - If order data is invalid * @throws InsufficientInventoryError - If items are out of stock * @throws PaymentRequiredError - If payment method is missing * * Preconditions: * - data.items is non-empty * - data.shippingAddress is valid * - Current user is authenticated * * Postconditions: * - Order is persisted with status 'pending' * - Inventory is reserved for items * - OrderCreated event is emitted * * Idempotency: * - Supports idempotency key in options * - Duplicate key returns original order */ createOrder( data: CreateOrderData, options?: CreateOrderOptions ): Promise<Order>; /** * Retrieves an order by its unique identifier. * * @param orderId - The unique identifier of the order * @returns The order if found and accessible * * @throws NotFoundError - If order doesn't exist * @throws ForbiddenError - If current user can't access this order */ getOrder(orderId: string): Promise<Order>; // ... additional operations with same level of detail} // Supporting type definitionsinterface CreateOrderData { items: OrderItem[]; shippingAddress: Address; billingAddress?: Address; // Optional, defaults to shipping notes?: string;} interface CreateOrderOptions { idempotencyKey?: string; notifyCustomer?: boolean; // Default: true} interface Order { id: string; status: OrderStatus; items: OrderItem[]; shippingAddress: Address; billingAddress: Address; totals: OrderTotals; createdAt: Date; updatedAt: Date;}Design Decisions Checklist:
For each operation, explicitly decide:
Type Design:
Pay special attention to the types used in your interface. They're part of the contract:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
// Type design principles // 1. Make invalid states unrepresentabletype OrderStatus = | 'pending' // Awaiting payment | 'paid' // Payment received | 'processing' // Order being prepared | 'shipped' // Order in transit | 'delivered' // Order arrived | 'cancelled'; // Order cancelled // Not: status: string (allows any string) // 2. Use domain types, not primitivestype OrderId = string & { readonly __brand: 'OrderId' };type UserId = string & { readonly __brand: 'UserId' };type Money = { amount: number; currency: string }; // Not: orderId: string, userId: string, price: number // 3. Separate input and output types when they differinterface CreateUserInput { email: string; name: string; password: string; // Only on input} interface User { id: UserId; email: string; name: string; createdAt: Date; // Only on output // No password!} // 4. Use union types for polymorphic datatype PaymentMethod = | { type: 'card'; cardToken: string; last4: string } | { type: 'bank'; accountId: string; routingNumber: string } | { type: 'wallet'; walletId: string; provider: string }; // 5. Document constraints in types when possibleinterface PaginationOptions { limit?: number; // 1-100, default 20 offset?: number; // >= 0, default 0}// Better with branded types or validationFor REST APIs, consider using OpenAPI (Swagger) to formally specify your interface. For GraphQL, the schema is the specification. For RPC-style APIs, consider Protocol Buffers or similar IDL. Formal specifications enable code generation, validation, and automated documentation.
Before implementation, rigorously review the design. This is your last cheap opportunity to catch issues.
Design Review Process:
1. Self-Review Against Principles:
2. Use Case Walkthrough:
For each documented use case, trace through exactly how a consumer would accomplish it:
123456789101112131415161718192021222324252627282930313233343536373839
// Use Case: Customer places an order // Step through the actual consumer code: // 1. Consumer is authenticated (precondition)const api = new ApiClient(authToken); // 2. Consumer adds items to cartconst cart = await api.getCart();await api.addToCart(cart.id, { productId: 'prod_123', quantity: 2 });await api.addToCart(cart.id, { productId: 'prod_456', quantity: 1 }); // Questions during walkthrough:// - Is getCart() the right name? Should it create cart if none exists?// - Should addToCart return the updated cart?// - What if productId is invalid? What error? // 3. Consumer provides shipping infoconst address = { /* ... */ };await api.setShippingAddress(cart.id, address); // Questions:// - Is this a cart operation or order operation?// - Should validation happen now or at checkout? // 4. Consumer submits orderconst order = await api.checkout(cart.id, { paymentMethodId: 'pm_abc123'}); // Questions:// - What does checkout return? Order? Payment status?// - What if payment fails? How is that communicated?// - Is cart cleared automatically? // 5. Consumer views order statusconst orderStatus = await api.getOrder(order.id); // Complete walkthrough reveals gaps and awkward patterns3. Peer Review:
Have other engineers review the design. They bring fresh perspectives and catch blind spots. Focus areas:
4. Consumer Validation:
If possible, validate with actual consumers before implementation:
Time pressure often leads teams to skip or rush design review. This is a false economy. Fixing design issues during review takes hours; fixing them after release takes weeks or months. Budget adequate time for review in your schedule.
With the design validated, implementation begins. The goal is to build exactly what was designed, without letting implementation concerns corrupt the API surface.
Implementation Principles:
1. The Specification is the Truth:
12345678910111213141516
// The design specifies: createOrder returns the created Order // ❌ Wrong: Implementation convenience changes the contractasync createOrder(data: CreateOrderData): Promise<{ orderId: string }> { // "It's simpler to just return the ID" // But now consumers need another call to get the order!} // ✅ Right: Implementation matches the specificationasync createOrder(data: CreateOrderData): Promise<Order> { const orderId = await this.repository.insert(data); return this.getOrder(orderId); // Extra query, but matches contract} // If the specification needs to change, go back to design phase,// don't change it during implementation2. Separate API Layer from Implementation:
The API layer should translate between external contracts and internal business logic:
123456789101112131415161718192021222324252627282930313233343536
// API Layer: Handles contract, validation, transformationclass OrderApiController { constructor(private orderService: OrderService) {} async createOrder(request: CreateOrderRequest): Promise<OrderResponse> { // 1. Validate input against contract this.validateCreateOrderRequest(request); // 2. Transform external format to internal const internalData = this.mapToInternal(request.data); // 3. Call business logic const order = await this.orderService.createOrder(internalData); // 4. Transform internal result to external format return this.mapToResponse(order); } private mapToInternal(data: CreateOrderData): InternalOrderData { // External API uses camelCase; internal uses different conventions // External uses string IDs; internal uses typed IDs // This layer handles the translation } private mapToResponse(order: InternalOrder): OrderResponse { // Internal may have fields not exposed via API // This layer filters and transforms }} // Business Layer: Pure domain logicclass OrderService { async createOrder(data: InternalOrderData): Promise<InternalOrder> { // Business logic, unaware of API contracts }}3. Contract Testing:
Write tests that verify the implementation honors the contract:
1234567891011121314151617181920212223242526272829303132333435
describe('OrderApi contract tests', () => { describe('createOrder', () => { it('returns created order with all specified fields', async () => { const data: CreateOrderData = { /* valid data */ }; const order = await api.createOrder(data); // Contract: Returns complete Order object expect(order.id).toBeDefined(); expect(order.status).toBe('pending'); expect(order.items).toEqual(data.items); expect(order.createdAt).toBeDefined(); }); it('throws ValidationError for empty items', async () => { const data: CreateOrderData = { items: [] }; // Contract: Throws ValidationError for invalid input await expect(api.createOrder(data)) .rejects.toThrow(ValidationError); }); it('is idempotent with idempotency key', async () => { const data: CreateOrderData = { /* valid data */ }; const options = { idempotencyKey: 'test-key-123' }; const order1 = await api.createOrder(data, options); const order2 = await api.createOrder(data, options); // Contract: Duplicate key returns original order expect(order1.id).toBe(order2.id); }); });});Contract tests serve double duty: they verify correctness and document expected behavior. When someone wonders 'what happens if X?', the contract test for that case provides the authoritative answer.
Documentation is not an afterthought—it's an integral part of the API product. Even a well-designed API fails if consumers can't learn to use it.
Documentation Layers:
| Layer | Purpose | Content |
|---|---|---|
| API Reference | Detailed spec of every operation | Parameters, types, errors, examples for each endpoint |
| Getting Started | First success experience | Quick start, authentication, first API call |
| Tutorials | Common task walkthroughs | Step-by-step guides for key use cases |
| Conceptual Guides | Deep understanding | How things work, design rationale, best practices |
| Changelog | Track evolution | Version history, breaking changes, migration guides |
Reference Documentation Essentials:
For each operation, reference documentation should include:
Example Quality:
Examples are often the first thing developers look at. Make them excellent:
1234567891011121314151617181920212223242526272829303132333435363738394041
// ❌ Poor example: Minimal, unrealisticconst order = await api.createOrder({ items: [{ productId: 'x', quantity: 1 }]}); // ✅ Good example: Complete, realistic, annotated// Create an order with multiple itemsconst order = await api.createOrder({ // Items to purchase (at least one required) items: [ { productId: 'prod_laptop_pro_15', quantity: 1, customization: { color: 'space-gray' } }, { productId: 'prod_usbc_cable_2m', quantity: 2 } ], // Shipping destination shippingAddress: { name: 'Jane Smith', line1: '123 Main Street', line2: 'Suite 456', city: 'San Francisco', state: 'CA', postalCode: '94105', country: 'US' }, // Optional: custom notes for fulfillment notes: 'Please include gift receipt'}, { // Enable idempotency for safe retries idempotencyKey: 'order_jane_2024_01_15_001'}); // Response includes the complete order with generated fieldsconsole.log(order.id); // 'ord_abc123def456'console.log(order.status); // 'pending'console.log(order.totals.total); // { amount: 1279.99, currency: 'USD' }Documentation should be generated from or validated against the actual implementation when possible. Tools like OpenAPI, Swagger, and API documentation generators help keep documentation in sync with code. Never let documentation drift from reality.
API design doesn't end at release. Gathering feedback and evolving the API is an ongoing process.
Feedback Channels:
Categorizing Feedback:
Not all feedback leads to immediate action. Categorize and prioritize:
| Category | Description | Action |
|---|---|---|
| Bugs | API doesn't match documented behavior | Fix immediately in current version |
| Usability issues | API works but is awkward to use | Consider for next minor version |
| Missing features | Consumers need capabilities not provided | Evaluate for roadmap |
| Breaking changes | Fundamental design needs revision | Plan for major version |
| Documentation gaps | Behavior is correct but not explained | Update documentation |
Versioning Strategy:
Plan for evolution from the start. A clear versioning strategy enables improvement while maintaining stability:
123456789101112131415161718192021222324252627282930313233343536
// Semantic versioning for APIs: MAJOR.MINOR.PATCH // PATCH (v1.0.1): Bug fixes only// - Behavior changes only to match documented contract// - No new features, no breaking changes// - Consumers update without code changes // MINOR (v1.1.0): Backward-compatible additions// - New operations, new optional parameters// - New fields in responses (additive only)// - Consumers update without code changes// - New features require updates to use // MAJOR (v2.0.0): Breaking changes// - Changed signatures, removed operations// - Changed response structures// - Consumers must update code// - Provide migration guide and overlap period // Change types and their version impact:// | Change | Version Impact |// |----------------------------------|----------------|// | Fix bug in implementation | PATCH |// | Add new endpoint | MINOR |// | Add optional request parameter | MINOR |// | Add new response field | MINOR |// | Change request parameter type | MAJOR |// | Remove response field | MAJOR |// | Change error code for scenario | MAJOR |// | Rename endpoint | MAJOR | // Deprecation process:// 1. Mark as deprecated in v1.x (add @deprecated annotation)// 2. Document migration path// 3. Continue supporting for defined period (e.g., 6 months)// 4. Remove in v2.0Each feedback cycle improves your understanding of consumer needs. Over time, you develop intuition for what makes APIs successful. This knowledge informs future designs, reducing iteration and increasing quality. The process gets easier with experience.
We've presented a complete, systematic API design process that transforms principles into practice. Following this process ensures you consider all important aspects and produce APIs that truly serve consumers.
Let's consolidate the key insights:
Module Complete:
You've now completed Module 1: What Is a Good API? You understand APIs as contracts, the usability principles that make APIs intuitive, the importance of consistency, and a systematic process for designing excellent APIs.
These foundations will serve you throughout the remainder of this chapter as we dive into specific aspects of API design: method signatures, error handling, versioning, REST principles, DTOs, and documentation.
Congratulations! You've completed the foundational module on API design principles. You now have a comprehensive framework for thinking about and creating excellent APIs—the contract mindset, usability principles, consistency requirements, and a systematic design process. Apply these principles to create APIs that developers love to use.