Loading learning content...
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:
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.
In REST, a resource is any information that can be named. This is deliberately broad. A resource could be:
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.
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:
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 |
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
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
Collections are resources that contain other resources. They require special design consideration for filtering, pagination, sorting, and efficient creation.
| 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) |
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:
status, not s)status=pending,processing)_gte, _lte, _contains)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;}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 resource endpoints represent single, uniquely identifiable items. They are the workhorses of REST APIs.
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).
Real-world domains contain related resources. Customers have orders; orders have items; items reference products. Modeling these relationships correctly is crucial for API usability.
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
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"} }}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:
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?
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.
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.
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.
Even experienced developers fall into these traps. Recognizing anti-patterns helps you avoid them—and refactor existing APIs toward better designs.
/api/execute with action in body. Loses HTTP semantics, breaks caching, reduces discoverability./getUserOrderSummary, /getAdminDashboard. Creates explosion of endpoints, no reusability./users/123/activate, /orders/456/sendEmail. Should be state changes or sub-resources./user/123/orders vs /users/123/order. Reduces predictability./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 compatibleLet's apply these principles to design a resource model for an e-commerce platform. This demonstrates how abstract concepts translate into concrete API structure.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
# E-Commerce API Resource Model # Top-Level Resources (independent identity)/products GET - List products (filterable by category, price, availability) POST - Create product (admin only) /products/{productId} GET - Get product details PUT - Replace product PATCH - Update product fields DELETE - Remove product /customers GET - List customers POST - Register new customer /customers/{customerId} GET - Customer profile PUT - Update customer DELETE - Delete customer (GDPR compliance) /orders GET - List all orders (admin) or own orders (customer) POST - Create new order /orders/{orderId} GET - Order details PATCH - Update order (limited fields) # Nested Resources (identity tied to parent)/products/{productId}/reviews GET - Reviews for a product POST - Add review /products/{productId}/reviews/{reviewId} GET - Single review PUT - Update review (author only) DELETE - Remove review /orders/{orderId}/items GET - Items in order POST - Add item (if order is editable) DELETE - Remove all items /orders/{orderId}/items/{itemId} GET - Single item PATCH - Update quantity DELETE - Remove item # Action Resources (operations with identity)/orders/{orderId}/cancellations GET - Cancellation history POST - Request cancellation /orders/{orderId}/shipments GET - Shipment tracking POST - Create shipment (fulfillment) /orders/{orderId}/refunds GET - Refund history POST - Request refund # Singleton Resources (one per parent)/customers/{customerId}/cart GET - Current cart PUT - Replace cart DELETE - Clear cart /customers/{customerId}/preferences GET - User preferences PUT - Update preferences # Controller Resources (procedural operations)/checkout POST - Process checkout (cart -> order) /search GET - Search across resources (products, customers, orders)• Products at top level (accessed independently, browsed by anyone) • Reviews nested under products (semantically owned by product) • Orders at top level (accessed by order ID from emails, tracking) • Order items nested (no meaning outside order context) • Cart as singleton (one per customer) • Cancellations as action resources (audit trail, async processing)
Resource-based design is the heart of REST API architecture. Let's consolidate the key principles:
What's Next:
Now that we understand how to model resources, we'll explore how HTTP methods encode operations on those resources. The next page covers HTTP Method Semantics—the meaning, safety, and idempotency of GET, POST, PUT, PATCH, DELETE, and beyond.
You now understand how to design APIs around resources rather than RPC-style operations. You can model collections, individual resources, relationships, and even procedural actions in a resource-oriented way. Next, we'll apply the correct HTTP methods to these resources.