In this chapter
Anna builds the core of her event waitlist: a REST API with Express, connected to PostgreSQL, with full CRUD — create, read, update and delete — designed first on paper. We'll build it step by step, explained as opening a small shop with a counter and a stockroom.
The Problem in Real Life
11:15 AM. Before writing any code, Anna does what Act 18 taught her: she writes the requirements on one sticky note. "Organisers create events. Fans join a waitlist with their email and see their position. Organisers see the list and can remove people. Nobody can join twice."
Then the design (Act 18 again): two resources, two tables, a handful of endpoints. Only then does she type npm init -y. John, walking past with the clipboard, notices the order and makes a small mark.
Design on paper first. Then the code is just typing.
Anna
Coding Straight Away vs. Designing the Resources First
What are the pieces?
Which things does the app store, and how do they relate?
What does the API look like?
Which addresses and methods will clients use to create, read, change and remove things?
Rules the data must follow
Nobody may join the same waitlist twice — even if they click twice in a second.
A Simple REST API, a Database and CRUD
The small shop analogy: a small shop has a counter where customers ask for things, and a stockroom behind it where everything is kept on labelled shelves. Customers never go into the stockroom; they ask at the counter, and the shopkeeper fetches, adds, changes or removes stock. A REST API is the counter (Acts 12, 23). The database is the stockroom (Act 13).
- Design the resources: two nouns: events (
id,name,date,organiser_id) and waitlist entries (id,event_id,email,created_at). One event has many entries — a one-to-many relationship with a foreign key (Act 13). - Design the data rules in the database: a unique constraint on
(event_id, email)so nobody can join twice — even with two clicks in the same millisecond (Act 13's lesson: let the database decide).NOT NULLon required columns. A position isn't stored; it's calculated fromcreated_atorder, so removing someone automatically moves everyone up. - CRUD — the four things you can do with data: Create (
POST), Read (GET), Update (PUT/PATCH), Delete (DELETE) — the HTTP verbs from Act 12. Almost every app is CRUD at its core. - The endpoints:
POST /events(create),GET /events/:id(read one),POST /events/:id/waitlist(join),GET /events/:id/waitlist/position?email=(my position),GET /events/:id/waitlist(organiser: the full list, paginated — Act 23),DELETE /events/:id/waitlist/:entryId(organiser: remove someone). - Connect the API to the database: a local PostgreSQL in Docker (Act 21), a connection pool with the
pglibrary (Act 13),DATABASE_URLfrom an environment variable (Act 16), parameterised queries everywhere (Act 22), and a migration file that creates the tables, so the schema lives in Git. - Good API manners from Act 23: validate input (an email must look like an email), return the right status codes —
201for created,404if the event doesn't exist,409if already on the list,400for bad input — and consistent JSON errors.
| Method | Path | CRUD | Who | Success |
|---|---|---|---|---|
| POST | /events | Create | Organiser | 201 Created |
| GET | /events/:id | Read | Anyone | 200 OK |
| POST | /events/:id/waitlist | Create | Fan | 201 Created (409 if already joined) |
| GET | /events/:id/waitlist/position?email= | Read | Fan | 200 { position } |
| GET | /events/:id/waitlist | Read | Organiser | 200 (paginated) |
| DELETE | /events/:id/waitlist/:entryId | Delete | Organiser | 204 No Content |
The Event Waitlist, first version
Client (curl, later a browser)
HTTP + JSON
Express API
routes → validation → queries
events
id, name, date, organiser_id
waitlist_entries
event_id, email — UNIQUE together
CREATE TABLE events (id SERIAL PRIMARY KEY,name TEXT NOT NULL,starts_at TIMESTAMPTZ NOT NULL,organiser_id INTEGER NOT NULL);CREATE TABLE waitlist_entries (id SERIAL PRIMARY KEY,event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,email TEXT NOT NULL,created_at TIMESTAMPTZ NOT NULL DEFAULT now(),UNIQUE (event_id, email) -- nobody joins twice, even with two fast clicks);
app.post("/events/:id/waitlist", async (req, res) => {const email = String(req.body.email || "").trim().toLowerCase();if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {return res.status(400).json({ error: { code: "invalid_email" } });}try {const { rows } = await db.query("INSERT INTO waitlist_entries (event_id, email) VALUES ($1, $2) RETURNING id, created_at",[req.params.id, email]);res.status(201).json(rows[0]);} catch (err) {if (err.code === "23505") return res.status(409).json({ error: { code: "already_joined" } });if (err.code === "23503") return res.status(404).json({ error: { code: "event_not_found" } });throw err;}});app.get("/events/:id/waitlist/position", async (req, res) => {const { rows } = await db.query(`SELECT position FROM (SELECT email, ROW_NUMBER() OVER (ORDER BY created_at, id) AS positionFROM waitlist_entries WHERE event_id = $1) ranked WHERE email = $2`,[req.params.id, String(req.query.email || "").toLowerCase()]);if (!rows.length) return res.status(404).json({ error: { code: "not_on_waitlist" } });res.json({ position: Number(rows[0].position) });});
23505 and 23503 are PostgreSQL's error codes for a unique violation and a missing foreign key — the database enforcing the rules.
By 2 PM: Anna tries it with curl (Act 06). She creates an event, joins with three test emails, asks for a position — { "position": 2 } — tries to join twice — 409 Conflict — removes the first person, and asks again: { "position": 1 }. Every rule works, and most of them live in the database, where they can't be skipped. The waitlist exists. It just isn't safe or shareable yet.
Key Takeaway
Design before coding: the resources (events, waitlist entries), their relationship and the data rules (a unique constraint so nobody joins twice, positions calculated, not stored). A REST API is the shop counter and the database is the stockroom: CRUD maps to POST, GET, PUT/PATCH and DELETE; connect with a connection pool, settings from environment variables and parameterised queries; keep the schema in a migration; and return proper status codes and JSON errors.
Why This Matters
A REST API backed by a database with CRUD is the most common shape of backend software in the world, and the classic first portfolio project. Designing the resources and data rules first — and letting the database enforce them — is what separates a project that works in a demo from one that works under real use.
Right now, anyone can create events and anyone can see or delete the whole waitlist. Before the app goes anywhere near the internet, Anna needs logins, permissions — and a safety net of tests.
