Skip to content
intermediate Phase 9 · Backend with Node.js

Express.js API Development

Build REST APIs with Express — routing, middleware, validation, error handling, and project structure.

1h 30m
0 problems
Topic Progress 0%

Express Routing and Project Structure

Express Routing and Project Structure

Project Layout

src/
├── app.js              # Express app setup
├── server.js           # HTTP server + graceful shutdown
├── config/
│   ├── index.js        # Environment config
│   └── database.js     # DB connection
├── middleware/
│   ├── errorHandler.js
│   ├── validate.js
│   └── auth.js
├── routes/
│   ├── index.js        # Route aggregator
│   ├── users.js
│   └── posts.js
├── controllers/
│   ├── users.js
│   └── posts.js
├── services/
│   ├── users.js
│   └── posts.js
└── models/
    ├── User.js
    └── Post.js

Route Definitions

// routes/users.js
import { Router } from 'express';
import * as usersController from '../controllers/users.js';
import { validate } from '../middleware/validate.js';
import { authenticate } from '../middleware/auth.js';
import { createUserSchema, updateUserSchema } from '../schemas/users.js';

const router = Router();

router.get('/', authenticate, usersController.list);
router.get('/:id', authenticate, usersController.getById);
router.post('/', validate(createUserSchema), usersController.create);
router.patch('/:id', authenticate, validate(updateUserSchema), usersController.update);
router.delete('/:id', authenticate, usersController.remove);

export default router;

// routes/index.js
import { Router } from 'express';
import usersRouter from './users.js';
import postsRouter from './posts.js';

const router = Router();
router.use('/users', usersRouter);
router.use('/posts', postsRouter);

export default router;

// app.js
import express from 'express';
import routes from './routes/index.js';
import { errorHandler } from './middleware/errorHandler.js';

const app = express();
app.use(express.json({ limit: '10mb' }));
app.use('/api', routes);
app.use(errorHandler);

export default app;

Route Parameters and Queries

router.get('/', async (req, res, next) => {
  try {
    const { page = 1, limit = 20, sort = 'createdAt', order = 'desc' } = req.query;
    const users = await usersService.list({
      page: parseInt(page),
      limit: Math.min(parseInt(limit), 100),
      sort,
      order,
    });
    res.json(users);
  } catch (error) {
    next(error);
  }
});

Input Validation and Error Handling

Input Validation and Error Handling

Zod Validation Schemas

// schemas/users.js
import { z } from 'zod';

export const createUserSchema = z.object({
  name: z.string().min(2).max(100).trim(),
  email: z.string().email().toLowerCase(),
  password: z.string().min(8).max(128)
    .regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, 'Must contain uppercase, lowercase, and number'),
  role: z.enum(['user', 'editor', 'admin']).default('user'),
});

export const updateUserSchema = createUserSchema.partial().omit({ password: true });

export const loginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(1),
});

// schemas/posts.js
export const createPostSchema = z.object({
  title: z.string().min(1).max(200).trim(),
  content: z.string().min(10).max(10000),
  published: z.boolean().default(false),
  tags: z.array(z.string().max(30)).max(10).default([]),
});

Validation Middleware

// middleware/validate.js
export function validate(schema) {
  return (req, res, next) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      const errors = result.error.errors.map(e => ({
        field: e.path.join('.'),
        message: e.message,
      }));
      return res.status(400).json({ errors });
    }
    req.body = result.data;
    next();
  };
}

Global Error Handler

// middleware/errorHandler.js
export function errorHandler(err, req, res, next) {
  console.error(`[${new Date().toISOString()}] Error:`, err);

  if (err.isOperational) {
    return res.status(err.statusCode).json({
      error: err.message,
      code: err.code,
    });
  }

  // Programming error — don't expose details
  res.status(500).json({ error: 'Internal server error' });
}

// Custom error class
export class AppError extends Error {
  constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') {
    super(message);
    this.statusCode = statusCode;
    this.code = code;
    this.isOperational = true;
  }
}

// Usage in controller
export async function getById(req, res, next) {
  try {
    const user = await usersService.findById(req.params.id);
    if (!user) throw new AppError('User not found', 404, 'USER_NOT_FOUND');
    res.json(user);
  } catch (error) {
    next(error);
  }
}

Middleware Stack

Middleware Stack

CORS Configuration

import cors from 'cors';

app.use(cors({
  origin: process.env.NODE_ENV === 'production'
    ? ['https://myapp.com']
    : ['http://localhost:3000'],
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true,
  maxAge: 86400,
}));

Rate Limiting

import rateLimit from 'express-rate-limit';

// Global rate limit
app.use(rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 minutes
  max: 100,                   // 100 requests per window
  message: { error: 'Too many requests' },
  standardHeaders: true,
  legacyHeaders: false,
}));

// Stricter limit for auth routes
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 10,
  message: { error: 'Too many auth attempts' },
});

app.use('/api/auth/login', authLimiter);

Request Logging

import morgan from 'morgan';

// Combined format with custom tokens
morgan.token('body', (req) => {
  if (req.method === 'POST') return JSON.stringify(req.body);
  return '';
});

app.use(morgan(
  ':method :url :status :response-time ms - :res[content-length] :body',
  { stream: logger.stream }
));

Helmet Security Headers

import helmet from 'helmet';

app.use(helmet());
// Adds: X-Content-Type-Options, X-Frame-Options, X-XSS-Protection,
// Strict-Transport-Security, Content-Security-Policy, etc.

Body Parsing and Size Limits

app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));

// File uploads with multer
import multer from 'multer';
const upload = multer({
  storage: multer.memoryStorage(),
  limits: { fileSize: 5 * 1024 * 1024 },  // 5MB
  fileFilter: (req, file, cb) => {
    if (file.mimetype.startsWith('image/')) cb(null, true);
    else cb(new AppError('Only images allowed', 400));
  },
});

app.post('/api/upload', upload.single('file'), (req, res) => {
  res.json({ filename: req.file.originalname, size: req.file.size });
});

Controller-Service-Repository Pattern

Controller-Service-Repository Pattern

Controller Layer

// controllers/users.js
import * as usersService from '../services/users.js';
import { AppError } from '../middleware/errorHandler.js';

export async function list(req, res, next) {
  try {
    const { page, limit, sort, order } = req.query;
    const result = await usersService.list({ page, limit, sort, order });
    res.json(result);
  } catch (error) {
    next(error);
  }
}

export async function getById(req, res, next) {
  try {
    const user = await usersService.findById(req.params.id);
    if (!user) throw new AppError('User not found', 404);
    res.json(user);
  } catch (error) {
    next(error);
  }
}

export async function create(req, res, next) {
  try {
    const user = await usersService.create(req.body);
    res.status(201).json(user);
  } catch (error) {
    if (error.code === '23505') {  // PostgreSQL unique constraint
      return next(new AppError('Email already exists', 409, 'DUPLICATE_EMAIL'));
    }
    next(error);
  }
}

Service Layer

// services/users.js
import * as usersRepo from '../repositories/users.js';
import { hashPassword } from '../utils/crypto.js';

export async function list({ page, limit, sort, order }) {
  const offset = (page - 1) * limit;
  const [users, total] = await Promise.all([
    usersRepo.findAll({ offset, limit, sort, order }),
    usersRepo.count(),
  ]);
  return {
    data: users,
    meta: { page, limit, total, totalPages: Math.ceil(total / limit) },
  };
}

export async function findById(id) {
  return usersRepo.findById(id);
}

export async function create(data) {
  const hashedPassword = await hashPassword(data.password);
  return usersRepo.create({ ...data, password: hashedPassword });
}

Repository Layer (Database Access)

// repositories/users.js
import { db } from '../config/database.js';

export async function findAll({ offset, limit, sort, order }) {
  const { rows } = await db.query(
    `SELECT id, name, email, role, created_at
     FROM users
     ORDER BY ${sort} ${order === 'desc' ? 'DESC' : 'ASC'}
     LIMIT $1 OFFSET $2`,
    [limit, offset]
  );
  return rows;
}

export async function findById(id) {
  const { rows } = await db.query('SELECT * FROM users WHERE id = $1', [id]);
  return rows[0] ?? null;
}

export async function create({ name, email, password, role }) {
  const { rows } = await db.query(
    'INSERT INTO users (name, email, password, role) VALUES ($1, $2, $3, $4) RETURNING *',
    [name, email, password, role]
  );
  return rows[0];
}

export async function count() {
  const { rows } = await db.query('SELECT COUNT(*) FROM users');
  return parseInt(rows[0].count);
}

Why This Pattern?

  • Controller: HTTP concerns (request parsing, response formatting)
  • Service: Business logic (validation rules, orchestration, transactions)
  • Repository: Data access (queries, ORM calls)
  • Each layer is independently testable and replaceable