What You'll Build
In this exercise you will scaffold a production-structured modular Express application for a cricket statistics platform. The finished scaffold will be a complete, runnable Express server organised into versioned API routes, feature-based router modules for players, matches, and teams, scoped authentication middleware, centralised error handling with a custom error class hierarchy, static asset serving, and a basic EJS template for a health-check HTML page. By the end, you will have a reusable project template that embodies every concept from Module 2's five reading lessons: application versus router versus middleware, the request lifecycle, route grouping and versioning, static assets and templates, and error handling patterns. This scaffold forms the foundation that later modules will extend with database integration, authentication, and background processing.
Prerequisites
- Node.js 18+ and npm installed; understanding of npm init and package.json dependency management.
- Understanding of Express Application, Router, and middleware from Lesson 7, including the three-tier model and middleware registration order.
- Understanding of the request lifecycle from Lesson 8, including async error propagation and the asyncHandler pattern.
- Understanding of route grouping and versioning from Lesson 9, including router factory pattern and URL path versioning.
- Understanding of error handling middleware from Lesson 11, including the four-argument signature and the operational versus programmer error distinction.
Setup & Project Structure
Create a new directory called cricket-api and initialise it as an npm project. Install Express, EJS for template rendering, and uuid for request ID generation. The project follows a feature-based directory structure where each Express router lives in its own file under src/routes, shared middleware lives under src/middleware, custom error classes live under src/errors, and EJS templates live under views. The src/app.js file is the composition root: it imports everything and wires the pipeline together. This structure keeps the entry point small and makes it trivially easy to add new resources by adding a new router file and a single mount line in app.js.
mkdir cricket-api && cd cricket-api
npm init -y
npm install express ejs uuid
# Project structure
cricket-api/
src/
routes/
v1/
players.js # player CRUD routes
matches.js # match routes
teams.js # team routes
index.js # mounts all v1 routers
middleware/
requestId.js # attaches UUID to every request
authenticate.js # JWT stub middleware
asyncHandler.js # wraps async handlers
errors/
AppError.js # base error class
NotFoundError.js # 404 operational error
app.js # composition root
views/
health.ejs # health check HTML page
public/
css/
main.css # basic styles
server.js # process entry pointStep 1 — Foundation
The foundation step creates the custom error class hierarchy and the shared middleware utilities that all subsequent code depends on. AppError is the base class for all application-specific errors: it extends Error, sets isOperational to true, assigns a statusCode and an error code string, and uses Error.captureStackTrace to ensure clean stack traces. NotFoundError extends AppError with a 404 status. The asyncHandler utility is a higher-order function that wraps any async Express handler in a Promise.resolve().catch(next) envelope, ensuring that rejected promises automatically call next(err) without requiring try-catch in every route handler. The requestId middleware generates a UUID v4 for each incoming request, attaches it to req.id, and sets the X-Request-Id response header so that clients can correlate responses to requests in their own logs.
// src/errors/AppError.js
class AppError extends Error {
constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') {
super(message);
this.statusCode = statusCode;
this.code = code;
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
class NotFoundError extends AppError {
constructor(resource) {
super(`${resource} not found`, 404, 'NOT_FOUND');
}
}
module.exports = { AppError, NotFoundError };
// src/middleware/asyncHandler.js
const asyncHandler = fn => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
module.exports = asyncHandler;
// src/middleware/requestId.js
const { v4: uuidv4 } = require('uuid');
module.exports = (req, res, next) => {
req.id = uuidv4();
req.startTime = process.hrtime.bigint();
res.setHeader('X-Request-Id', req.id);
next();
};
// src/middleware/authenticate.js
module.exports = (req, res, next) => {
const token = req.headers['authorization']?.split(' ')[1];
if (!token) {
const { AppError } = require('../errors/AppError');
return next(new AppError('Authentication required', 401, 'UNAUTHORIZED'));
}
req.user = { id: 'player_42', role: 'admin' }; // stub - real JWT in Module 4
next();
};Step 2 — Core Logic
Step 2 builds the three feature router files and the v1 index router that composes them. Each feature router uses asyncHandler for async operations, throws NotFoundError for missing resources, and uses in-memory arrays as data stores since database integration comes in a later module. The players router implements GET / to list all players, GET /:id to fetch a single player, POST / with authenticate middleware to create a player, and DELETE /:id with authenticate to remove one. The matches router implements GET / and GET /:id. The teams router implements GET / only. The v1/index.js file simply imports all three routers and mounts them on a parent router that is exported and mounted in app.js. This two-level mounting creates the /api/v1/players, /api/v1/matches, and /api/v1/teams paths through pure composition.
// src/routes/v1/players.js
const router = require('express').Router();
const asyncHandler = require('../../middleware/asyncHandler');
const authenticate = require('../../middleware/authenticate');
const { NotFoundError } = require('../../errors/AppError');
let players = [
{ id: '1', name: 'Rohit Sharma', role: 'batsman', average: 48.96 },
{ id: '2', name: 'Jasprit Bumrah', role: 'bowler', wickets: 149 },
{ id: '3', name: 'MS Dhoni', role: 'wicket-keeper', matches: 350 }
];
router.get('/', asyncHandler(async (req, res) => {
res.json({ data: players, meta: { total: players.length } });
}));
router.get('/:id', asyncHandler(async (req, res) => {
const player = players.find(p => p.id === req.params.id);
if (!player) throw new NotFoundError(`Player ${req.params.id}`);
res.json({ data: player });
}));
router.post('/', authenticate, asyncHandler(async (req, res) => {
const player = { id: String(Date.now()), ...req.body };
players.push(player);
res.status(201).json({ data: player });
}));
router.delete('/:id', authenticate, asyncHandler(async (req, res) => {
const index = players.findIndex(p => p.id === req.params.id);
if (index === -1) throw new NotFoundError(`Player ${req.params.id}`);
players.splice(index, 1);
res.status(204).send();
}));
module.exports = router;
// src/routes/v1/index.js
const router = require('express').Router();
router.use('/players', require('./players'));
router.use('/matches', require('./matches'));
router.use('/teams', require('./teams'));
module.exports = router;Step 3 — Integration & Enhancement
Step 3 builds the composition root, app.js, which wires every piece together into a functioning server. The app.js file registers global middleware in the correct order: requestId first, then express.json(), then express.static() for public assets, then the EJS view engine configuration, then the health check route that renders an HTML page, then the API v1 router at /api/v1, and finally the two chained error handlers. The health check route renders the views/health.ejs template with uptime and process information, demonstrating template engine integration. The server.js entry point simply imports app and calls listen, separating the HTTP binding concern from the application logic so that app can be imported by tests without starting a real server.
// src/app.js
const express = require('express');
const path = require('path');
const requestId = require('./middleware/requestId');
const app = express();
// 1. Request ID and timing (first)
app.use(requestId);
// 2. Body parsing
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// 3. Static assets
app.use(express.static(path.join(__dirname, '../public')));
// 4. Template engine
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, '../views'));
// 5. Health check (public)
app.get('/health', (req, res) => {
res.render('health', {
status: 'ok',
uptime: process.uptime().toFixed(2),
requestId: req.id,
timestamp: new Date().toISOString()
});
});
// 6. API routes
app.use('/api/v1', require('./routes/v1'));
// 7. 404 for unmatched routes
app.use((req, res, next) => {
const { NotFoundError } = require('./errors/AppError');
next(new NotFoundError(`Route ${req.method} ${req.path}`));
});
// 8. Error handlers (last)
app.use((err, req, res, next) => {
const elapsedMs = Number(process.hrtime.bigint() - req.startTime) / 1e6;
console.error({ requestId: req.id, code: err.code, message: err.message, elapsed: elapsedMs });
next(err);
});
app.use((err, req, res, next) => {
res.status(err.statusCode || 500).json({
error: {
code: err.code || 'INTERNAL_ERROR',
message: err.isOperational ? err.message : 'Internal server error',
requestId: req.id
}
});
});
module.exports = app;
// server.js
const app = require('./src/app');
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Cricket API listening on port ${PORT}`));Step 4 — Testing & Verification
Start the server with node server.js and verify all endpoints respond correctly using curl or a REST client. The health check should return an HTML page, the public player list should return JSON without an auth header, and the create player endpoint should return 401 without an auth header and 201 with one. Verify that a request to an undefined route returns a structured 404 JSON response with the expected error code, and that the request ID header appears in every response.
# Start the server
node server.js
# Output: Cricket API listening on port 3000
# Health check - expect HTML response
curl http://localhost:3000/health
# List players - expect JSON array, no auth required
curl http://localhost:3000/api/v1/players
# {"data":[{"id":"1","name":"Rohit Sharma",...}],"meta":{"total":3}}
# Get single player
curl http://localhost:3000/api/v1/players/1
# {"data":{"id":"1","name":"Rohit Sharma","role":"batsman","average":48.96}}
# Get missing player - expect 404
curl http://localhost:3000/api/v1/players/999
# {"error":{"code":"NOT_FOUND","message":"Player 999 not found","requestId":"..."}}
# Create player without auth - expect 401
curl -X POST http://localhost:3000/api/v1/players \
-H "Content-Type: application/json" \
-d '{"name":"Virat Kohli","role":"batsman","average":53.5}'
# {"error":{"code":"UNAUTHORIZED","message":"Authentication required","requestId":"..."}}
# Create player with auth stub - expect 201
curl -X POST http://localhost:3000/api/v1/players \
-H "Content-Type: application/json" \
-H "Authorization: Bearer any-token-for-now" \
-d '{"name":"Virat Kohli","role":"batsman","average":53.5}'
# {"data":{"id":"...","name":"Virat Kohli","role":"batsman","average":53.5}}
# Undefined route - expect 404 with structured JSON
curl http://localhost:3000/api/v1/undefined-resource
# {"error":{"code":"NOT_FOUND","message":"Route GET /api/v1/undefined-resource not found","requestId":"..."}}Warning: If you receive Cannot GET / or Cannot POST / errors instead of your expected JSON responses, the router mounting order in app.js is likely wrong, or a router file has a syntax error that prevented it from exporting correctly. Use node -e 'require("./src/routes/v1")' to test that each router file loads without errors before debugging the full server. If you receive req.body as undefined on POST requests, confirm that app.use(express.json()) is registered before the API router in app.js.
Extension Challenge: Once the basic scaffold is working, add a v2 router at /api/v2 using the router factory pattern from Lesson 9. The v2 player list response should include a links object with self and collection HATEOAS links. Then add an integration test file using Node's built-in http module that sends real HTTP requests to the running server and asserts on the response status codes and JSON shapes.
- A production Express scaffold separates concerns into discrete files: composition root (app.js), feature routers, shared middleware, error classes, templates, and static assets, each with a single clear responsibility.
- Custom error class hierarchies with isOperational, statusCode, and code properties give error handlers a rich type system and prevent sensitive internal details from leaking to clients.
- The asyncHandler utility eliminates boilerplate try-catch blocks from every async route handler by wrapping them in Promise.resolve().catch(next), ensuring rejected promises call next(err) automatically.
- Middleware registration order in app.js is a correctness requirement: requestId must precede logging, body parsing must precede route handlers, routes must precede the catch-all 404 handler, and error handlers must be last.
- Two-level router mounting, v1/index.js composing feature routers mounted at /api/v1 in app.js, keeps the composition root small and makes adding new resources a two-file change.
- Separating the HTTP server binding (server.js) from the application logic (app.js) allows the app to be imported by test suites without starting a real server on a real port.