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