100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Node.js & Express Backend
55 minintermediate

Practice — Modular Express App Scaffold

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.

Analogy🏏Cricket
🏏 Think of it like cricket: You are the data operations manager at the BCCI analytics division, responsible for processing the complete ball-by-ball feed from an IPL season — 74 matches, 888 overs, over 5,000 deliveries. The raw feed arrives as a continuous data stream from the scoring tablets at each ground. Your job is to build the pipeline that ingests that stream, filters out extras and wide deliveries for certain statistics, aggregates the useful data into player-level summaries, and publishes the final report to the BCCI's official statistics portal before the next morning's press conference. Just as the BCCI would never ask analysts to hold the entire season's data in memory before starting analysis (they process each match's data as it arrives from the ground), your pipeline processes each CSV chunk as it streams from disk — maintaining a constant memory footprint regardless of how many seasons of data you process.

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.

Analogy🏏Cricket
🏏 Think of it like cricket: A talented all-rounder like Hardik Pandya carries one kitbag and wears one jersey, yet fills two roles in the same match depending on what the situation demands — batting when the innings needs runs, bowling when it needs wickets. Just as the team does not sign a separate player for each discipline, this pipeline lives in a single pipeline.js file with zero external dependencies, using isMainThread to branch: run as the main script it drives the streaming pipeline, loaded as a Worker Thread it computes the heavy statistics. Just as the all-rounder relies only on standard-issue gear provided by the board — Node's built-in modules like fs, stream, zlib and worker_threads — the project needs nothing installed beyond Node 16+ and one data-generator run to create the input CSV. Just as a clear team role sheet is set before the toss, the output directory is created programmatically up front. The payoff: a single self-contained, dual-mode module keeps all logic co-located and trivially runnable.
bash
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 point

Step 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.

Analogy🏏Cricket
🏏 Think of it like cricket: Generating the CSV data is like preparing the official ball-by-ball scoring sheets before the match — every delivery needs a proper record to exist before the analysts can process it. The isMainThread branching is like the dual role of a cricket all-rounder: Hardik Pandya uses the same physical body in the same match to both bat (main thread: run the pipeline) and bowl (worker thread: compute statistics) — the context determines which role he plays at any given moment, not a different person.
javascript
// 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.

Analogy🏏Cricket
🏏 Think of it like cricket: The stream pipeline is the scoring team's relay: one person reads the raw tally sheets (CSV stream), another converts tally marks to digits (CSV parser Transform), another verifies the numbers against the umpire's signal log (cleaner Transform), and the final person enters confirmed records into the digital system (accumulator Writable). The Worker Threads computing statistics in parallel are like four analysts simultaneously computing batting averages, bowling economies, strike rates, and partnership data for different players — each on their own workstation, handing their results to the chief analyst (main thread) who compiles the final report.
javascript
// 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.

Analogy🏏Cricket
🏏 Think of it like cricket: The debounced progress reporter is like the scoreboard operator who only updates the display every 30 seconds during a fast-paced T20 innings rather than after every single delivery — frequent enough to be useful, infrequent enough not to distract or overwhelm. The fs.watch restart capability is like the third umpire's system that automatically re-runs a ball-tracking analysis when new camera angles arrive mid-review — the system notices the data changed and reprocesses automatically without manual intervention.
javascript
// 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.

Analogy🏏Cricket
🏏 Think of it like cricket: After the innings, the match referee does not just trust the on-field scoreboard — an independent scorer decompresses the official ledger, recomputes each batsman's runs and each bowler's figures, and confirms the totals reconcile before the result is ratified. Just as that reconciliation catches a mis-recorded boundary, the verification script reads the gzipped output files, decompresses them, and validates that the computed player statistics are numerically consistent with the input CSV. Just as the referee also confirms the game flowed without the umpires seizing up mid-over, the event-loop responsiveness check confirms that dispatching the stats to Worker Threads never blocked the main thread — the setInterval ticker kept firing throughout. Just as ratifying a result only after independent checks prevents disputed records, verifying decompressed output and loop responsiveness proves the pipeline is both correct and non-blocking. The payoff: you ship a pipeline you have actually observed producing right answers without ever freezing the server.
bash
# 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.
Lesson 12 of 36
0% complete