REST API Design — Conventions, Naming, Status Codes, and Versioning

REST (Representational State Transfer) is an architectural style for designing networked APIs. It is not a protocol or a standard — it is a set of constraints and conventions that, when followed consistently, produce APIs that are intuitive to use, easy to document, and straightforward to consume from any client (Angular, mobile, curl, Postman). The difference between a REST API that developers enjoy using and one they dread is almost entirely in how well these conventions are followed. This lesson covers every convention you need to design professional, production-quality REST APIs for the MEAN Stack.

REST Constraints and Key Principles

Principle Meaning Practical Impact
Stateless Each request contains all information needed to process it No server-side sessions — JWT tokens travel with every request
Resource-based URLs identify resources (nouns), HTTP methods describe actions (verbs) /tasks not /getTasks or /deleteTask
Uniform interface Consistent conventions across all endpoints All errors have the same shape, all lists are paginated the same way
Client-Server Frontend and backend are independently deployable Angular can be replaced with React without changing the API
Layered System Client does not know if it talks to the real server or a proxy Load balancers, CDNs, and gateways are transparent to the client

URL Naming Conventions

Rule Good Bad
Use nouns, not verbs /api/tasks /api/getTasks, /api/createTask
Use plural nouns /api/tasks, /api/users /api/task, /api/user
Use kebab-case /api/task-categories /api/taskCategories, /api/task_categories
Nest related resources /api/users/:id/tasks /api/getUserTasks?userId=:id
Keep nesting shallow (max 2) /api/users/:id/tasks /api/users/:id/projects/:pid/tasks/:tid/comments
Actions via sub-resources POST /api/tasks/:id/complete PUT /api/tasks/:id?action=complete
Version in the path /api/v1/tasks /tasks (no version)

HTTP Method to CRUD Mapping

Method Collection /tasks Document /tasks/:id Idempotent
GET List all tasks (with filters) Get one task by ID Yes
POST Create a new task N/A (or custom action) No
PUT Bulk replace (rare) Replace entire task Yes
PATCH N/A Partially update task No
DELETE Bulk delete (rare) Delete task by ID Yes

HTTP Status Code Quick Reference

Code Name When to Use in a REST API
200 OK Successful GET, PUT, PATCH. Also for DELETE if body returned
201 Created Successful POST — new resource created. Include resource in body
204 No Content Successful DELETE or action with no response body needed
400 Bad Request Malformed JSON, missing required fields, validation failure
401 Unauthorized No valid authentication token provided
403 Forbidden Authenticated but not authorised for this resource
404 Not Found Resource does not exist — or deliberately hide existence
409 Conflict Duplicate resource — email already registered
422 Unprocessable Entity Semantic validation error — valid syntax, invalid logic
429 Too Many Requests Rate limit exceeded
500 Internal Server Error Unhandled server error — never expose stack trace
Note: The difference between PUT and PATCH matters. PUT replaces the entire resource — fields not included in the request body are cleared or reset to defaults. PATCH applies only the supplied changes, leaving everything else untouched. For most MEAN Stack APIs, PATCH is the right choice for updates because clients rarely need to send the entire document. PUT is reserved for cases where replacing the whole document is the explicit intent.
Tip: Design your response envelope before writing a single controller. Decide on a consistent structure for success and error responses that every endpoint follows. A simple pattern: success responses use { success: true, data: ... }, list responses add meta: { total, page, limit, totalPages }, and error responses use { success: false, message: '...', errors: [...] }. Angular services that consume these can extract data and handle errors with a single shared pipe.
Warning: API versioning is not optional for production APIs — it is mandatory. Once clients depend on your API, you cannot change field names, response shapes, or behaviour without breaking them. Version from day one with /api/v1/. When you need to make breaking changes, introduce /api/v2/ and give consumers time to migrate. Trying to add versioning to an unversioned API after clients are deployed is extremely painful.

Complete Task Manager API Design

Task Manager REST API — Complete Endpoint Map
══════════════════════════════════════════════════════════════

Authentication
  POST   /api/v1/auth/register      Create account
  POST   /api/v1/auth/login         Login, receive JWT
  POST   /api/v1/auth/refresh       Refresh access token
  POST   /api/v1/auth/logout        Invalidate refresh token
  GET    /api/v1/auth/me            Get current user profile
  PATCH  /api/v1/auth/me            Update profile
  PATCH  /api/v1/auth/me/password   Change password

Tasks (all protected — require Authorization: Bearer token)
  GET    /api/v1/tasks              List tasks (filter, sort, paginate)
  POST   /api/v1/tasks              Create task
  GET    /api/v1/tasks/:id          Get one task
  PUT    /api/v1/tasks/:id          Replace task
  PATCH  /api/v1/tasks/:id          Partial update
  DELETE /api/v1/tasks/:id          Delete task
  PATCH  /api/v1/tasks/:id/complete Mark as complete
  PATCH  /api/v1/tasks/:id/reopen   Reopen completed task

File Uploads
  POST   /api/v1/tasks/:id/attachments   Upload file to task
  DELETE /api/v1/tasks/:id/attachments/:fileId  Delete attachment
  GET    /api/v1/uploads/:filename       Serve uploaded file

Query String Parameters (GET /api/v1/tasks)
  status    pending | in-progress | completed
  priority  low | medium | high
  page      integer (default: 1)
  limit     integer (default: 10, max: 100)
  sort      field name (default: -createdAt)
  q         search query (searches title and description)

Response Envelope
  Success:  { "success": true, "data": {...} }
  List:     { "success": true, "data": [...], "meta": { total, page, limit, totalPages } }
  Created:  { "success": true, "data": {...} }   HTTP 201
  Deleted:  (empty body)                          HTTP 204
  Error:    { "success": false, "message": "...", "errors": [...] }

API Versioning in Express

// ── Option 1: URL path versioning (recommended) ───────────────────────────
const express = require('express');
const app     = express();

const v1Router = express.Router();
v1Router.use('/auth',  require('./routes/v1/auth.routes'));
v1Router.use('/tasks', require('./routes/v1/task.routes'));

const v2Router = express.Router();
v2Router.use('/auth',  require('./routes/v2/auth.routes'));
v2Router.use('/tasks', require('./routes/v2/task.routes'));

app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);

// ── Option 2: Header versioning ───────────────────────────────────────────
app.use('/api/tasks', (req, res, next) => {
    const version = req.get('API-Version') || '1';
    if (version === '2') return require('./routes/v2/task.routes')(req, res, next);
    require('./routes/v1/task.routes')(req, res, next);
});

// ── Consistent response helpers ───────────────────────────────────────────
// utils/response.js
const success = (res, data, statusCode = 200) =>
    res.status(statusCode).json({ success: true, data });

const created = (res, data) =>
    res.status(201).json({ success: true, data });

const noContent = (res) =>
    res.status(204).end();

const paginated = (res, data, meta) =>
    res.json({ success: true, data, meta });

const error = (res, message, statusCode = 400, errors = []) =>
    res.status(statusCode).json({ success: false, message, errors });

module.exports = { success, created, noContent, paginated, error };

// ── Usage in a controller ─────────────────────────────────────────────────
const { success, created, noContent, paginated } = require('../utils/response');
const asyncHandler = require('../utils/asyncHandler');

exports.getAll = asyncHandler(async (req, res) => {
    const { page = 1, limit = 10 } = req.query;
    const pageNum  = Math.max(1,   parseInt(page,  10));
    const limitNum = Math.min(100, parseInt(limit, 10));
    const skip     = (pageNum - 1) * limitNum;

    const [data, total] = await Promise.all([
        Task.find({ user: req.user.id }).skip(skip).limit(limitNum).lean(),
        Task.countDocuments({ user: req.user.id }),
    ]);

    paginated(res, data, {
        total,
        page:       pageNum,
        limit:      limitNum,
        totalPages: Math.ceil(total / limitNum),
    });
});

exports.create = asyncHandler(async (req, res) => {
    const task = await Task.create({ ...req.body, user: req.user.id });
    created(res, task);
});

exports.remove = asyncHandler(async (req, res) => {
    await Task.findOneAndDelete({ _id: req.params.id, user: req.user.id });
    noContent(res);
});

How It Works

Step 1 — Resources Are Nouns, Methods Are Verbs

The URL identifies what the action is about (the resource), and the HTTP method identifies what to do with it. DELETE /api/v1/tasks/42 is self-explanatory — delete task 42. GET /api/v1/tasks/42 reads it. PATCH /api/v1/tasks/42 modifies it. This separation lets clients predict URL patterns and lets servers organise code by resource rather than by action.

Step 2 — Nested Resources Express Ownership

/api/v1/users/42/tasks means “tasks belonging to user 42”. This is cleaner than /api/v1/tasks?userId=42 for resources that genuinely own sub-resources. However, keep nesting to a maximum of two levels deep — deeper nesting creates URLs that are difficult to construct and maintain. If you need a deeply nested resource, consider an independent endpoint with filter parameters.

Step 3 — The Response Envelope Creates Consistency

Wrapping every response in { success, data } or { success, message, errors } gives Angular a guaranteed structure. Every HTTP service method can use the same map(res => res.data) operator regardless of which endpoint it calls. Error handling code can always read error.error.message for the human-readable error message. This consistency dramatically reduces frontend boilerplate.

Step 4 — Versioning Protects Existing Clients

Once Angular (or a mobile app, or a third-party integration) depends on your API’s response shape, changing it breaks them. URL versioning (/api/v1/) lets you introduce /api/v2/ with breaking changes while /api/v1/ continues working for existing clients. You can then deprecate v1 after all consumers have migrated. Without versioning, every change is potentially a breaking change.

Step 5 — Idempotency Matters for Safe Retries

Idempotent operations (GET, PUT, DELETE) produce the same result regardless of how many times they are called. Non-idempotent operations (POST) create a new resource each time. Clients can safely retry idempotent requests after network failures without side effects. This is why payment APIs use PUT (idempotent) for charging rather than POST — retrying a failed PUT does not double-charge the customer.

Real-World Example: Full Task Controller

// controllers/task.controller.js
const Task         = require('../models/task.model');
const asyncHandler = require('../utils/asyncHandler');
const { NotFoundError, ForbiddenError } = require('../utils/AppError');
const { success, created, noContent, paginated } = require('../utils/response');

const VALID_SORT_FIELDS = new Set([
    'createdAt', '-createdAt', 'title', '-title',
    'priority', '-priority', 'dueDate', '-dueDate',
]);

// GET /api/v1/tasks
exports.getAll = asyncHandler(async (req, res) => {
    const { status, priority, q, page = 1, limit = 10, sort = '-createdAt' } = req.query;

    const filter = { user: req.user.id };
    if (status)   filter.status   = status;
    if (priority) filter.priority = priority;
    if (q)        filter.$or      = [
        { title:       { $regex: q, $options: 'i' } },
        { description: { $regex: q, $options: 'i' } },
    ];

    const pageNum  = Math.max(1,   parseInt(page,  10));
    const limitNum = Math.min(100, parseInt(limit, 10));
    const sortStr  = VALID_SORT_FIELDS.has(sort) ? sort : '-createdAt';

    const [data, total] = await Promise.all([
        Task.find(filter)
            .sort(sortStr)
            .skip((pageNum - 1) * limitNum)
            .limit(limitNum)
            .select('-__v')
            .lean(),
        Task.countDocuments(filter),
    ]);

    res.set('X-Total-Count', total);
    paginated(res, data, {
        total,
        page:       pageNum,
        limit:      limitNum,
        totalPages: Math.ceil(total / limitNum),
    });
});

// GET /api/v1/tasks/:id
exports.getById = asyncHandler(async (req, res) => {
    const task = await Task.findOne({ _id: req.params.id, user: req.user.id }).lean();
    if (!task) throw new NotFoundError('Task not found');
    success(res, task);
});

// POST /api/v1/tasks
exports.create = asyncHandler(async (req, res) => {
    const { title, description, priority, dueDate } = req.body;
    const task = await Task.create({ title, description, priority, dueDate, user: req.user.id });
    created(res, task);
});

// PUT /api/v1/tasks/:id — full replace
exports.replace = asyncHandler(async (req, res) => {
    const { title, description, priority, status, dueDate } = req.body;
    const task = await Task.findOneAndUpdate(
        { _id: req.params.id, user: req.user.id },
        { title, description, priority, status, dueDate },
        { new: true, runValidators: true, overwrite: true }
    );
    if (!task) throw new NotFoundError('Task not found');
    success(res, task);
});

// PATCH /api/v1/tasks/:id — partial update
exports.update = asyncHandler(async (req, res) => {
    const allowed = ['title', 'description', 'priority', 'status', 'dueDate'];
    const updates = Object.fromEntries(
        Object.entries(req.body).filter(([k]) => allowed.includes(k))
    );
    const task = await Task.findOneAndUpdate(
        { _id: req.params.id, user: req.user.id },
        { $set: updates },
        { new: true, runValidators: true }
    );
    if (!task) throw new NotFoundError('Task not found');
    success(res, task);
});

// DELETE /api/v1/tasks/:id
exports.remove = asyncHandler(async (req, res) => {
    const task = await Task.findOneAndDelete({ _id: req.params.id, user: req.user.id });
    if (!task) throw new NotFoundError('Task not found');
    noContent(res);
});

// PATCH /api/v1/tasks/:id/complete
exports.markComplete = asyncHandler(async (req, res) => {
    const task = await Task.findOneAndUpdate(
        { _id: req.params.id, user: req.user.id },
        { $set: { status: 'completed', completedAt: new Date() } },
        { new: true }
    );
    if (!task) throw new NotFoundError('Task not found');
    success(res, task);
});

Common Mistakes

Mistake 1 — Using verbs in URLs

❌ Wrong — RPC-style URLs mix action into the path:

app.post('/api/createTask',   handler);
app.get( '/api/getTaskById',  handler);
app.post('/api/deleteTask',   handler);
app.post('/api/completeTask', handler);

✅ Correct — nouns in URLs, HTTP method carries the action:

app.post(  '/api/v1/tasks',              create);
app.get(   '/api/v1/tasks/:id',          getById);
app.delete('/api/v1/tasks/:id',          remove);
app.patch( '/api/v1/tasks/:id/complete', markComplete);

Mistake 2 — Inconsistent response shapes across endpoints

❌ Wrong — every endpoint returns a different structure:

res.json(task);                        // GET /tasks/:id — raw document
res.json({ tasks: [...], count: 5 });  // GET /tasks — different wrapper
res.json({ result: 'ok', id: '...' }); // POST /tasks — yet another shape

✅ Correct — use a shared response utility for every endpoint:

success(res, task);                          // { success: true, data: task }
paginated(res, tasks, { total, page, ... }); // { success: true, data: [...], meta: {...} }
created(res, task);                          // 201 { success: true, data: task }

Mistake 3 — Not versioning the API from the start

❌ Wrong — no versioning, future breaking changes will break all consumers:

app.use('/api/tasks', taskRoutes);   // all clients depend on this exact shape
// 6 months later: rename 'title' to 'name' — breaks every Angular component

✅ Correct — version from day one:

app.use('/api/v1/tasks', taskRoutesV1);
// Later: introduce v2 with renamed fields
app.use('/api/v2/tasks', taskRoutesV2);  // v1 continues working for existing clients

Quick Reference

Operation Method URL Success Status Body
List GET /api/v1/tasks 200 Array + meta
Get one GET /api/v1/tasks/:id 200 Object
Create POST /api/v1/tasks 201 New object
Full replace PUT /api/v1/tasks/:id 200 Updated object
Partial update PATCH /api/v1/tasks/:id 200 Updated object
Delete DELETE /api/v1/tasks/:id 204 None
Custom action POST/PATCH /api/v1/tasks/:id/complete 200 Updated object

🧠 Test Yourself

A client wants to update only the status field of a task without affecting other fields. Which HTTP method and status code are correct?