Designing Your First Production CRUD API
A complete step-by-step walkthrough of designing clean URI hierarchies, validation contracts, status codes, and consistent response envelopes.
Resource-Oriented Modeling Principles
In REST architecture, everything centers on Resources. A resource is an entity with an identity, state, and relationships to other resources.
When designing endpoints for your application, following three core rules will keep your API predictable and intuitive for any developer or client:
Rule 1: Use Nouns, Never Verbs in URIs
The HTTP method already communicates the action to be performed (GET means read, POST means create, DELETE means remove). Repeating verbs in the URL path is redundant and breaks REST conventions:
❌ AVOID (RPC Style):
POST /v1/createUser
GET /v1/getUsers
POST /v1/deleteUser?id=123
POST /v1/updateUserStatus
✅ PREFERRED (REST Style):
POST /v1/users # Create a new user
GET /v1/users # List users
GET /v1/users/123 # Retrieve user 123
PATCH /v1/users/123 # Partially update user 123
DELETE /v1/users/123 # Delete user 123
Rule 2: Always Pluralize Resource Names
Consistency is paramount. Always use plural nouns for collections so that URL patterns remain uniform throughout your application:
/v1/projects(represents the collection of projects)/v1/projects/:id(represents a specific project)/v1/projects/:id/tasks(represents tasks belonging to that project)
Mixing singular and plural (e.g., /v1/user and /v1/projects) causes client developers to constantly guess whether an endpoint uses singular or plural naming.
Rule 3: Hierarchical Nesting for Parent-Child Relationships
When a sub-resource cannot exist independently of its parent, represent that relationship hierarchically:
# List members of an organization
GET /v1/organizations/:orgId/members
# Invite a member to an organization
POST /v1/organizations/:orgId/members
# Remove a member from an organization
DELETE /v1/organizations/:orgId/members/:memberId
Architectural Tip: Limit URI nesting to at most 2 levels deep. Paths like
/v1/orgs/1/teams/2/projects/3/tasks/4are fragile and unwieldy. Once an entity possesses its own unique identifier, flatten the path to/v1/tasks/4.
Complete CRUD Walkthrough: Building a Task Service
Let’s design a production-grade Task API from start to finish, examining the exact headers, payloads, and HTTP status codes for each operation.
1. Listing Tasks (GET)
Retrieves a paginated list of task resources. Always return an array wrapped in a top-level data envelope alongside pagination metadata:
- Endpoint:
GET /v1/tasks - Query Parameters:
?status=pending&limit=10 - Response Code:
200 OK
{
"data": [
{
"id": "tsk_019a8b",
"title": "Configure SQLite database on Cloudflare",
"status": "pending",
"priority": "high",
"created_at": "2026-10-04T12:00:00Z"
},
{
"id": "tsk_019a8c",
"title": "Implement JWT refresh token rotation",
"status": "pending",
"priority": "medium",
"created_at": "2026-10-04T12:05:00Z"
}
],
"pagination": {
"has_more": false,
"limit": 10,
"next_cursor": null
}
}
2. Creating a Task (POST)
Creates a new task. The server generates a unique identifier and returns the newly created representation along with a Location header pointing to the new resource:
- Endpoint:
POST /v1/tasks - Response Code:
201 Created - Response Headers:
Location: /v1/tasks/tsk_019a8d
Request Body:
{
"title": "Add analytics tracking script to Layout",
"priority": "high"
}
Response Body:
{
"id": "tsk_019a8d",
"title": "Add analytics tracking script to Layout",
"status": "pending",
"priority": "high",
"created_at": "2026-10-04T12:30:00Z",
"updated_at": "2026-10-04T12:30:00Z"
}
3. Retrieving a Single Task (GET)
Fetches the current representation of a specific task by its unique identifier:
- Endpoint:
GET /v1/tasks/tsk_019a8d - Response Code:
200 OK
{
"id": "tsk_019a8d",
"title": "Add analytics tracking script to Layout",
"status": "pending",
"priority": "high",
"created_at": "2026-10-04T12:30:00Z",
"updated_at": "2026-10-04T12:30:00Z"
}
If the ID does not exist in the database, return:
- Response Code:
404 Not Found
4. Updating a Task (PATCH vs PUT)
Use PATCH when updating specific fields without overwriting the entire resource:
- Endpoint:
PATCH /v1/tasks/tsk_019a8d - Response Code:
200 OK
Request Body:
{
"status": "completed"
}
Response Body:
{
"id": "tsk_019a8d",
"title": "Add analytics tracking script to Layout",
"status": "completed",
"priority": "high",
"created_at": "2026-10-04T12:30:00Z",
"updated_at": "2026-10-04T12:45:00Z"
}
5. Deleting a Task (DELETE)
Deletes the target resource permanently or soft-deletes it:
- Endpoint:
DELETE /v1/tasks/tsk_019a8d - Response Code:
204 No Content - Response Body: (Empty)
Key Rule: A
204 No Contentresponse MUST NOT contain a message body. If you wish to return a confirmation object (e.g.,{"deleted": true}), use200 OKinstead.
Common CRUD Anti-Patterns to Avoid
| Anti-Pattern | Why It Breaks | How to Fix |
|---|---|---|
Returning 200 OK with {"error": "not found"} |
CDNs cache error bodies, client libraries cannot catch standard HTTP exceptions | Return 404 Not Found with RFC 9457 error body |
Using GET to delete resources (GET /task/delete/1) |
Web crawlers and prefetchers will unintentionally delete data | Strictly use DELETE /v1/tasks/1 |
Deep nesting (/users/1/orgs/2/teams/3/tasks/4) |
Fragile URLs, difficult to refactor ownership hierarchies | Flatten to /v1/tasks/4 once ID is unique |
Returning unwrapped JSON arrays ([...]) |
Prevents adding pagination or metadata without breaking schema contract | Always wrap in an object: {"data": [...]} |