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
- What we are going to build, and with what
- Node.js 20 LTS: installation and verification
- Version managers:
nvmandfnm - npm and
npx - Creating the project with
npm init - Anatomy of
package.json - ESM versus CommonJS and
"type": "module" - Semantic versioning in dependencies:
^and~ package-lock.json,npm civersusnpm installdependenciesversusdevDependencies- The course's dependencies, one by one
- The project's npm scripts
- The folder structure
- Configuration with environment variables
.gitignoreand initialising Git- Editor and working tools
- Final check of the environment
- 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.
- 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:
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, withoutnodemon.node:testandnode --test: built-in test runner (03-08).node --env-file: loading.envfiles without a library (from 20.6 onwards).
- Version managers:
nvm and fnm
nvm and fnmYou 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 zshDay-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 versionsA 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:
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.
- npm and
npx
npxInstalling 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.
- Creating the project with
npm init
npm initWe create the folder and initialise it:
mkdircreates the project directory. The kebab-case name is the npm convention.npm init -ygenerates apackage.jsonwith 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"
}
- Anatomy of
package.json
package.jsonpackage.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 |
- ESM versus CommonJS and
"type": "module"
"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 folderNote 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.
- Semantic versioning in dependencies:
^ and ~
^ 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.
package-lock.json, npm ci versus npm install
package-lock.json, npm ci versus npm installIf 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.jsonis 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.
dependencies versus devDependencies
dependencies versus devDependenciesnpm 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.
- The course's dependencies, one by one
We install the production ones first:
| 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:
| 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@4and 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 byasyncfunctions— which forces us to genuinely understand error handling in 03-07. We will say at the right moment what changes with 5.bcryptand notbcryptjs.bcryptis 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—,bcryptjsis a pure JavaScript substitute with the same API:npm install bcryptjsand change theimport.
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.
- 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.
- 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
reqorres, 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.
- 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=1hAnd .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=1hThis 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.envand dumps its keys intoprocess.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 withjwtSecret === undefinedand sign insecure tokens for weeks.numeric()converts and checks. Remember that every environment variable is a string:process.env.PORTis"3000", not3000.Object.freezeprevents 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.
.gitignore and initialising Git
.gitignore and initialising GitThe .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.jsonNotice 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:
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.
- 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.recommendedenables 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).eqeqeqenforces===instead of==.prettierConfiggoes 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:
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
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:
-iincludes the response headers, indispensable for checkingLocation,LinkorAllow.-valso shows the full request.-ssilences the progress bar, useful when piping intojq.
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.
- 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.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.
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:
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:
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
- What Is an API?
- History and Evolution of APIs
- HTTP Fundamentals for APIs
- Basic Principles of REST
- The Richardson Maturity Model and HATEOAS
- REST vs. SOAP
- REST Compared with GraphQL, gRPC and Webhooks
Module 2: Designing RESTful APIs
- RESTful API Design Principles
- Resources and URIs
- HTTP Methods
- HTTP Status Codes
- Representations, Headers and Content Negotiation
- Filtering, Sorting, Pagination and Search
- API Versioning
- API Documentation
Module 3: Building RESTful APIs
- Setting Up the Development Environment
- Building a Basic Server
- Handling Requests and Responses
- Input Data Validation
- Persistence and the Data Access Layer
- Authentication and Authorisation
- Error Handling
- Testing and Validation
Module 4: Best Practices and Security
- API Design Best Practices
- Security in RESTful APIs
- OAuth 2.0 and OpenID Connect in Practice
- Rate Limiting and Throttling
- CORS and Security Policies
- HTTP Caching and Performance
- Observability: Logs, Metrics and Traces
Module 5: Tools and Frameworks
- Postman for API Testing
- Swagger and OpenAPI for Documentation
- Popular Frameworks for RESTful APIs
- Contracts, Mocks and Automated API Testing
- Continuous Integration and Deployment
- API Gateways and Developer Portals
