REST API Design
Resource-Based Design
Read aloud is unavailable in this browser.
Thinking in Resources
The transition from RPC-style thinking to resource-based thinking is the single most important conceptual shift when designing REST APIs. In RPC (Remote Procedure Call), you design around verbs—actions the system can perform: createUser(), updateOrderStatus(), sendNotification(). In REST, you design around nouns—the things that exist in your system: users, orders, notifications.
This shift seems subtle, but its implications are profound. Resource-based design produces APIs that are:
- Intuitive — Developers understand your API by understanding your domain
- Discoverable — Standard HTTP methods reveal what operations are possible
- Evolvable — Adding new resources doesn't break existing clients
- Cacheable — Resources have identity; responses can be cached by URI
Mastering resource design is the foundation of effective API architecture. Get this right, and everything else falls into place. Get it wrong, and no amount of documentation can save the developer experience.
By the end of this page, you will understand how to identify resources in any domain, design URIs that are intuitive and consistent, structure collections and individual resources, model relationships between resources, and avoid common anti-patterns that plague API design.
What Is a Resource?
In REST, a resource is any information that can be named. This is deliberately broad. A resource could be:
- A document — A user profile, a product description, an article
- A collection — All users, all orders from a customer, search results
- A physical thing — A device, a warehouse location, a vehicle
- An abstraction — A transaction, a session, a calculation result
- An action result — A report generation, an analysis output
- A temporal concept — Today's exchange rate, the current user's cart
The key insight is that resources are conceptual mappings, not implementation details. A resource might be backed by a database table, but it could also be a computed aggregation, a cached snapshot, or a virtual composite that never exists in storage.
Resources vs Representations:
A resource is abstract—it's the concept of "user 12345." The representation is the concrete data format returned when you access that resource: JSON, XML, HTML, or a binary protocol buffer. One resource can have multiple representations, selected via content negotiation.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
# Same resource, different representations # Request JSON representationGET /users/12345 HTTP/1.1Accept: application/json # ResponseHTTP/1.1 200 OKContent-Type: application/json { "id": "12345", "name": "Alice Chen", "email": "alice@example.com", "createdAt": "2024-01-15T09:30:00Z"} # Request HTML representation (for browser)GET /users/12345 HTTP/1.1Accept: text/html # ResponseHTTP/1.1 200 OKContent-Type: text/html <!DOCTYPE html><html><head><title>User: Alice Chen</title></head><body> <h1>Alice Chen</h1> <p>Email: alice@example.com</p> <p>Member since: January 15, 2024</p></body></html> # Request minimal representation (for internal services)GET /users/12345 HTTP/1.1Accept: application/vnd.company.user.summary+json # ResponseHTTP/1.1 200 OKContent-Type: application/vnd.company.user.summary+json {"id":"12345","name":"Alice Chen"}When designing an API, ask: 'What are the nouns in my domain?' Not 'What actions can users perform?' but 'What things exist that users interact with?' Those things are your resources. The actions will map naturally to HTTP methods operating on those resources.
Designing URIs
URIs are the addresses of your resources. Well-designed URIs are intuitive, predictable, and convey the structure of your domain. Poorly designed URIs confuse developers and couple clients to implementation details.
Core Principles of URI Design:
2.1 Use Nouns, Not Verbs
URIs should name resources, not actions. Actions are expressed through HTTP methods.
| ❌ Poor (verb-based) | ✅ Good (noun-based) |
|---|---|
/getUsers | GET /users |
/createOrder | POST /orders |
/deleteItem?id=123 | DELETE /items/123 |
/updateUserEmail | PATCH /users/123 |
2.2 Use Plural Nouns for Collections
Consistently use plural nouns for collections. This maintains uniformity and makes the relationship between collections and individual items clear.
/users — Collection of users
/users/123 — Individual user
/users/123/orders — Collection of orders for user 123
/users/123/orders/456 — Individual order
2.3 Hierarchy Reflects Relationships
Nested URIs express containment or association relationships. Limit nesting depth to maintain readability.
/customers/456/orders/789/items/12 — An item in an order (acceptable)
/customers/456/orders/789/items/12/reviews/34 — Too deep, hard to navigate
Rule of thumb: If a nested path exceeds 3 levels, consider whether the nested resource should be promoted to a top-level resource with filtering.
| Pattern | URI Example | When to Use |
|---|---|---|
| Collection | /users | Accessing or creating members of a group |
| Individual | /users/123 | Accessing a specific resource by ID |
| Nested Collection | /users/123/posts | Resources owned by or associated with a parent |
| Nested Individual | /users/123/posts/456 | Specific child resource |
| Top-level with filter | /posts?author=123 | When child resource is often accessed independently |
| Action-like resource | /orders/123/cancellation | When an action produces a resource (receipt of action) |
• Don't encode file extensions: /users/123.json — Use Accept headers instead
• Don't use verbs: /users/123/activate — Use PATCH with a state change or POST to /users/123/activations
• Don't mix singular and plural: /user/123/order/456 — Be consistent
• Don't include redundant information: /api/v2/users/123/user-details — The ID already identifies the user
Collection Resources
Collections are resources that contain other resources. They require special design consideration for filtering, pagination, sorting, and efficient creation.
3.1 Collection Operations
| Method | URI | Meaning |
|---|---|---|
GET | /users | List users (with pagination/filtering) |
POST | /users | Create a new user |
DELETE | /users | Delete all users (use with extreme caution) |
PUT | /users | Replace entire collection (rarely used) |
3.2 Filtering
Allowing clients to filter collections is essential for performance and usability. Use query parameters for filtering:
GET /orders?status=shipped&customer=123&min_total=100
GET /users?role=admin&created_after=2024-01-01
GET /products?category=electronics&in_stock=true
Design decisions for filtering:
- Use meaningful parameter names (
status, nots) - Support multiple values where logical (
status=pending,processing) - Document operators for range queries (
_gte,_lte,_contains) - Provide simple defaults (most common filters pre-applied)
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
// GET /products?price_gte=10&price_lte=100&category=electronics&sort=-rating,price interface ProductQuery { // Range filters price_gte?: number; price_lte?: number; rating_gte?: number; // Exact match filters category?: string | string[]; brand?: string | string[]; in_stock?: boolean; // Search q?: string; // Full-text search name_contains?: string; // Pagination page?: number; per_page?: number; cursor?: string; // For cursor-based pagination // Sorting (prefix with - for descending) sort?: string; // e.g., "-rating,price" = rating DESC, price ASC} // Implementation approachfunction buildQuery(params: ProductQuery) { let query = Product.query(); // Range filters if (params.price_gte) query = query.where('price', '>=', params.price_gte); if (params.price_lte) query = query.where('price', '<=', params.price_lte); // Multi-value exact match if (params.category) { const categories = Array.isArray(params.category) ? params.category : params.category.split(','); query = query.whereIn('category', categories); } // Boolean filters if (params.in_stock !== undefined) { query = params.in_stock ? query.where('inventory', '>', 0) : query.where('inventory', '=', 0); } // Sorting if (params.sort) { params.sort.split(',').forEach(field => { const direction = field.startsWith('-') ? 'desc' : 'asc'; const column = field.replace(/^-/, ''); query = query.orderBy(column, direction); }); } return query;}3.3 Pagination
Collections can contain millions of items. Pagination is mandatory for any non-trivial collection. Two primary approaches exist:
Offset-based Pagination:
GET /users?page=3&per_page=25
GET /users?offset=50&limit=25
Pros: Simple, allows jumping to any page Cons: Inconsistent with real-time data (items shift between pages), expensive for large offsets (requires counting)
Cursor-based Pagination:
GET /users?limit=25&cursor=eyJpZCI6MTI1MH0=
Pros: Consistent results, efficient for any position, immune to insertions/deletions Cons: Can't jump to arbitrary pages, cursor management complexity
123456789101112131415161718192021
{ "data": [ {"id": "usr-051", "name": "Alice Chen", "email": "alice@example.com"}, {"id": "usr-052", "name": "Bob Davis", "email": "bob@example.com"}, {"id": "usr-053", "name": "Carol Evans", "email": "carol@example.com"} ], "pagination": { "total": 1250, "per_page": 25, "current_page": 3, "total_pages": 50, "has_more": true }, "_links": { "self": {"href": "/users?page=3&per_page=25"}, "first": {"href": "/users?page=1&per_page=25"}, "prev": {"href": "/users?page=2&per_page=25"}, "next": {"href": "/users?page=4&per_page=25"}, "last": {"href": "/users?page=50&per_page=25"} }}Individual Resources
Individual resource endpoints represent single, uniquely identifiable items. They are the workhorses of REST APIs.
4.1 Resource Identification
Every resource needs a unique identifier. Common approaches:
| ID Type | Example | Pros | Cons |
|---|---|---|---|
| Sequential Integer | /users/12345 | Simple, compact | Predictable (security), migration issues |
| UUID | /users/a1b2c3d4-... | Globally unique, unpredictable | Long, less URL-friendly |
| Short ID | /users/usr_5kJ8mN | Readable, unpredictable | Custom generation logic |
| Slug | /articles/rest-api-design | SEO-friendly, memorable | Uniqueness management, renames break links |
| Composite | /orgs/acme/projects/alpha | Context-aware | Longer paths |
Best Practice: Use URL-safe, reasonably short IDs that don't expose internal sequencing. UUIDs or prefixed short IDs (e.g., usr_, ord_) work well.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
// Individual resource endpoints pattern // GET /users/:id - Retrieve a specific userapp.get('/users/:id', async (req, res) => { const user = await userRepository.findById(req.params.id); if (!user) { return res.status(404).json({ error: 'NOT_FOUND', message: `User with ID '${req.params.id}' not found`, details: { resource: 'User', identifier: req.params.id } }); } // Full representation with HATEOAS links res.json({ id: user.id, name: user.name, email: user.email, role: user.role, createdAt: user.createdAt, updatedAt: user.updatedAt, _links: { self: { href: `/users/${user.id}` }, orders: { href: `/users/${user.id}/orders` }, avatar: { href: `/users/${user.id}/avatar` }, update: { href: `/users/${user.id}`, method: 'PUT' }, delete: { href: `/users/${user.id}`, method: 'DELETE' } } });}); // PUT /users/:id - Replace entire user resourceapp.put('/users/:id', validateBody(userSchema), async (req, res) => { const user = await userRepository.findById(req.params.id); if (!user) { // PUT can create at a known ID (optional behavior) const newUser = await userRepository.create({ id: req.params.id, ...req.body }); return res.status(201).json(newUser); } // Full replacement - all fields must be provided const updatedUser = await userRepository.replace(req.params.id, req.body); res.json(updatedUser);}); // PATCH /users/:id - Partial updateapp.patch('/users/:id', validateBody(userPatchSchema), async (req, res) => { const user = await userRepository.findById(req.params.id); if (!user) { return res.status(404).json({ error: 'NOT_FOUND' }); } // Merge partial update const updatedUser = await userRepository.update(req.params.id, req.body); res.json(updatedUser);}); // DELETE /users/:id - Remove the resourceapp.delete('/users/:id', async (req, res) => { const user = await userRepository.findById(req.params.id); if (!user) { // Idempotency: DELETE on non-existent resource is 204, not 404 return res.status(204).send(); } await userRepository.delete(req.params.id); res.status(204).send();});PUT replaces the entire resource — you must send the complete representation. PATCH applies partial modifications — you send only what changes. For most APIs, PATCH is more practical for updates, but PUT provides stronger semantics (the resource is exactly what was sent).
Resource Relationships
Real-world domains contain related resources. Customers have orders; orders have items; items reference products. Modeling these relationships correctly is crucial for API usability.
5.1 Types of Relationships
One-to-Many (Parent-Child):
/customers/123/orders — Orders belonging to customer 123
/orders/456/items — Items in order 456
Many-to-Many (Associations):
/users/123/roles — Roles assigned to user (list role IDs or objects)
/roles/admin/users — Users with admin role
One-to-One (Component):
/users/123/profile — The single profile owned by user
/orders/456/invoice — The invoice generated for an order
- •Use when child identity is tied to parent
- •Use when child rarely accessed independently
- •Use when parent controls child lifecycle
- •Example: Order items within an order
- •Use when resource has independent identity
- •Use when accessed across multiple parents
- •Use when needing complex cross-parent queries
- •Example: Comments across all posts
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
// Pattern 1: Embedding related resources// GET /orders/456?expand=items,customer{ "id": "ord-456", "status": "shipped", "total": 299.99, "customer": { "id": "cust-123", "name": "Alice Chen", "email": "alice@example.com" }, "items": [ { "id": "item-1", "product": {"id": "prod-789", "name": "Widget Pro"}, "quantity": 2, "price": 149.995 } ]} // Pattern 2: Reference IDs with links (preferred for large relations)// GET /orders/456{ "id": "ord-456", "status": "shipped", "total": 299.99, "customerId": "cust-123", "_links": { "self": {"href": "/orders/456"}, "customer": {"href": "/customers/cust-123"}, "items": {"href": "/orders/456/items"}, "shipment": {"href": "/shipments/ship-001"} }} // Pattern 3: Collection link relationship// GET /customers/123{ "id": "cust-123", "name": "Alice Chen", "stats": { "orderCount": 15, "totalSpent": 4500.00 }, "_links": { "self": {"href": "/customers/cust-123"}, "orders": {"href": "/customers/cust-123/orders"}, "recent_orders": {"href": "/orders?customer=cust-123&limit=5&sort=-created"} }}5.2 The Expansion Pattern
Clients often need related data without making multiple requests. The expansion pattern allows clients to request inline embedding of related resources:
GET /orders/456?expand=customer,items
GET /users/123?include=profile,recent_orders
GET /posts/789?fields=title,author&expand=author.profile
Implementation considerations:
- Limit expansion depth to prevent N+1 query explosions
- Whitelist expandable relationships (not all relationships should be expandable)
- Consider GraphQL if expansion needs become complex
- Monitor performance; expansion can make responses enormous
Modeling Non-CRUD Operations
Not everything maps neatly to Create, Read, Update, Delete. Real systems have actions like "approve," "cancel," "archive," "send," "calculate." How do we model these in a resource-oriented way?
Strategy 1: State as a Resource Property
If an action changes a resource's state, model it as a PATCH on that property:
PATCH /orders/456
Content-Type: application/json
{"status": "cancelled"}
When to use: Simple state transitions with no side effects to report.
Strategy 2: Action as a Sub-Resource
If an action is significant or produces a result, model the action as a resource:
POST /orders/456/cancellations
Content-Type: application/json
{"reason": "Customer changed mind", "refundRequested": true}
Response:
{
"id": "cancel-789",
"orderId": "ord-456",
"status": "processing",
"reason": "Customer changed mind",
"refundStatus": "pending",
"createdAt": "2025-01-07T15:30:00Z"
}
When to use: When the action has identity, history matters, or it produces a result worth tracking.
Strategy 3: Controller Resources
For operations that don't fit the resource model, accept a "controller" resource at a semantic URI:
POST /payment-processor/authorize
POST /email-service/send
POST /reports/monthly-sales/generate
When to use: Integrations with external systems, batch operations, or genuinely procedural actions.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
// Modeling actions as resources provides audit trails and async support // POST /orders/:orderId/cancellationsapp.post('/orders/:orderId/cancellations', async (req, res) => { const order = await orderRepository.findById(req.params.orderId); if (!order) { return res.status(404).json({ error: 'Order not found' }); } if (!order.canBeCancelled()) { return res.status(422).json({ error: 'UNPROCESSABLE_ENTITY', message: `Order in status '${order.status}' cannot be cancelled`, allowed_statuses: ['pending', 'confirmed'] }); } // Create cancellation record (the action becomes a resource) const cancellation = await cancellationRepository.create({ orderId: order.id, reason: req.body.reason, refundRequested: req.body.refundRequested ?? false, initiatedBy: req.user.id, status: 'processing' }); // Trigger async processing await eventBus.publish('order.cancellation.requested', { cancellationId: cancellation.id, orderId: order.id }); // Return the cancellation resource, not the order res.status(202).json({ id: cancellation.id, status: cancellation.status, orderId: order.id, reason: cancellation.reason, createdAt: cancellation.createdAt, _links: { self: { href: `/orders/${order.id}/cancellations/${cancellation.id}` }, order: { href: `/orders/${order.id}` }, status: { href: `/orders/${order.id}/cancellations/${cancellation.id}/status`, poll_interval: 2000 } } });}); // GET /orders/:orderId/cancellations - List cancellation attemptsapp.get('/orders/:orderId/cancellations', async (req, res) => { const cancellations = await cancellationRepository.findByOrder(req.params.orderId); res.json({ data: cancellations, _links: { order: { href: `/orders/${req.params.orderId}` } } });});Treating actions as resources provides: (1) Audit trails—every action has a record. (2) Async support—clients can poll the action status. (3) Idempotency—retrying creates the same cancellation, not multiple. (4) Extensibility—metadata can be added to actions without changing the parent resource.
Resource Design Anti-Patterns
Even experienced developers fall into these traps. Recognizing anti-patterns helps you avoid them—and refactor existing APIs toward better designs.
- •Tunnel all operations through POST —
/api/executewith action in body. Loses HTTP semantics, breaks caching, reduces discoverability. - •One endpoint per use case —
/getUserOrderSummary,/getAdminDashboard. Creates explosion of endpoints, no reusability. - •Database tables as resources — Exposes internal schema, couples tightly to implementation, leaks relational model quirks.
- •Verbs in URIs —
/users/123/activate,/orders/456/sendEmail. Should be state changes or sub-resources. - •Overloaded GET with body — Sending request body in GET for complex queries. Violates HTTP spec, caching breaks.
- •Inconsistent pluralization —
/user/123/ordersvs/users/123/order. Reduces predictability. - •Deep nesting without purpose —
/orgs/1/teams/2/members/3/roles/4/permissions. Flatten when relationships aren't strictly hierarchical.
12345678910111213141516171819
# ❌ ANTI-PATTERN: RPC disguised as REST POST /api/execute HTTP/1.1Content-Type: application/json { "action": "getUserOrders", "params": { "userId": 123, "status": "pending", "limit": 10 }} # Problems:# - Not cacheable (POST)# - Not discoverable (one URL)# - No HTTP method semantics# - Can't use browser directly1234567891011
# ✅ CORRECT: Resource-oriented REST GET /users/123/orders?status=pending&limit=10 HTTP/1.1Accept: application/json # Benefits:# - Cacheable (GET)# - Self-documenting URL# - Standard HTTP semantics# - Browser navigable# - Proxy/CDN compatibleKEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.