Dx Music League Part 2 : One Backend, Two Worlds


Part 2 of a 5-part series. Part 1 built the whole product on mocks. Now it needs a real spine.
The frontend spent day one learning to live without a backend. Day two, the backend arrived — designed around the same trick that made the frontend pleasant to build: the exact same code runs in two worlds.
The backend went up over a weekend (June 28–29) in explicit, numbered steps — the commits literally say "build-order step 3, part 1." The sequence:
Working in that order meant every layer landed on something already tested. But the interesting part isn't the order — it's the architecture that made each step small.
The whole backend is organized as:
local dev ───────▶ routes.ts (shared) ◀─────── API Gateway + Lambda
http/local-server.ts handlers/*.ts http/lambda.ts
(Node http, domain/rules.ts (Cognito-authorized)
x-dev-user auth) │ Repository port
┌──────────┴──────────┐
MemoryRepository DynamoRepositoryroutes.ts is a flat table of { method, pattern, handler } entries — no framework, just a segment matcher. Two thin adapters consume it: a plain Node HTTP server for local dev (auth stubbed via an x-dev-user header), and a Lambda handler for production (auth from the API Gateway Cognito authorizer's validated JWT claims). Handlers are pure functions over a Repository interface with two implementations: in-memory Maps for dev and tests, DynamoDB single-table for production.
The payoff compounds. Every feature in the rest of this series was developed and tested locally with npm run dev — instant startup, seeded data, no AWS credentials — and deployed unchanged. The ~46 backend tests run against the memory store in a couple of seconds using Node's built-in test runner. No Jest, no mocking library, no LocalStack.
(One sharp edge worth confessing: the TypeScript here runs via --experimental-strip-types, which strips types without checking them. Tests can pass while the types are broken. npx tsc --noEmit is a separate, mandatory habit.)
Part 1 locked the game rules before the backend existed. Now they got a home: domain/rules.ts, a file of pure functions with no I/O. The centerpiece is ballot validation — every rule from the design doc, enforced in one place:
if (total !== settings.votePoolSize) {
throw badRequest(
`You must spend exactly ${settings.votePoolSize} points (you spent ${total}).`
);
}Points must target real submissions in the round. Integers only. Per-song cap. No self-votes. Spend the pool exactly. The tie-break — points, then distinct voters, then title — is one comparator:
b.points - a.points || b.distinctVoters - a.distinctVoters || a.title.localeCompare(b.title)The frontend enforces the same rules for UX (the submit button stays disabled until your pool hits zero), but the frontend's opinion is advisory. The referee lives server-side, where nobody can edit it in DevTools.
The DynamoDB design is a classic single table. The two decisions I'd defend hardest:
Ballots are one item per voter. The sort key BALLOT#<voterId> means "one ballot per voter per round" is enforced by the schema, not by application logic. Re-voting overwrites the whole allocation atomically — there is no code path where half a ballot exists.
Round ids encode their own address. A round's id is "<leagueId>~<0003>" — you can decode it straight back into the table keys. No lookup table, no GSI, no ambiguity about which league a round belongs to.
Identity, meanwhile, never comes from the client. The design doc's first principle — the caller is the JWT sub claim, period — meant endpoints never grew a tempting little userId body field to spoof.
The Spotify integration follows a read/write split that later turned out to generalize perfectly (see the YouTube Music post):
The browser never talks to Spotify at all; everything goes through the Lambda proxy, and the client secret lives in Secrets Manager and never leaves it. This is also where the round gained a phase: previewing, between submitting and voting — close submissions, get the playlist, live with the songs for a bit, then vote. The game got noticeably better the week that phase appeared.
Two rule commits from that first weekend deserve a mention because they came straight from imagining real players:
draft → submitting → previewing → voting → revealed through an explicit allow-list of transitions. There is no API call that skips a phase or goes backward, and reveal has its own endpoint with its own rules (owner-only, tallies and banks points transactionally).By the end of the weekend: invite links with deep-link join, owner round controls in the UI, the full loop — create league, invite, submit, playlist, vote, reveal — working against real Cognito and real DynamoDB.
It worked. It was also ugly in exactly one way that mattered: it lived at an unmemorable API Gateway URL and had never met a real user.
Next: Part 3 — Shipping It, in which the app gets a domain, a CDN, public leagues, and a very long July 4th of removing every fake thing the UI was still lying about.