We closed module 2 with the Aroma Store contract complete on paper: 24 URIs, methods with their idempotency settled, an error catalogue, JSON representations, pagination, versioning and documentation. Not a single line of server code. This module fulfils that contract with Node.js 20 and Express, and as in any serious piece of work, we start with the foundations: preparing the machine, creating the project, choosing and understanding each dependency, defining the folder structure that will support eight lessons of code, and isolating configuration in environment variables. It is the least glamorous lesson of the module and the one that prevents the most trouble: almost every beginner's blockage with Node comes from a wrong version, an import the project does not accept, or a secret hard-coded into the source. By the end you will have the project skeleton ready for the next lesson to start it up; there will still be no server, and that is intentional.

Contents

  1. What we are going to build, and with what
  2. Node.js 20 LTS: installation and verification
  3. Version managers: nvm and fnm
  4. npm and npx
  5. Creating the project with npm init
  6. Anatomy of package.json
  7. ESM versus CommonJS and "type": "module"
  8. Semantic versioning in dependencies: ^ and ~
  9. package-lock.json, npm ci versus npm install
  10. dependencies versus devDependencies
  11. The course's dependencies, one by one
  12. The project's npm scripts
  13. The folder structure
  14. Configuration with environment variables
  15. .gitignore and initialising Git
  16. Editor and working tools
  17. Final check of the environment

  1. What we are going to build, and with what

Across the eight lessons of this module we are going to build a single project that grows. There will be no throwaway standalone examples: each lesson starts from the state the previous one left behind and states explicitly which files it creates and which it modifies.

Lesson What it adds to the project
03-01 Skeleton: package.json, folders, configuration, Git
03-02 Express server, /v1 router, first in-memory coffee routes
03-03 Controllers, services, mappers, full CRUD, filters and pagination
03-04 Zod schemas and validation middleware
03-05 SQLite with the repository pattern, migrations, transactions
03-06 Registration, login, JWT, roles and permissions
03-07 ApiError and unified error middleware
03-08 Unit and integration tests

The technical stack is fixed here and does not change:

Piece Choice Why
Runtime Node.js 20 LTS Long-term support, built-in node --watch and node:test
Modules ESM (import/export) It is the JavaScript standard; CommonJS is the legacy
HTTP framework Express 4.x Minimal, explicit, the most widespread; no hidden magic
Validation Zod Declarative schemas with type inference
Persistence SQLite via better-sqlite3 Zero configuration, real SQL, swappable for PostgreSQL
Authentication JWT (jsonwebtoken) + bcrypt Stateless, a natural fit for REST
Testing node:test + Supertest No extra dependency for the runner

A note on language: as we settled in the style guide in 02-01, code and comments are written in English. You will see getCoffees, priceEuros, coffeeRepository or errorHandler, and the project's files are called routes/coffees.js or services/orders.js. Only the names imposed by the tooling stay as they are: package.json, .env, node_modules.

  1. Node.js 20 LTS: installation and verification

Node.js is the environment that runs JavaScript outside the browser. Even-numbered versions (18, 20, 22) are LTS (Long Term Support): they receive fixes for around three years and are the ones used in production. Odd-numbered ones are experimental.

The first thing to do is see what is installed:

node --version
npm --version
v20.11.1
10.2.4

If node --version answers v20.x.x, you already have what you need. If it answers v16.x.x or the command does not exist, read on. We need 20 or above for three concrete reasons that we will make use of in this module:

  • node --watch: automatic reload of the server when you save, without nodemon.
  • node:test and node --test: built-in test runner (03-08).
  • node --env-file: loading .env files without a library (from 20.6 onwards).

  1. Version managers: nvm and fnm

You could install Node from nodejs.org and be done. Don't. An installer leaves a single global version, and the moment you work on two projects —one on Node 18 and another on Node 20— you have a problem whose only fix is uninstalling and reinstalling. A version manager lets you keep several versions at once and switch between them in seconds, even per folder.

The two usual choices:

Manager Written in Advantage Platforms
nvm Bash The most widespread, huge amount of documentation Linux, macOS (Windows: nvm-windows, a different project)
fnm Rust Much faster, automatic switching per folder Linux, macOS, native Windows

Installing nvm on Linux or macOS:

# Download and install nvm (check the latest version in its repository)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash

# Reload the shell configuration so that 'nvm' becomes available
source ~/.bashrc     # or ~/.zshrc if you use zsh

Day-to-day use:

nvm install 20        # Installs the latest 20.x LTS
nvm use 20            # Uses 20 in this terminal
nvm alias default 20  # 20 becomes the default version when you open a new terminal
nvm ls                # Lists the installed versions

A very practical detail: if you create a .nvmrc file at the root of the project containing 20, anyone who clones the repository can run nvm use and get the right version without asking. Let's add it:

echo "20" > .nvmrc

With fnm the commands are almost identical (fnm install 20, fnm use 20) and it also reads .nvmrc automatically when you enter the folder if you configure it with --use-on-cd.

  1. npm and npx

Installing Node gives you two commands that are worth not confusing:

Command What it does Example
npm Package manager: installs, updates and runs scripts npm install express
npx Runs a package without installing it permanently npx eslint src/

npx is especially useful for one-off tools (generators, migrators) and for running binaries that live in node_modules/.bin without typing the full path.

There are alternatives to npm —pnpm (faster and saves disk space), yarn, bun— and they are perfectly valid. This course uses npm because it ships with Node and does not add one more requirement.

  1. Creating the project with npm init

We create the folder and initialise it:

mkdir aroma-store-api
cd aroma-store-api
npm init -y
  • mkdir creates the project directory. The kebab-case name is the npm convention.
  • npm init -y generates a package.json with default values without asking questions. Without -y, npm asks for name, version, description and so on interactively.

The result is a minimal package.json:

{
  "name": "aroma-store-api",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

  1. Anatomy of package.json

package.json is the project's identity card: who it is, what it needs in order to work and how it is run. We are going to replace it with the course's definitive version, field by field:

{
  "name": "aroma-store-api",
  "version": "1.0.0",
  "description": "RESTful API for Aroma Store, a speciality coffee shop",
  "type": "module",
  "main": "src/server.js",
  "engines": {
    "node": ">=20.0.0"
  },
  "scripts": {
    "dev": "node --watch src/server.js",
    "start": "node src/server.js",
    "test": "node --test tests/",
    "lint": "eslint src/ tests/",
    "format": "prettier --write \"**/*.{js,json,md}\""
  },
  "license": "UNLICENSED",
  "private": true
}

What each field means:

Field What it is for
name Package identifier. Lowercase, no spaces
version Project version in SemVer format (02-07). Careful: this is not the API version, which lives in the /v1 path
description Free text; appears in the npm registry if published
type "module" enables ESM. That is the decision of the next section
main Entry point if another package imports this one
engines Supported Node versions. npm warns if they do not match
scripts Shorthand commands launched with npm run <name>
license UNLICENSED for private code; MIT or similar for open source
private true prevents publishing it to npm by accident. Essential in company code

  1. ESM versus CommonJS and "type": "module"

Node carries two module systems and you have to choose one deliberately, because mixing them is the number one source of baffling errors.

Aspect CommonJS (the legacy) ESM (the standard)
Importing const express = require('express') import express from 'express'
Exporting module.exports = something export default something / export { something }
When it is resolved At run time At parse time (statically)
How it is enabled By default "type": "module" or the .mjs extension
Extension in your own paths Optional (./coffees) Mandatory (./coffees.js)
__dirname, __filename Available Do not exist (there are equivalents)
Top-level await No Yes

Aroma Store uses ESM. It is the language standard, it works the same way in the browser and on the server, and it allows await at the top level of a file, something we will be grateful for when we open the database in 03-05.

The two practical consequences that trip people up most at the start:

// CORRECT in ESM: the .js extension is mandatory in your own paths
import { coffeeRepository } from './repositories/coffees-memory.js';

// INCORRECT in ESM: missing extension → ERR_MODULE_NOT_FOUND
import { coffeeRepository } from './repositories/coffees-memory';

// Packages from node_modules do NOT take an extension
import express from 'express';
// __dirname does not exist in ESM. The equivalent:
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';

const filePath = fileURLToPath(import.meta.url);   // Absolute path of this file
const folderPath = dirname(filePath);              // Its containing folder

Note the node: prefix as well (node:url, node:path, node:fs). It is the modern, explicit way of importing Node's internal modules, and it stops a malicious npm package called path from impersonating the system module. Always use it.

  1. Semantic versioning in dependencies: ^ and ~

When you install a package, npm writes a range of acceptable versions into package.json, not an exact version. Remember SemVer from 02-07: MAJOR.MINOR.PATCH.

Range Means Accepts Does not accept
^4.18.2 Compatible: pins the major 4.18.3, 4.19.0 5.0.0
~4.18.2 Approximate: pins major and minor 4.18.3, 4.18.9 4.19.0
4.18.2 Exact only 4.18.2 any other
* or latest Anything everything

npm's default is ^, and it is a reasonable compromise: you get bug fixes and new features without breaking changes, as long as the author respects SemVer. Never use *: it means "install me whatever", and one day your build will break without you having touched a thing.

  1. package-lock.json, npm ci versus npm install

If package.json says ^4.18.2, two people installing on different dates can end up with 4.18.2 and 4.19.1. That is exactly what produces the classic "it works on my machine". The solution is package-lock.json: an automatically generated file that records the exact version of every installed package and of every dependency of its dependencies, along with its integrity hash.

Golden rules:

  • package-lock.json is committed to Git. Always. It is not a temporary file.
  • It is never edited by hand.

And hence the difference between the two installation commands:

npm install npm ci
Reads package.json (and updates the lock) Only package-lock.json
Can change versions Yes No, never
Deletes node_modules first No Yes, entirely
Speed Slower Faster
Recommended use Development, when adding packages Continuous integration and production

Practical rule: on your laptop, npm install; in the CI pipeline and on the server, npm ci (we will see this in 05-05). If npm ci fails because the lock does not match package.json, that is a virtue, not a fault: it is warning you that someone touched the dependencies without regenerating the lock.

  1. dependencies versus devDependencies

npm install express         # goes into "dependencies"
npm install --save-dev eslint   # goes into "devDependencies"  (short form: -D)
Block Contains Installed in production?
dependencies What the code needs in order to run Yes
devDependencies Development tooling: tests, linters, formatters No (npm ci --omit=dev)

The distinction is not cosmetic: it reduces the size of the deployment image and, above all, the attack surface. A linter should not even exist on the production server. The opposite mistake —putting something the server uses at run time into devDependencies— produces an ERR_MODULE_NOT_FOUND that only shows up on deployment.

  1. The course's dependencies, one by one

We install the production ones first:

npm install express@4 zod better-sqlite3 jsonwebtoken bcrypt dotenv
Package What it does Where we will use it
express HTTP framework: routing, middleware, request and response helpers 03-02 onwards
zod Validation through declarative schemas with type inference 03-04
better-sqlite3 Synchronous SQLite client, very fast, no database server 03-05
jsonwebtoken Signing and verifying JWTs 03-06
bcrypt Password hashing with salt and a configurable cost 03-06
dotenv Loads variables from a .env file into process.env right now

And the development ones:

npm install --save-dev supertest eslint prettier eslint-config-prettier
Package What it does Where we will use it
supertest Fires HTTP requests at the Express app without opening a port 03-08
eslint Detects errors and bad practice before you run anything ongoing
prettier Formats code consistently and automatically ongoing
eslint-config-prettier Switches off the ESLint rules that clash with Prettier ongoing

Two clarifications about choices that stand out:

  • express@4 and not 5. Express 5 is now stable, but the vast majority of the code, documentation and answers you will find out there are for 4. Besides, 4 has a very instructive shortcoming —it does not catch errors thrown by async functions— which forces us to genuinely understand error handling in 03-07. We will say at the right moment what changes with 5.
  • bcrypt and not bcryptjs. bcrypt is a native extension (it is compiled on install) and it is faster. If its compilation fails on your machine —which tends to happen on Windows without build tools—, bcryptjs is a pure JavaScript substitute with the same API: npm install bcryptjs and change the import.

We do not need nodemon: node --watch does the same job as of Node 18. Nor cors, helmet or express-rate-limit, which will arrive in module 4 when it is time to harden the API.

  1. The project's npm scripts

Scripts are the interface available to anyone who lands on the repository. The first thing a new developer does is look at scripts to find out how the project is started.

"scripts": {
  "dev": "node --watch src/server.js",
  "start": "node src/server.js",
  "test": "node --test tests/",
  "lint": "eslint src/ tests/",
  "format": "prettier --write \"**/*.{js,json,md}\""
}
Script Command What it does
npm run dev node --watch Starts and restarts by itself when you save a file
npm start node Starts without watching. This is the production one
npm test node --test Runs every test in tests/
npm run lint eslint Analyses the code looking for errors
npm run format prettier --write Reformats every file

A confusing detail about npm: start and test are "well-known" scripts and are invoked without run (npm start, npm test); the rest need run (npm run dev). Both work with run, so when in doubt, type npm run.

  1. The folder structure

Here lies the architectural decision behind the whole module. We are going to organise the code by layers, with one clear responsibility per folder:

mkdir -p src/{routes,controllers,services,repositories,schemas,middleware,config,errors}
mkdir -p tests/{unit,integration,helpers}
mkdir -p migrations
Folder / file Responsibility Lesson
src/server.js Starts the process: reads the port and calls listen() 03-02
src/app.js Builds the Express application and mounts the middleware 03-02
src/routes/ Declares which URI and method invokes which controller 03-02
src/controllers/ Translates HTTP ↔ domain: reads req, calls the service, writes res 03-03
src/services/ Business logic. It does not know HTTP exists 03-03
src/repositories/ Data access. The only thing that knows about the database 03-03 / 03-05
src/schemas/ Zod schemas for input validation 03-04
src/middleware/ Cross-cutting pieces: validation, authentication, errors 03-04 onwards
src/config/ Reading and validating the environment configuration 03-01
src/errors/ ApiError and its factories 03-07
migrations/ Versioned .sql files that create the schema 03-05
tests/ Unit tests, integration tests and supporting utilities 03-08

Why so many folders for a small API? Because each boundary solves a real problem, and every one of them pays off inside this very module:

  • Routes separated from controllers: the URI map from 02-02 can be read at a glance in a single file, with no logic in the way.
  • Controllers separated from services: the service never touches req or res, so it can be tested without starting a server (03-08) and reused from a command-line script or a scheduled job.
  • Services separated from repositories: in 03-05 we replace the in-memory store with SQLite without touching a single line of the controllers or the services. That is the proof that the boundary is worth it.
  • Middleware on its own: validation, authentication and errors are cross-cutting; if they live inside the routes, they end up duplicated in twenty places.

The flow of a request, from the outside in, will always be the same:

graph LR
  C[Client] --> R[routes/]
  R --> M[middleware/]
  M --> CT[controllers/]
  CT --> S[services/]
  S --> RP[repositories/]
  RP --> DB[(Data)]

And the rule that keeps it healthy: the arrows never point backwards. A repository does not call a service, and a service imports nothing from Express.

  1. Configuration with environment variables

The port, the database path and the JWT signing secret cannot be written into the code. They change between your laptop, the test environment and production, and some of them are secrets.

The de facto standard is the Twelve-Factor App methodology: configuration lives in the environment, not in the code. In Node it is read via process.env.

We create the .env file at the root:

# .env — local configuration. NOT committed to Git.
NODE_ENV=development
PORT=3000
BASE_URL=http://localhost:3000
DATABASE_PATH=./data/aroma.db
JWT_SECRET=replace-this-with-a-long-random-string-in-production
JWT_EXPIRY=1h

And .env.example, which is committed, with the same keys but no real values:

# .env.example — template. Copy it to .env and fill in the values.
NODE_ENV=development
PORT=3000
BASE_URL=http://localhost:3000
DATABASE_PATH=./data/aroma.db
JWT_SECRET=
JWT_EXPIRY=1h

This second file is executable documentation: whoever clones the repository runs cp .env.example .env, fills it in and starts up. Without it, the only way to know which variables are needed is to read all the code or wait for it to blow up.

14.1. src/config/environment.js

Reading process.env.PORT scattered across the whole codebase is a bad idea: you do not know which variables exist, there are no centralised default values and a typo produces a silent undefined. We centralise the reading in a single module that also validates at start-up and fails loudly if something is missing:

// src/config/environment.js
import 'dotenv/config';

/**
 * Reads a required variable. If it does not exist, abort the start-up.
 * Failing at start-up is far better than failing on request number 500.
 */
function required(name) {
  const value = process.env[name];
  if (value === undefined || value.trim() === '') {
    throw new Error(`Missing required environment variable: ${name}`);
  }
  return value;
}

/** Reads an optional variable with a default value. */
function optional(name, defaultValue) {
  const value = process.env[name];
  return value === undefined || value.trim() === '' ? defaultValue : value;
}

/** Reads a numeric variable and checks that it really is one. */
function numeric(name, defaultValue) {
  const value = optional(name, String(defaultValue));
  const number = Number(value);
  if (!Number.isInteger(number)) {
    throw new Error(`The variable ${name} must be an integer, and its value is "${value}"`);
  }
  return number;
}

export const environment = {
  nodeEnv: optional('NODE_ENV', 'development'),
  port: numeric('PORT', 3000),
  baseUrl: optional('BASE_URL', 'http://localhost:3000'),
  databasePath: optional('DATABASE_PATH', './data/aroma.db'),
  jwtSecret: required('JWT_SECRET'),
  jwtExpiry: optional('JWT_EXPIRY', '1h'),
};

// We freeze the object so that no part of the code can modify it on the fly:
// configuration is read once and does not change while the process runs.
Object.freeze(environment);

Line by line, the important bits:

  • import 'dotenv/config' runs dotenv for its side effect: it reads .env and dumps its keys into process.env. It must happen before any variable is read, which is why it sits on the first line of the first module that gets imported.
  • required() throws an error if the variable is missing. This is deliberate: we prefer the process not to start at all than to start with jwtSecret === undefined and sign insecure tokens for weeks.
  • numeric() converts and checks. Remember that every environment variable is a string: process.env.PORT is "3000", not 3000.
  • Object.freeze prevents accidental reassignments.

From now on, any file that needs configuration does import { environment } from '../config/environment.js' and uses environment.port. Nobody else touches process.env.

In 03-04 we will meet Zod and you will see that this file could be written with a five-line schema. We are deliberately keeping it in plain JavaScript: configuration is validated before anything else exists, and it is best that it does not depend on third parties.

A useful aside: since Node 20.6 there is node --env-file=.env src/server.js, which does dotenv's job without installing anything. We keep dotenv because it works the same way on any version and inside the testing tools.

  1. .gitignore and initialising Git

The .env file is never committed to Git. A secret pushed to a repository is considered compromised forever, even if you delete the commit: it stays in the history, in your colleagues' clones and in the platform's caches. Rotating it is the only fix, and it is far more expensive than writing a proper .gitignore.

# .gitignore

# Dependencies
node_modules/

# Local configuration and secrets
.env
.env.*.local

# Local database and its auxiliary files
data/
*.db
*.db-journal

# Logs and coverage
*.log
coverage/

# Operating system and editors
.DS_Store
.vscode/*
!.vscode/extensions.json

Notice that .env.example is not ignored (only .env is), which is exactly what we want. And neither is package-lock.json: that is a file that must travel with the project.

We initialise the repository:

git init
git add .
git commit -m "Skeleton of the Aroma Store API project"

Before committing, check with git status that .env does not appear in the list of added files. If it does, either the .gitignore is wrong or the file was already staged: git rm --cached .env takes it out of the index without deleting it from disk.

  1. Editor and working tools

16.1. Editor

Any editor will do, but VS Code is the most common one in the Node ecosystem and integrates directly with the course's tools. Recommended extensions: ESLint, Prettier - Code formatter and REST Client (it lets you fire requests from a .http file, very convenient for testing the API without leaving the editor).

16.2. ESLint

ESLint analyses the code without running it and spots unused variables, forgotten awaits or suspicious comparisons. Flat configuration (the modern one, eslint.config.js):

// eslint.config.js
import js from '@eslint/js';
import prettierConfig from 'eslint-config-prettier';

export default [
  js.configs.recommended,
  {
    languageOptions: {
      ecmaVersion: 2023,
      sourceType: 'module',
      globals: {
        process: 'readonly',
        console: 'readonly',
      },
    },
    rules: {
      'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
      'no-console': 'off',
      eqeqeq: ['error', 'always'],
    },
  },
  prettierConfig,
];
  • js.configs.recommended enables the set of sensible rules maintained by ESLint itself.
  • sourceType: 'module' tells ESLint that the code is ESM (consistent with "type": "module").
  • argsIgnorePattern: '^_' allows unused arguments if they start with an underscore. We will need it: Express's error middleware forces you to declare four parameters even if you never use the last one (03-07).
  • eqeqeq enforces === instead of ==.
  • prettierConfig goes last and switches off the style rules that would clash with Prettier.

It needs one more package: npm install --save-dev @eslint/js.

16.3. Prettier

Prettier has no opinion about whether the code is correct, only about how it looks, and it puts an end forever to arguments about quotes and commas:

{
  "semi": true,
  "singleQuote": true,
  "printWidth": 100,
  "trailingComma": "all"
}

Save this as .prettierrc.json. With the VS Code extension and "Format on Save" enabled, formatting stops being a topic of conversation.

16.4. Automatic reload

npm run dev

node --watch watches the files imported by the entry point and restarts the process when you save. Do not confuse it with node --watch-path, which watches a specific folder, nor with the browser's hot reload: here the whole process restarts, so in-memory state is lost. In 03-02 and 03-03 the coffees live in memory, and you will notice that a restart returns them to their initial value. That is normal and it goes away in 03-05 with SQLite.

16.5. curl and Postman

Throughout the module we will test with curl, which we already used in 01-03. It is universal, it copies and pastes into any documentation and it hides nothing:

curl -i http://localhost:3000/v1/coffees
  • -i includes the response headers, indispensable for checking Location, Link or Allow.
  • -v also shows the full request.
  • -s silences the progress bar, useful when piping into jq.

Postman is a graphical client with collections, environments and automated tests; it is an excellent tool and we devote the whole of lesson 05-01 to it. We do not need it here.

  1. Final check of the environment

The project still has no server —that is 03-02— but we can verify that the foundations hold. Create a temporary file check.js at the root:

// check.js — environment verification. Delete it once the lesson is over.
import { environment } from './src/config/environment.js';

console.log('Node.js:', process.version);
console.log('Environment:', environment.nodeEnv);
console.log('Configured port:', environment.port, typeof environment.port);
console.log('Database:', environment.databasePath);
console.log('Is there a JWT secret?:', environment.jwtSecret ? 'yes' : 'no');
node check.js
Node.js: v20.11.1
Environment: development
Configured port: 3000 number
Database: ./data/aroma.db
Is there a JWT secret?: yes

Notice the number: the conversion in numeric() has worked. Now try the failure path, which matters just as much: comment out the JWT_SECRET= line in your .env and run it again.

Error: Missing required environment variable: JWT_SECRET

The process dies immediately with a message that says exactly what is missing. That is the correct behaviour. Restore the .env and delete check.js.

State of the project at the end of the lesson:

aroma-store-api/
├── .env                 (ignored by Git)
├── .env.example
├── .gitignore
├── .nvmrc
├── .prettierrc.json
├── eslint.config.js
├── package.json
├── package-lock.json
├── node_modules/        (ignored by Git)
├── migrations/          (empty, filled in 03-05)
├── tests/
│   ├── helpers/
│   ├── integration/
│   └── unit/
└── src/
    ├── config/
    │   └── environment.js
    ├── controllers/
    ├── errors/
    ├── middleware/
    ├── repositories/
    ├── routes/
    ├── schemas/
    └── services/

Common Mistakes and Tips

1. ERR_REQUIRE_ESM or Cannot use import statement outside a module. "type": "module" is missing from package.json, or you are using require in an ESM project. Pick one system and stick to it across the whole project.

2. ERR_MODULE_NOT_FOUND with a file that does exist. In ESM the .js extension is mandatory in relative imports. ./services/coffees fails; ./services/coffees.js works.

3. Committing .env to Git. The most expensive mistake in this lesson. Write the .gitignore before the first git add. If it has already happened, deleting the file is not enough: the secret must be rotated.

4. Running npm install on the production server. It can install versions different from the ones you tested. Always use npm ci, which follows the lock to the letter.

5. Adding package-lock.json to .gitignore. This happens more often than you would think and it destroys all reproducibility. The lock is committed.

6. Installing the project's dependencies globally (npm install -g). Global installs are not recorded in package.json, so the project works on your machine and on no other. Only system tools are installed globally, and often not even those: npx runs them without installing.

7. Reading process.env from half a dozen files. Centralise it in config/environment.js. The day a variable is renamed, you touch one place instead of six.

8. Confusing the package.json version with the API version. "version": "1.0.0" belongs to the software artefact; /v1 belongs to the contract. They can advance separately: 1.4.7 still serves /v1 (02-07).

Tip: make a commit at the end of every lesson in this module. If something breaks in 03-05, git diff will tell you in thirty seconds what changed compared with the last good state.

Exercises

Exercise 1

Add a new variable to the configuration, MAX_PAGE_LIMIT, fixing the maximum number of items per page decided in 02-06. It must be numeric, optional, with a default value of 100, and start-up must fail if anyone writes a non-integer value or one greater than 1000. Modify .env, .env.example and src/config/environment.js.

Exercise 2

A colleague clones the repository and runs npm start. They get:

Error: Missing required environment variable: JWT_SECRET

Explain exactly what has happened, why it is the desirable behaviour and which two steps they must follow. Then propose an improvement to the error message so that it is self-explanatory.

Exercise 3

Classify these packages as dependencies or devDependencies and justify each one in a single sentence: express, supertest, dotenv, prettier, better-sqlite3, eslint, jsonwebtoken. Then state exactly what would happen if dotenv ended up in devDependencies by mistake and the project were deployed with npm ci --omit=dev.

Solutions

Solution 1

In .env and .env.example:

MAX_PAGE_LIMIT=100

In src/config/environment.js, one new function and one more field:

/** Reads a numeric variable and checks that it falls within a range. */
function numericInRange(name, defaultValue, minimum, maximum) {
  const number = numeric(name, defaultValue);
  if (number < minimum || number > maximum) {
    throw new Error(
      `The variable ${name} must be between ${minimum} and ${maximum}, and its value is ${number}`
    );
  }
  return number;
}

export const environment = {
  // ...previous fields...
  maxPageLimit: numericInRange('MAX_PAGE_LIMIT', 100, 1, 1000),
};

We reuse numeric(), which already rejects non-integer values, and we only add the range check. With MAX_PAGE_LIMIT=5000 the start-up aborts with an explicit message, which is exactly what we want: a badly configured pagination limit is an availability problem (02-06), not a minor detail.

Solution 2

What has happened: cloning only gave them .env.example, because .env is in .gitignore and does not travel with the repository. Without .env, dotenv finds nothing to load, process.env.JWT_SECRET is undefined and the required() function aborts the start-up.

Why it is desirable: it is a fail fast. The alternative would be starting with jwtSecret === undefined, and then jsonwebtoken would sign (or fail) on the first login request, in production, with a cryptic error, at three in the morning. Catching the problem at second zero, at start-up, with the exact name of the variable, is infinitely cheaper.

The two steps: cp .env.example .env and fill in JWT_SECRET with a long random string, for example with node -e "console.log(require('crypto').randomBytes(48).toString('hex'))".

Improved message:

throw new Error(
  `Missing required environment variable: ${name}. ` +
    `Copy .env.example to .env and fill it in (see README, section "Getting started").`
);

A good error message does not describe the problem: it describes the solution.

Solution 3

Package Block Justification
express dependencies The server does not start without it
supertest devDependencies It is only used in the tests in 03-08
dotenv dependencies It runs at start-up, in production too
prettier devDependencies It formats code; irrelevant at run time
better-sqlite3 dependencies It is the application's data access
eslint devDependencies Static analysis prior to deployment
jsonwebtoken dependencies It signs and verifies tokens on every authenticated request

If dotenv fell into devDependencies: npm ci --omit=dev would not install it, and import 'dotenv/config' in src/config/environment.js would throw ERR_MODULE_NOT_FOUND at start-up. The process would die before listening on the port. That is a loud, immediate failure, which is fortunate; the genuinely dangerous case is a package that is only imported on a rarely used route, because then the deployment looks fine and blows up days later. One nuance is worth pointing out: in real production there is often no .env at all —the variables are injected by the orchestrator— so some people argue that dotenv is development-only. If your start-up imports it unconditionally, it is a production dependency, full stop.

Conclusion

The environment is set up and, more importantly, every decision has been taken on merit rather than by inertia: Node.js 20 LTS managed with nvm so it can coexist with other projects, ESM instead of CommonJS with everything that implies for each import, dependencies with ^ ranges backed by a package-lock.json that is committed, npm ci reserved for CI and production, and a clear separation between what the application needs in order to run and what only we use while developing. You know what each of the ten installed packages brings and in which lesson it will show up.

Above all, you have fixed two things that shape the rest of the module. The first is the layered structure —routes, controllers, services, repositories— which in 03-05 will let us swap the in-memory store for SQLite without touching the logic, and in 03-08 will let us test the services without starting a server. The second is configuration in the environment: a .env that is never committed, a .env.example that documents, and a src/config/environment.js that validates at start-up and would rather not start at all than run half-broken.

We have the skeleton and not a single line that answers a request. In 03-02, Building a Basic Server, that changes: we will see what Express really is and what a middleware is with its (req, res, next) signature, we will separate app.js from server.js —a decision that looks arbitrary until the tests of 03-08 arrive—, we will mount the Router under /v1, making concrete the path versioning we decided on in 02-07, and we will return the first real coffees, cof_001 and cof_002, wrapped in the contract's {"data": [...], "total": n} envelope.

REST API Course: Principles of Designing and Developing RESTful APIs

Module 1: Introduction to RESTful APIs

Module 2: Designing RESTful APIs

Module 3: Building RESTful APIs

Module 4: Best Practices and Security

Module 5: Tools and Frameworks

Module 6: Case Studies and Projects

© Copyright 2026. All rights reserved