Every time node --watch restarts the server, the coffees go back to being two and order ord_5001 comes back to life as pending_payment. The in-memory arrays have served us well for building the contract without distractions, but they are not a database: they do not survive a restart, they cannot guarantee that deducting stock across three items happens atomically, and they do not scale beyond a few thousand records. Today we replace them with SQLite via better-sqlite3 behind the repository pattern, and we will do it while keeping the promise we made in 03-01: not a single line of the controllers or the services is touched. Along the way we will see Aroma Store's SQL schema with money stored in whole cents, versioned migrations, prepared statements and why they eliminate SQL injection, transactions for creating an order, optimistic concurrency control, the N+1 problem and cursor pagination implemented for real.
Contents
- What changes in the project today
- The repository pattern
- Which database, and why SQLite in this course
better-sqlite3: synchronous and free of surprises- Aroma Store's SQL schema
snake_casein the database andcamelCasein the JSON- Migrations: what they are and why they are versioned
- The migration script and the seed data
- The connection:
src/config/database.js src/repositories/coffees-sqlite.jswith prepared statements- Why prepared statements prevent SQL injection
- Safe dynamic queries: filters, sorting and pagination
- Transactions: creating an order while deducting stock
- Optimistic concurrency and
version_conflict - The N+1 problem when loading the items
- Cursor pagination for real
- ORMs: when they pay off
- Connections and graceful shutdown
- What changes in the project today
| File | Action |
|---|---|
migrations/001-initial.sql |
New: the complete schema |
migrations/apply.js |
New: applies the pending migrations |
migrations/seed.js |
New: the course's initial data |
src/config/database.js |
New: opens and configures the connection |
src/repositories/coffees-sqlite.js |
New: coffee repository over SQL |
src/repositories/orders-sqlite.js |
New: order repository over SQL |
src/repositories/index.js |
New: chooses which implementation is used |
src/services/coffees.js |
Modified: one import line |
src/services/orders.js |
Modified: one import line |
src/server.js |
Modified: closes the database on shutdown |
package.json |
Modified: migrate and seed scripts |
src/repositories/*-memory.js |
Kept: we will use them in the tests of 03-08 |
The fact that the list of modifications is so short is the outcome we were after. If the controllers had talked directly to the array, today we would be touching twenty files.
- The repository pattern
A repository is an object that offers operations over a collection of entities and completely hides where and how they are stored. The interface we have been using since 03-02:
| Method | What it does | Returns |
|---|---|---|
findAll(criteria) |
Query with filters, sorting and pagination | { items, total } |
findById(id) |
One specific entity | The entity or undefined |
create(data) |
Inserts | The created entity |
update(id, changes) |
Modifies | The updated entity or undefined |
remove(id) |
Soft delete | true / false |
The three properties that make it worthwhile:
- Substitutability.
coffees-memory.jsandcoffees-sqlite.jshonour the same interface. Switching from one to the other means changing oneimport, and in 03-08 we will use precisely the in-memory one as a test double. - Localised SQL. All the coffee SQL is in one file. When a query runs slowly, you know where to look; when a column changes, you know what to review.
- Domain vocabulary. The service asks for
findById('cof_001'), it does not run aSELECT. The business logic reads without technical noise.
And the limit of the pattern, said honestly: it does not make databases magically interchangeable. A query using SQLite's JSON_EXTRACT does not work the same way in MongoDB. What the repository guarantees is that, if it has to be rewritten, it is rewritten in one place.
graph LR C[controllers] --> S[services] S --> I[repositories/index.js] I --> M[coffees-memory.js] I --> Q[coffees-sqlite.js] Q --> D[(SQLite)]
- Which database, and why SQLite in this course
| SQLite | PostgreSQL | MongoDB | |
|---|---|---|---|
| Type | Relational, in a file | Relational, client-server | Document |
| Installation | None: it is a library | A separate server | A separate server |
| Write concurrency | One writer at a time | Many, with MVCC | Many |
| Transactions | Yes, ACID | Yes, ACID and very complete | Yes, since 4.0 |
| Schema | Rigid (with CHECK) |
Rigid and rich in types | Flexible |
| Type for money | INTEGER (cents) |
NUMERIC(10,2) or BIGINT |
Decimal128 or an integer |
| When to use it | Development, testing, small or embedded applications | The default choice in production | Loosely structured or highly variable data |
Why SQLite in the course: because there is nothing to install or configure, the database is a file you can delete and regenerate in a second, and the SQL we will write is standard SQL, so what you learn transfers. What you learn about prepared statements, transactions, indexes and N+1 applies exactly the same in PostgreSQL.
What would change with PostgreSQL. Little in the design and quite a lot in the mechanics: you would use the pg package with a connection pool, the calls would be asynchronous (await client.query(...)), the placeholders would be $1, $2 instead of ?, and transactions would be written with explicit BEGIN/COMMIT on a connection reserved from the pool. The types would gain precision: TIMESTAMPTZ for dates, NUMERIC available for money, JSONB with indexes for the tasting notes. The structure of the repository, however, would be the same; that is why starting with SQLite is not a dead end.
What would change with MongoDB. More: joins disappear, an order would store its items embedded in the document itself —which, curiously, eliminates the N+1 problem of section 15 at the root— and multi-document transactions exist but are used far less. It is a reasonable choice for catalogues with highly variable attributes; for orders, with their referential integrity and their totals, the relational model fits better.
better-sqlite3: synchronous and free of surprises
better-sqlite3: synchronous and free of surprisesThe oddity of better-sqlite3 is that its API is synchronous: db.prepare(sql).get(id) returns the row directly, with no await. In an ecosystem where everything touching input/output is asynchronous, this feels wrong. And it is correct:
- SQLite is not a server: there is no network and no latency. The query is a file read, often from the operating system's cache, taking microseconds.
- Wrapping that operation in a promise would add more overhead than the query itself.
- The resulting code is simpler and transactions are trivial: with no
awaitin the middle, there is no risk of another request slipping into the middle of a transaction.
The trade-off: if a query takes 200 ms, it blocks the event loop and no other request makes progress during that time. The practical consequence is that queries must be kept fast —with indexes, without full scans— and heavy reports must not run in the same process that serves the API. With PostgreSQL and pg, everything would be asynchronous and this problem would not exist.
Our services have been written synchronously since 03-03, so the migration is straightforward. If tomorrow we moved to PostgreSQL, we would have to turn the repository's, the service's and the controller's methods into async —and that is where the asyncHandler() wrapper we will see in 03-07 becomes essential.
- Aroma Store's SQL schema
-- migrations/001-initial.sql
PRAGMA foreign_keys = ON;
-- ---------------------------------------------------------------
-- COFFEES
-- ---------------------------------------------------------------
CREATE TABLE coffees (
id TEXT PRIMARY KEY, -- 'cof_001', a text key
name TEXT NOT NULL,
origin TEXT NOT NULL,
roast TEXT NOT NULL CHECK (roast IN ('light', 'medium', 'dark')),
price_cents INTEGER NOT NULL CHECK (price_cents > 0),
stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0),
tasting_notes TEXT NOT NULL DEFAULT '[]', -- serialised JSON array
description TEXT, -- allows NULL
created_at TEXT NOT NULL, -- ISO-8601 UTC with Z
active INTEGER NOT NULL DEFAULT 1, -- soft delete: 1 / 0
version INTEGER NOT NULL DEFAULT 1 -- optimistic concurrency
);
-- Indexes for the filters and sorting decided in 02-06
CREATE INDEX idx_coffees_origin ON coffees (origin);
CREATE INDEX idx_coffees_roast ON coffees (roast);
CREATE INDEX idx_coffees_price ON coffees (price_cents);
CREATE INDEX idx_coffees_active ON coffees (active);
-- ---------------------------------------------------------------
-- CUSTOMERS (properly populated in 03-06)
-- ---------------------------------------------------------------
CREATE TABLE customers (
id TEXT PRIMARY KEY, -- 'cus_842'
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, -- uniqueness guaranteed by the DB
password_hash TEXT NOT NULL, -- NEVER the password in the clear
role TEXT NOT NULL DEFAULT 'customer'
CHECK (role IN ('customer', 'employee', 'administrator', 'partner')),
created_at TEXT NOT NULL
);
-- ---------------------------------------------------------------
-- ORDERS
-- ---------------------------------------------------------------
CREATE TABLE orders (
id TEXT PRIMARY KEY, -- 'ord_5001'
customer_id TEXT NOT NULL REFERENCES customers(id),
status TEXT NOT NULL DEFAULT 'pending_payment'
CHECK (status IN ('pending_payment', 'paid', 'shipped')),
total_cents INTEGER NOT NULL CHECK (total_cents >= 0),
created_at TEXT NOT NULL,
paid_at TEXT,
shipped_at TEXT,
idempotency_key TEXT UNIQUE, -- the Idempotency-Key of 02-03
version INTEGER NOT NULL DEFAULT 1
);
-- Composite index: it serves both the filter by customer AND the cursor
-- pagination of section 16, which sorts by (created_at, id).
CREATE INDEX idx_orders_customer_date ON orders (customer_id, created_at DESC, id DESC);
CREATE INDEX idx_orders_status_date ON orders (status, created_at DESC, id DESC);
-- ---------------------------------------------------------------
-- ORDER ITEMS
-- ---------------------------------------------------------------
CREATE TABLE order_items (
order_id TEXT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
coffee_id TEXT NOT NULL REFERENCES coffees(id),
coffee_name TEXT NOT NULL, -- FROZEN copy of the name
quantity INTEGER NOT NULL CHECK (quantity > 0),
price_cents INTEGER NOT NULL, -- FROZEN price from the day of purchase
PRIMARY KEY (order_id, coffee_id) -- a coffee cannot repeat within an order
);
CREATE INDEX idx_order_items_order ON order_items (order_id);
-- ---------------------------------------------------------------
-- REVIEWS
-- ---------------------------------------------------------------
CREATE TABLE reviews (
id TEXT PRIMARY KEY, -- 'rev_101'
coffee_id TEXT NOT NULL REFERENCES coffees(id) ON DELETE CASCADE,
customer_id TEXT NOT NULL REFERENCES customers(id),
rating INTEGER NOT NULL CHECK (rating BETWEEN 1 AND 5),
comment TEXT,
status TEXT NOT NULL DEFAULT 'pending_moderation'
CHECK (status IN ('pending_moderation', 'published', 'rejected')),
created_at TEXT NOT NULL
);
CREATE INDEX idx_reviews_coffee ON reviews (coffee_id, status);Schema decisions worth justifying one by one:
price_cents INTEGER. It is the decision of 02-05 carried into the database. A REAL (floating point) would be a design error: SELECT SUM(price) FROM ... would accumulate rounding errors and the order total could differ by cents from the one shown to the customer. With integers, the sum is exact by definition. In PostgreSQL you could use NUMERIC(10,2), which is also exact; the integer-cents approach works in any engine.
Text primary keys (cof_001). It breaks the habit of INTEGER AUTOINCREMENT, and in exchange it gives us what we decided in 02-02: opaque identifiers that say what type they are and that do not leak how many records exist. The cost is a slightly larger index and string comparisons instead of integer ones, which is irrelevant at this scale.
CHECK on the enums. Zod's validation (03-04) already rejects an invalid roast, but the CHECK protects against every way in: an import script, a manual fix with sqlite3, a future bug. It is defence in depth, and it does not replace validation at the edge, it complements it: a CHECK produces a database error, not a friendly 400.
tasting_notes as JSON text. SQLite has no array type. We store '["citrus","floral"]' and parse it when reading. It is acceptable because we never filter by a specific note with SQL; ?q= searches inside the text. If tomorrow we needed to filter by note, the right answer would be a coffee_notes table with one row per note. In PostgreSQL we would use JSONB with a GIN index and there would be no debate.
active INTEGER. SQLite has no boolean type: 1 and 0 are used. And there is an important practical detail: better-sqlite3 rejects JavaScript booleans as parameters; you must pass 1 or 0 explicitly. It is a mistake that surprises everyone the first time.
Dates as TEXT in ISO-8601 UTC. SQLite has no date type either. The ISO-8601 format has an extremely valuable property: alphabetical order coincides with chronological order, so ORDER BY created_at DESC works correctly over text. That is exactly what makes the cursor pagination of section 16 possible.
version INTEGER. It is the counter for the optimistic concurrency control of section 14.
snake_case in the database and camelCase in the JSON
snake_case in the database and camelCase in the JSONWe have three vocabularies and you need to know where each translation happens:
| Layer | Convention | Example |
|---|---|---|
| Database | snake_case, internal units |
price_cents |
| Internal model (repository and above) | camelCase, internal units |
priceCents |
| Public representation (JSON) | camelCase, public units |
priceEuros |
And the two boundaries:
- DB → internal model: in the repository, inside a
toModel(row)function. - Internal model → JSON: in the mapper of 03-03 (
coffeeToRepresentation).
We could have saved ourselves the first translation by using camelCase for the columns too. We do not, because snake_case is the universal convention in SQL —tools, dumps and DBAs expect it— and because in PostgreSQL identifiers with uppercase letters have to be quoted in every query. Having the translation live in a single repository function makes the cost negligible.
- Migrations: what they are and why they are versioned
A migration is a schema change written as a SQL file: numbered, immutable and versioned in Git.
The problem it solves is this: you create the coffees table on your laptop by running SQL by hand. Your colleague does not have it. The test server has an old version without the version column. Production has yet another one. Nobody knows which schema is where, and applying a change becomes a manual ritual with minutes taken.
The rules of migrations, which admit no exceptions:
- Numbered and ordered:
001-initial.sql,002-add-discounts.sql. - Immutable: an applied migration is never edited. If it was wrong, you fix it with a new one.
- Versioned in Git, alongside the code that needs them.
- Recorded: the database itself keeps track of which ones have been applied.
That fourth rule requires one more table, which we add at the start of the migrations file:
- The migration script and the seed data
// migrations/apply.js
import { readdirSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { database } from '../src/config/database.js';
const folder = dirname(fileURLToPath(import.meta.url));
// Control table: which migrations have already been applied.
database.exec(`
CREATE TABLE IF NOT EXISTS migrations (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL
);
`);
const alreadyApplied = new Set(
database.prepare('SELECT name FROM migrations').all().map((row) => row.name)
);
// They are sorted by name: hence the numeric prefix with leading zeros.
const files = readdirSync(folder)
.filter((f) => f.endsWith('.sql'))
.sort();
let applied = 0;
for (const file of files) {
if (alreadyApplied.has(file)) {
console.log(`- ${file}: already applied`);
continue;
}
const sql = readFileSync(join(folder, file), 'utf8');
// Each migration is applied INSIDE A TRANSACTION: if the file has ten
// statements and the seventh fails, no half-built schema is left behind.
const run = database.transaction(() => {
database.exec(sql);
database
.prepare('INSERT INTO migrations (name, applied_at) VALUES (?, ?)')
.run(file, new Date().toISOString());
});
run();
console.log(`+ ${file}: applied`);
applied++;
}
console.log(`\n${applied} migration(s) applied. Schema up to date.`);And the seeding, which loads the data we work with throughout the course:
// migrations/seed.js
import { database } from '../src/config/database.js';
const insertCoffee = database.prepare(`
INSERT INTO coffees (id, name, origin, roast, price_cents, stock,
tasting_notes, description, created_at, active, version)
VALUES (@id, @name, @origin, @roast, @priceCents, @stock,
@tastingNotes, @description, @createdAt, 1, 1)
`);
const coffees = [
{
id: 'cof_001',
name: 'Ethiopia Yirgacheffe',
origin: 'Ethiopia',
roast: 'light',
priceCents: 1450,
stock: 120,
tastingNotes: JSON.stringify(['citrus', 'floral', 'black tea']),
description: null,
createdAt: '2026-01-15T09:00:00Z',
},
{
id: 'cof_002',
name: 'Colombia Huila',
origin: 'Colombia',
roast: 'medium',
priceCents: 1290,
stock: 80,
tastingNotes: JSON.stringify(['chocolate', 'caramel', 'nutty']),
description: null,
createdAt: '2026-01-20T11:15:00Z',
},
];
// The whole seeding, in a single transaction.
const seed = database.transaction(() => {
database.prepare('DELETE FROM order_items').run();
database.prepare('DELETE FROM orders').run();
database.prepare('DELETE FROM coffees').run();
database.prepare('DELETE FROM customers').run();
for (const coffee of coffees) insertCoffee.run(coffee);
database
.prepare(
`INSERT INTO customers (id, name, email, password_hash, role, created_at)
VALUES ('cus_842', 'Marta García', '[email protected]', 'pending-until-03-06',
'customer', '2026-01-10T08:00:00Z')`
)
.run();
database
.prepare(
`INSERT INTO orders (id, customer_id, status, total_cents, created_at, version)
VALUES ('ord_5001', 'cus_842', 'pending_payment', 2900, '2026-03-14T10:30:00Z', 1)`
)
.run();
database
.prepare(
`INSERT INTO order_items (order_id, coffee_id, coffee_name, quantity, price_cents)
VALUES ('ord_5001', 'cof_001', 'Ethiopia Yirgacheffe', 2, 1450)`
)
.run();
});
seed();
console.log('Seed data loaded: 2 coffees, 1 customer, 1 order.');With the scripts in package.json:
"scripts": {
"migrate": "node migrations/apply.js",
"seed": "node migrations/seed.js",
"db:reset": "rm -f data/aroma.db && npm run migrate && npm run seed"
}+ 001-initial.sql: applied 1 migration(s) applied. Schema up to date. Seed data loaded: 2 coffees, 1 customer, 1 order.
Seeding and migration are different things. A migration changes the structure and runs in every environment, production included. Seeding inserts data and only makes sense in development and testing. Mixing them —an INSERT inside a migration— is a classic mistake that ends up putting test coffees into production.
- The connection:
src/config/database.js
src/config/database.js// src/config/database.js
import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { environment } from './environment.js';
// Make sure the folder holding the .db file exists
if (environment.databasePath !== ':memory:') {
mkdirSync(dirname(environment.databasePath), { recursive: true });
}
export const database = new Database(environment.databasePath);
// --- PRAGMAs: engine configuration ---
// WAL (Write-Ahead Logging): lets reads avoid blocking writes and vice
// versa. It is the difference between a toy SQLite and one usable by an
// API with any concurrency.
database.pragma('journal_mode = WAL');
// Foreign keys are DISABLED by default in SQLite, for historical
// compatibility. Without this line, REFERENCES is never checked.
database.pragma('foreign_keys = ON');
// If another connection holds the database locked, wait up to 5 s before
// failing with SQLITE_BUSY, instead of giving up instantly.
database.pragma('busy_timeout = 5000');
/** Closes the connection. Called by the graceful shutdown in server.js. */
export function closeDatabase() {
database.close();
}The foreign_keys = ON line is the one that prevents the most grief: without it, SQLite happily accepts an order item pointing at a nonexistent coffee and the problem surfaces months later as orphaned data.
src/repositories/coffees-sqlite.js with prepared statements
src/repositories/coffees-sqlite.js with prepared statements// src/repositories/coffees-sqlite.js
import { database } from '../config/database.js';
/** Database row → the application's internal model. */
function toModel(row) {
if (!row) return undefined;
return {
id: row.id,
name: row.name,
origin: row.origin,
roast: row.roast,
priceCents: row.price_cents,
stock: row.stock,
tastingNotes: JSON.parse(row.tasting_notes),
description: row.description,
createdAt: row.created_at,
active: row.active === 1,
version: row.version,
};
}
// --- Prepared statements ---
// They are prepared ONCE, when the module loads, and reused on every
// request. SQLite parses the SQL and computes the execution plan only the
// first time; after that, running means substituting parameters and going.
const statements = {
byId: database.prepare('SELECT * FROM coffees WHERE id = ? AND active = 1'),
insert: database.prepare(`
INSERT INTO coffees (id, name, origin, roast, price_cents, stock,
tasting_notes, description, created_at, active, version)
VALUES (@id, @name, @origin, @roast, @priceCents, @stock,
@tastingNotes, @description, @createdAt, 1, 1)
`),
update: database.prepare(`
UPDATE coffees
SET name = @name, origin = @origin, roast = @roast,
price_cents = @priceCents, stock = @stock,
tasting_notes = @tastingNotes, description = @description,
version = version + 1
WHERE id = @id AND active = 1
`),
softDelete: database.prepare(
'UPDATE coffees SET active = 0, version = version + 1 WHERE id = ? AND active = 1'
),
nextNumber: database.prepare(
"SELECT COALESCE(MAX(CAST(SUBSTR(id, 5) AS INTEGER)), 0) + 1 AS next FROM coffees"
),
};
export const coffeeRepository = {
findById(id) {
return toModel(statements.byId.get(id));
},
create(data) {
const number = statements.nextNumber.get().next;
const record = {
id: `cof_${String(number).padStart(3, '0')}`,
name: data.name,
origin: data.origin,
roast: data.roast,
priceCents: data.priceCents,
stock: data.stock,
tastingNotes: JSON.stringify(data.tastingNotes ?? []),
description: data.description ?? null,
createdAt: new Date().toISOString(),
};
statements.insert.run(record);
return this.findById(record.id);
},
update(id, changes) {
const current = this.findById(id);
if (!current) return undefined;
// We merge the current state with the changes: that way the same SQL
// serves PUT (every field arrives) and PATCH (only some do).
const merged = { ...current, ...changes };
statements.update.run({
id,
name: merged.name,
origin: merged.origin,
roast: merged.roast,
priceCents: merged.priceCents,
stock: merged.stock,
tastingNotes: JSON.stringify(merged.tastingNotes),
description: merged.description,
});
return this.findById(id);
},
remove(id) {
// .run() returns, among other things, how many rows were modified.
return statements.softDelete.run(id).changes > 0;
},
// findAll() is implemented in section 12: it needs dynamic SQL.
};About generating identifiers: nextNumber computes the existing maximum and adds one. It works because SQLite serialises writes, and we will wrap it in the same transaction as the insert. In PostgreSQL we would use a SEQUENCE, and in a distributed system the usual answer would be a ULID or a prefixed UUID (cof_01HQ...), generated without querying anything and revealing nothing about business volume.
- Why prepared statements prevent SQL injection
Compare these two ways of searching by origin:
// DANGEROUS: string concatenation. NEVER do this.
const sql = `SELECT * FROM coffees WHERE origin = '${origin}'`;
database.prepare(sql).all();
// SAFE: a placeholder.
database.prepare('SELECT * FROM coffees WHERE origin = ?').all(origin);With the first one, if the client sends ?origin=x' OR '1'='1, the SQL that runs is:
It returns the entire catalogue. And with a bit more imagination —x'; DROP TABLE orders; --— it destroys data. That technique has topped the security risk lists for twenty years and it still works in real applications.
Why the ? version is immune. It is not that it "escapes the quotes": it is that the SQL and the data travel along separate paths. The statement is parsed and compiled before the values are known, producing an execution plan with holes in it. When it runs, each value is dropped into its hole as data, already inside the engine, without going through the parser again. By definition, a value cannot become an instruction: x' OR '1'='1 is looked up literally as an origin, finds nothing, and returns zero rows. The engine does not see a special quote; it sees a twenty-character string.
Three practical consequences:
- Placeholders for every value. Always. Even if the data "comes from inside", even if it is a number, even if you are sure.
- Placeholders only work for values, not for table names, column names or
ASC/DESC. Those are handled with allow-lists, as we will see right now. - The validation of 03-04 does not replace this. It is defence in depth: validation filters shapes, prepared statements make the attack impossible.
- Safe dynamic queries: filters, sorting and pagination
findAll() has to combine optional filters. The technique is to build the list of conditions and the list of parameters at the same time, never concatenating a value:
// src/repositories/coffees-sqlite.js (continued)
/**
* Sorting allow-list: public name → real column.
* It is MANDATORY because ORDER BY does not accept placeholders and its
* value would have to be concatenated. Only what comes out of here is concatenated.
*/
const SORTABLE_COLUMNS = {
name: 'name',
priceEuros: 'price_cents',
stock: 'stock',
createdAt: 'created_at',
id: 'id',
};
export function findCoffees(criteria = {}) {
const {
origin, roast, priceMinCents, priceMaxCents,
available, q, sort = [], limit = 20, offset = 0,
} = criteria;
const conditions = ['active = 1'];
const params = [];
if (origin !== undefined) {
conditions.push('LOWER(origin) = LOWER(?)');
params.push(origin);
}
if (roast !== undefined) {
conditions.push('roast = ?');
params.push(roast);
}
if (priceMinCents !== undefined) {
conditions.push('price_cents >= ?');
params.push(priceMinCents);
}
if (priceMaxCents !== undefined) {
conditions.push('price_cents <= ?');
params.push(priceMaxCents);
}
if (available !== undefined) {
conditions.push(available ? 'stock > 0' : 'stock = 0');
}
if (q !== undefined) {
// The wildcard goes in the PARAMETER, not in the SQL: it is still data.
conditions.push('(name LIKE ? OR origin LIKE ? OR tasting_notes LIKE ?)');
const pattern = `%${q}%`;
params.push(pattern, pattern, pattern);
}
const where = `WHERE ${conditions.join(' AND ')}`;
// --- Total: the SAME WHERE clause, with no ORDER BY and no LIMIT ---
const total = database
.prepare(`SELECT COUNT(*) AS n FROM coffees ${where}`)
.get(...params).n;
// --- ORDER BY built from the allow-list ---
const parts = sort
.map(({ field, descending }) => {
const column = SORTABLE_COLUMNS[field];
if (!column) return null; // field not allowed: ignored
return `${column} ${descending ? 'DESC' : 'ASC'}`;
})
.filter(Boolean);
parts.push('id ASC'); // stable tie-break (03-03)
const orderSql = `ORDER BY ${parts.join(', ')}`;
const rows = database
.prepare(`SELECT * FROM coffees ${where} ${orderSql} LIMIT ? OFFSET ?`)
.all(...params, limit, offset);
return { items: rows.map(toModel), total };
}Three critical points:
The total comes from a separate query with the same WHERE. It cannot be obtained from the paginated query, because LIMIT trims it. And it must carry exactly the same filters, or the client will miscalculate the number of pages. It is one extra COUNT(*) per request, and it is the price of offset pagination.
ORDER BY is built by concatenation, but only with values from SORTABLE_COLUMNS. The name the client sends is used as a lookup key in the map, never as SQL text. If it is not in the map, there is no column. It is impossible to inject anything through there.
The LIKE wildcard goes in the parameter. '%' || ? || '%' would also be valid, but putting the complete pattern in the parameter is clearer. What can never be done is LIKE '%${q}%'.
A performance note: LIKE '%text%' with a leading wildcard cannot use an index and forces a full table scan. With 200 coffees it is irrelevant; with 200,000 records you would need a full-text index (FTS5 in SQLite, tsvector in PostgreSQL) or a dedicated search engine. It is a known and accepted limitation of the ?q= we promised in 02-06.
Finally, the implementation selector:
// src/repositories/index.js
export { coffeeRepository } from './coffees-sqlite.js';
export { orderRepository } from './orders-sqlite.js';And in src/services/coffees.js one line changes:
// Before: import { coffeeRepository } from '../repositories/coffees-memory.js';
import { coffeeRepository } from '../repositories/index.js';There is the promise kept. Restart, try curl -s http://localhost:3000/v1/coffees | jq .total and check that everything answers the same as before… and that it now survives restarts.
- Transactions: creating an order while deducting stock
Creating an order is not one operation: it is several that must all happen or none at all.
- Check that each coffee exists and has stock.
- Insert the order row.
- Insert one item per coffee.
- Deduct the stock of each coffee.
Without a transaction, a failure at step 4 —because the stock no longer suffices, for instance— would leave a chargeable order with the inventory untouched. That is data corruption.
A transaction guarantees the four ACID properties; the one that matters here is atomicity: either every change is applied, or none is.
// src/repositories/orders-sqlite.js
import { database } from '../config/database.js';
const statements = {
insertOrder: database.prepare(`
INSERT INTO orders (id, customer_id, status, total_cents,
created_at, idempotency_key, version)
VALUES (@id, @customerId, 'pending_payment', @totalCents,
@createdAt, @idempotencyKey, 1)
`),
insertItem: database.prepare(`
INSERT INTO order_items (order_id, coffee_id, coffee_name, quantity, price_cents)
VALUES (@orderId, @coffeeId, @coffeeName, @quantity, @priceCents)
`),
// The stock deduction carries its own safety condition:
// 'AND stock >= ?' means the row is NOT updated if there is not enough.
deductStock: database.prepare(
'UPDATE coffees SET stock = stock - ?, version = version + 1 WHERE id = ? AND stock >= ?'
),
coffeeForOrder: database.prepare(
'SELECT id, name, price_cents, stock FROM coffees WHERE id = ? AND active = 1'
),
nextNumber: database.prepare(
"SELECT COALESCE(MAX(CAST(SUBSTR(id, 5) AS INTEGER)), 5000) + 1 AS next FROM orders"
),
};
/**
* Creates an order ATOMICALLY.
* database.transaction(fn) returns a function: calling it runs BEGIN,
* executes the body and COMMITs. If the body throws an exception, it
* ROLLBACKs automatically and rethrows the error.
*/
export const createOrderAtomically = database.transaction(
({ customerId, items, idempotencyKey = null }) => {
const id = `ord_${statements.nextNumber.get().next}`;
const createdAt = new Date().toISOString();
let totalCents = 0;
const resolvedItems = items.map((item) => {
const coffee = statements.coffeeForOrder.get(item.coffeeId);
if (!coffee) {
// Throwing here triggers a ROLLBACK: nothing done before persists.
throw Object.assign(new Error(`The coffee '${item.coffeeId}' does not exist`), {
domainCode: 'coffee_not_found',
});
}
if (coffee.stock < item.quantity) {
throw Object.assign(
new Error(`Only ${coffee.stock} units of '${coffee.name}' remain`),
{ domainCode: 'insufficient_stock' }
);
}
totalCents += coffee.price_cents * item.quantity;
return {
orderId: id,
coffeeId: coffee.id,
coffeeName: coffee.name, // frozen
quantity: item.quantity,
priceCents: coffee.price_cents, // frozen
};
});
statements.insertOrder.run({
id, customerId, totalCents, createdAt, idempotencyKey,
});
for (const item of resolvedItems) {
statements.insertItem.run(item);
// Second stock check, this time inside the UPDATE itself.
const result = statements.deductStock.run(
item.quantity, item.coffeeId, item.quantity
);
if (result.changes === 0) {
throw Object.assign(
new Error(`Not enough stock of '${item.coffeeId}'`),
{ domainCode: 'insufficient_stock' }
);
}
}
return id;
}
);What happens if it fails halfway. Suppose the second item runs out of stock because another customer got in 30 milliseconds earlier. The exception propagates, better-sqlite3 runs ROLLBACK and the state of the database goes back exactly to what it was: there is no order, there are no items, and the stock of the first coffee has not been deducted. The service translates that error into 409 insufficient_stock and the client can retry. Without a transaction we would have an incomplete order, an orphaned item and stock deducted for an order that never existed.
Why the stock is checked twice. The first read (coffeeForOrder) is there to give a useful error message with the coffee's name and the units left. The second one, the AND stock >= ? condition inside the UPDATE, is what genuinely protects: it is a check and a write in a single atomic statement, so there is no window between reading and writing. This pattern —check in the WHERE of the update and inspect changes— is the correct way of stopping two simultaneous requests from selling the same last bag of coffee.
One better-sqlite3 detail that must be respected: there can be no await inside a transaction. Since its API is synchronous, we do not need one; but if you mixed an asynchronous call in there, the transaction would close early. With PostgreSQL, by contrast, the whole block would be async and you would have to reserve a connection from the pool for the entire transaction.
- Optimistic concurrency and
version_conflict
version_conflictAnother concurrency problem, distinct from stock: the lost update.
sequenceDiagram participant A as Employee A participant API participant DB as Database A->>API: GET /v1/coffees/cof_001 (price 14.50, version 3) Note over API: Employee B reads the same thing A->>API: PATCH price 15.90 API->>DB: UPDATE ... version 3 → 4 Note over API: B sends PATCH stock 200 with version 3 API->>DB: UPDATE ... WHERE version = 3 DB-->>API: 0 rows modified API-->>A: 409 version_conflict
Without any control, the second PATCH would trample the new price with the one B read a minute ago, and nobody would notice. Optimistic locking detects it: every update demands the version the client read.
// src/repositories/coffees-sqlite.js (added)
const updateWithVersion = database.prepare(`
UPDATE coffees
SET name = @name, origin = @origin, roast = @roast,
price_cents = @priceCents, stock = @stock,
tasting_notes = @tastingNotes, description = @description,
version = version + 1
WHERE id = @id AND active = 1 AND version = @version
`);
/**
* Updates only if the version matches.
* @returns the updated coffee, or null if there was a version conflict.
*/
export function updateCoffeeWithVersion(id, changes, expectedVersion) {
const current = coffeeRepository.findById(id);
if (!current) return undefined;
const merged = { ...current, ...changes };
const result = updateWithVersion.run({
id,
version: expectedVersion,
name: merged.name,
origin: merged.origin,
roast: merged.roast,
priceCents: merged.priceCents,
stock: merged.stock,
tastingNotes: JSON.stringify(merged.tastingNotes),
description: merged.description,
});
// 0 rows modified while the resource exists = somebody changed it first.
return result.changes === 0 ? null : coffeeRepository.findById(id);
}And the service translates that null into the catalogue's 409 version_conflict:
{
"error": {
"code": "version_conflict",
"message": "The coffee 'cof_001' has been modified by someone else. Reload it and repeat your change.",
"details": []
}
}Two clarifications about the catalogue of 02-04. There, version_conflict was associated with 412 Precondition Failed, which is the right code when the client sends the condition in the If-Match header with an ETag —the standard HTTP mechanism, which we will see alongside conditional caching in 04-06. Here the condition travels as a version field in the body, with no conditional header, and then the appropriate code is 409 Conflict: the request was valid but it clashes with the resource's current state. Same problem, same catalogue code, two HTTP codes depending on how the condition is expressed. And a nuance about the word "optimistic": it is called that because it locks nothing; it assumes conflicts are rare and simply detects them. Pessimistic locking (SELECT ... FOR UPDATE) locks the row, and only pays off when conflicts are frequent.
- The N+1 problem when loading the items
This is how not to load orders together with their items:
// WRONG: 1 query for the orders + N queries, one per order.
const orders = database.prepare('SELECT * FROM orders LIMIT 20').all();
for (const order of orders) {
order.items = database
.prepare('SELECT * FROM order_items WHERE order_id = ?')
.all(order.id);
}With 20 orders that is 21 queries. The code looks innocent because the query is hidden inside the loop, and that is why the N+1 problem is so common. In SQLite, with the database on the local disk, the cost is small; with PostgreSQL on another machine and 2 ms of network per query, those 21 round trips are 42 ms of pure latency to return one page. And if the list were 100 orders, 200 ms.
The solution: a second query for all the items at once.
// src/repositories/orders-sqlite.js (continued)
function loadItemsFor(orders) {
if (orders.length === 0) return orders;
// One '?' placeholder per id: (?, ?, ?, ...). The SQL is generated, but
// the VALUES are still parameters: no data is ever concatenated.
const placeholders = orders.map(() => '?').join(', ');
const rows = database
.prepare(
`SELECT order_id, coffee_id, coffee_name, quantity, price_cents
FROM order_items
WHERE order_id IN (${placeholders})
ORDER BY order_id, coffee_id`
)
.all(...orders.map((o) => o.id));
// We group in memory: O(n) cost, with no extra queries.
const byOrder = new Map();
for (const row of rows) {
if (!byOrder.has(row.order_id)) byOrder.set(row.order_id, []);
byOrder.get(row.order_id).push({
coffeeId: row.coffee_id,
name: row.coffee_name,
quantity: row.quantity,
priceCents: row.price_cents,
});
}
return orders.map((order) => ({ ...order, items: byOrder.get(order.id) ?? [] }));
}Two queries, whatever the number of orders. The alternative would be a single JOIN, which also works but returns the order repeated as many times as it has items and forces you to deduplicate. With two queries the code is clearer and the performance is equivalent.
A warning: IN (...) has a parameter limit (999 by default in SQLite). Since our pages are 100 items at most, it does not affect us; in a batch process you would have to chunk them.
- Cursor pagination for real
In 02-06 we decided that /v1/orders would use a cursor, and now you can see why. With OFFSET, the engine has to walk through and discard every preceding row:
-- To return 20 rows, SQLite reads and discards 100,000. Every time.
SELECT * FROM orders ORDER BY created_at DESC, id DESC LIMIT 20 OFFSET 100000;This is deep paging: page 1 is instantaneous and page 5,000 takes seconds. And it is also unstable: if a new order comes in while the client is paginating, everything shifts by one position and they see a repeated order.
The cursor solves both problems by remembering the last item seen instead of a position number:
-- No OFFSET: the index jumps straight to the cut-off point.
SELECT * FROM orders
WHERE (created_at, id) < (?, ?)
ORDER BY created_at DESC, id DESC
LIMIT 20;Tuple comparison (a, b) < (x, y) is standard SQL and means "a < x, or a = x and b < y". It is exactly the tie-break we needed, expressed as a single condition.
// src/repositories/orders-sqlite.js (continued)
/** Encodes the cursor in base64url so that it is OPAQUE (02-06). */
function encodeCursor(order) {
return Buffer.from(JSON.stringify({ d: order.created_at, i: order.id })).toString(
'base64url'
);
}
function decodeCursor(cursor) {
try {
const { d, i } = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
if (typeof d !== 'string' || typeof i !== 'string') return null;
return { date: d, id: i };
} catch {
return null; // tampered or corrupt cursor → 400 invalid_parameter
}
}
export function findOrdersByCursor({ customerId, status, cursor, limit = 20 }) {
const conditions = [];
const params = [];
if (customerId !== undefined) {
conditions.push('customer_id = ?');
params.push(customerId);
}
if (status !== undefined) {
conditions.push('status = ?');
params.push(status);
}
if (cursor !== undefined) {
const point = decodeCursor(cursor);
if (!point) return { invalidCursor: true };
conditions.push('(created_at, id) < (?, ?)');
params.push(point.date, point.id);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// We ask for ONE extra row: if it comes back, there is a next page.
const rows = database
.prepare(
`SELECT * FROM orders ${where}
ORDER BY created_at DESC, id DESC
LIMIT ?`
)
.all(...params, limit + 1);
const hasMore = rows.length > limit;
const page = hasMore ? rows.slice(0, limit) : rows;
return {
items: loadItemsFor(page).map(orderToModel),
nextCursor: hasMore ? encodeCursor(page[page.length - 1]) : null,
};
}Link: <http://localhost:3000/v1/orders?limit=20&cursor=eyJkIjoiMjAyNi0wMy0xNFQxMDozMDowMFoiLCJpIjoib3JkXzUwMDEifQ>; rel="next"Three decisions to underline. The cursor is opaque: base64 of an internal JSON object. It is not encrypted —anyone can decode it— but it is explicitly "none of your business": the contract of 02-06 forbids building it by hand, and that lets us change its contents without breaking anyone. Asking for limit + 1 rows is the standard trick for knowing whether there is more without an extra COUNT. And cursor pagination offers neither a total nor a jump to the last page: that is the price you pay, which is why the contract reserved the cursor for /v1/orders —where the history is huge and you only ever move forward— and left offset pagination on /v1/coffees, where the catalogue is small and knowing the total matters.
The index idx_orders_customer_date (customer_id, created_at DESC, id DESC) was built for exactly this query: the engine locates the cut-off point with a tree lookup and reads 21 consecutive rows. The cost does not depend on how far away the page is.
- ORMs: when they pay off
| Tool | Approach | Notes |
|---|---|---|
| Prisma | Its own schema + a generated client | Excellent experience and types; generates its own migrations |
| Drizzle | SQL with types, very thin | Close to SQL; light at run time |
| Sequelize | Classic active-record ORM | A veteran; abstracts a great deal |
| TypeORM | ORM with decorators | Popular with NestJS (05-03) |
| Knex | Query builder, not an ORM | Composes SQL without hiding it |
In favour: less boilerplate, built-in migrations, typed results, portability between engines, and relations loaded without writing the JOIN yourself.
Against: one more abstraction to learn and debug; generated queries that surprise you; the N+1 problem hidden —an .items that looks like a property access and in reality fires a query—; and difficulty expressing advanced SQL.
The criterion: if your application has many entities with similar relationships and repetitive CRUD, an ORM saves weeks. If it has few entities and demanding queries, direct SQL behind a repository —what we have done— is simpler and faster. And with the repository pattern, the decision is reversible: adopting Prisma tomorrow would mean rewriting the files in src/repositories/, and nothing else.
- Connections and graceful shutdown
SQLite is a file and needs no pool: one single connection per process, opened at start-up. WAL mode allows concurrent reads alongside a write, and busy_timeout handles collisions between writers.
With PostgreSQL a pool would be necessary: opening a TCP connection costs tens of milliseconds, so a reusable set is kept alive (new Pool({ max: 10 })). That brings its own problems —pool exhaustion from connections that are never returned, transactions that must run on a reserved connection— which we do not have here.
What we do have to do is close the database on shutdown. We update src/server.js:
// src/server.js (modified)
import { app } from './app.js';
import { environment } from './config/environment.js';
import { closeDatabase } from './config/database.js';
const server = app.listen(environment.port, () => {
console.log(`Aroma Store API listening on ${environment.baseUrl}/v1`);
});
function shutdownGracefully(signal) {
console.log(`\nReceived signal ${signal}. Shutting down...`);
server.close(() => {
// The order matters: first we stop accepting and finish the in-flight
// requests, and only then do we close the database.
closeDatabase();
console.log('Server and database closed.');
process.exit(0);
});
}
process.on('SIGINT', () => shutdownGracefully('SIGINT'));
process.on('SIGTERM', () => shutdownGracefully('SIGTERM'));better-sqlite3's close() flushes the WAL into the main file and releases the lock. Without it, an abrupt shutdown leaves stray -wal and -shm files that SQLite knows how to recover, but there is no reason to depend on that.
Common Mistakes and Tips
1. Concatenating values into SQL. Guaranteed injection. Placeholders always, with no exceptions and no "but this value comes from inside".
2. Forgetting PRAGMA foreign_keys = ON. SQLite ignores them by default and you end up with items pointing at nonexistent orders.
3. Passing a JavaScript boolean as a parameter. better-sqlite3 rejects it. Convert to 1 / 0.
4. Storing money in REAL. The most expensive mistake on the list. INTEGER cents, or NUMERIC where it exists.
5. Editing an already-applied migration. Your database updates, your colleagues' do not, and the migrations record lies. Always fix with a new migration.
6. Mixing seeding and migration. The test data ends up in production.
7. Querying inside a loop. That is N+1. If you see a prepare inside a for, stop: there is almost always a version with IN (...).
8. Calculating the total from the paginated query. The COUNT goes with the same filters and without LIMIT.
9. Preparing statements inside the handler. Calling db.prepare() on every request wastes the SQL parsing. Prepare when the module loads and reuse.
Tip: when a query runs slowly, ask for the plan before touching anything: EXPLAIN QUERY PLAN SELECT .... If you see SCAN TABLE, an index is missing; if you see SEARCH TABLE ... USING INDEX, it is fine.
Exercises
Exercise 1
Write the migration 002-add-discounts.sql, adding a discount_percent column to coffees (integer, between 0 and 50, defaulting to 0). Explain why this migration is a backward-compatible change for the API's consumers (02-07) and what else would be needed for the field to appear in the JSON.
Exercise 2
Implement findReviewsForCoffee(coffeeId, { status, limit, offset }) in a new src/repositories/reviews-sqlite.js. It must return { items, total }, filter by status if one is given, sort by created_at DESC with a tie-break on id, and not return reviews for a nonexistent coffee without distinguishing that case from a coffee with no reviews. State which index in the schema supports it.
Exercise 3
Two employees open the record of cof_001 (version: 7) at the same time. The first changes the price to €15.90 and the second, thirty seconds later, changes the stock to 200 using the version 7 they read. Describe step by step what happens with updateCoffeeWithVersion, what the API answers the second one, and compare it with what would happen without version control. Then explain why this mechanism would not work for the order's stock deduction and what is used instead.
Solutions
Solution 1
-- migrations/002-add-discounts.sql
ALTER TABLE coffees ADD COLUMN discount_percent INTEGER NOT NULL DEFAULT 0;
-- SQLite does not allow adding a CHECK to an existing table with ALTER TABLE,
-- so the constraint is enforced in the Zod schema (03-04) and, if it were
-- wanted in the database, the table would have to be recreated. In PostgreSQL:
-- ALTER TABLE coffees ADD CONSTRAINT chk_discount
-- CHECK (discount_percent BETWEEN 0 AND 50);
CREATE INDEX idx_coffees_discount ON coffees (discount_percent)
WHERE discount_percent > 0; -- partial index: only the discounted onesWhy it is backward compatible: the column has DEFAULT 0 and NOT NULL, so existing rows fill themselves in and no previous insert stops working. From the API's point of view, adding a field to a representation is an additive change (02-07): a consumer that ignores discountPercent carries on working exactly as before, because a well-written JSON client ignores fields it does not know. No v2 would be needed.
What is missing for it to appear in the JSON: three things, and none of them in the database. Add discountPercent: row.discount_percent in the repository's toModel(); add it to coffeeToRepresentation() in the mapper; and add it to openapi.yaml with its description. That exactly those three places are needed, and that they are always the same three, is the sign that the layered architecture is properly in place.
Solution 2
// src/repositories/reviews-sqlite.js
import { database } from '../config/database.js';
function toModel(row) {
return {
id: row.id,
coffeeId: row.coffee_id,
customerId: row.customer_id,
rating: row.rating,
comment: row.comment,
status: row.status,
createdAt: row.created_at,
};
}
const coffeeExists = database.prepare('SELECT 1 FROM coffees WHERE id = ? AND active = 1');
export function findReviewsForCoffee(coffeeId, { status, limit = 20, offset = 0 } = {}) {
// Distinguishing "nonexistent coffee" from "coffee with no reviews" requires
// checking: the first is 404 coffee_not_found; the second, 200 with empty data.
if (!coffeeExists.get(coffeeId)) return { coffeeNotFound: true };
const conditions = ['coffee_id = ?'];
const params = [coffeeId];
if (status !== undefined) {
conditions.push('status = ?');
params.push(status);
}
const where = `WHERE ${conditions.join(' AND ')}`;
const total = database
.prepare(`SELECT COUNT(*) AS n FROM reviews ${where}`)
.get(...params).n;
const rows = database
.prepare(
`SELECT * FROM reviews ${where}
ORDER BY created_at DESC, id DESC
LIMIT ? OFFSET ?`
)
.all(...params, limit, offset);
return { items: rows.map(toModel), total };
}The index that supports it: idx_reviews_coffee ON reviews (coffee_id, status). It covers the mandatory filter on coffee_id and, when status is added, that one too. The created_at DESC sort is not covered, so the engine sorts that coffee's subset of reviews in memory —acceptable, because there are few of them. If a coffee ever had thousands, the ideal index would be (coffee_id, status, created_at DESC, id DESC), which would serve both the filter and the sort at once. Check it with EXPLAIN QUERY PLAN.
Solution 3
Step by step:
- Both read
cof_001withversion: 7. - The first sends their change with
version: 7. TheUPDATE ... WHERE id = 'cof_001' AND version = 7finds the row, applies the price and raises the version to 8.changes === 1, and the API answers200with the updated representation. - The second sends their change with
version: 7. TheWHERE ... AND version = 7finds no row, because it is now 8.changes === 0and the coffee does exist → conflict. - The API answers
409 version_conflictwith the message asking them to reload and repeat.
Without version control: the second UPDATE would have written its complete copy of the coffee, including the price_cents of 1450 that it read half a minute earlier. The first employee's price change disappears without a trace: no error, no log, no clue. That is the lost update, and its seriousness lies in being silent: the first employee sees their 200 OK, walks away satisfied, and the old price reappears.
Why it does not work for the order's stock. They are two different problems. Optimistic locking requires the client to have read the resource and to return its version, and it protects a complete replacement of the resource. The stock deduction, on the other hand, is a relative operation —"subtract 2 from whatever is there"— on something nobody has read beforehand: the buyer does not send the coffee's version and has no reason to know it. If they did, two simultaneous purchases of different coffees from the same order would produce 409 constantly and the shop would be unusable.
What is used instead is the condition inside the UPDATE itself:
Here the read and the write happen in the same atomic statement, so there is no window between checking and updating. If two requests try to buy the last bag, one gets changes === 1 and the other changes === 0, and the latter receives 409 insufficient_stock. The general rule: optimistic locking for absolute updates; a condition in the WHERE for relative ones.
Conclusion
Aroma Store's data now lives in a real database, and the change cost three import lines outside the repositories/ folder. That is what the repository pattern buys you: the rest of the application never knew where the coffees were. The SQL schema translates the contract's decisions into structure —an integer price_cents because money is never floating point, prefixed text primary keys, CHECK on the enums as defence in depth, ISO-8601 dates whose alphabetical order is chronological, and indexes placed exactly where 02-06 said there would be filters and sorting. Numbered, immutable migrations turn "the schema" into something that gets deployed, reviewed in a pull request and reproduced on any machine with npm run migrate.
And you have seen the four problems that separate a naive data layer from a professional one. Prepared statements, which do not escape quotes but send the SQL and the data along different paths, with an allow-list for the one thing that admits no placeholders, the ORDER BY. Transactions, which make creating an order while deducting stock across several items an all-or-nothing affair, with the AND stock >= ? condition in the UPDATE as the real protection against two simultaneous buyers. Optimistic concurrency control with version, which turns a silent lost update into a visible 409 version_conflict. And N+1, together with cursor pagination that makes page five thousand cost the same as the first.
One door is still wide open: anyone can create, modify and delete coffees, and GET /v1/orders returns every customer's orders to whoever asks. In 03-06, Authentication and Authorisation, we close it: we will distinguish authentication from authorisation —the 401 from the 403 that the contract has separated since 02-04—, register customers with their password protected by bcrypt, issue and verify JWTs signed with the secret that already lives in .env, write the middleware that tells a missing token from an expired one and returns WWW-Authenticate, and apply the roles customer, employee, administrator and partner with a permission matrix per endpoint and resource-level ownership checks.
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
