JP
HomeBlogProjectsResumeAbout

© 2026 JP. All rights reserved.

Back to blog

Dx Music League Part 2 : One Backend, Two Worlds

AdminJuly 12, 20265 min read
Dx Music League  Part 2 : One Backend, Two Worlds

On this page

  • The build order
  • One set of handlers, two repositories
  • The server is the referee
  • One table, seven key patterns
  • Spotify without asking players for anything
  • The referee gets pickier

On this page

  • The build order
  • One set of handlers, two repositories
  • The server is the referee
  • One table, seven key patterns
  • Spotify without asking players for anything
  • The referee gets pickier
PreviousDx Music League Part 1 : A Fake App That Feels Real NextDx Music League Part 3 : Shipping It

Building 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 build order

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:

  1. Infrastructure scaffold on AWS CDK: DynamoDB, Cognito, API Gateway, one Lambda — plus a local dev server running the same code against an in-memory store.
  2. League create / join / list / detail.
  3. Round lifecycle endpoints, then submissions.
  4. Ballots, reveal, and results.
  5. The Spotify proxy, then playlist creation when submissions close.
  6. Wire the deployed API into the frontend.

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.

One set of handlers, two repositories

The whole backend is organized as:

plaintext
  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          DynamoRepository

routes.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.)

The server is the referee

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:

ts
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:

ts
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.

One table, seven key patterns

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.

Spotify without asking players for anything

The Spotify integration follows a read/write split that later turned out to generalize perfectly (see the YouTube Music post):

  • Search uses the Client Credentials flow — an app-level token, cached in the Lambda until just before expiry, no user involved. Players search Spotify's catalog without ever seeing a Spotify login screen.
  • Playlists use a single host account: when submissions close, the backend creates the round's playlist on my account using a stored refresh token, and shares the URL with the league.

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.

The referee gets pickier

Two rule commits from that first weekend deserve a mention because they came straight from imagining real players:

  • Duplicate rejection. Within a round, no two people can submit the same song — and no artist can appear twice. The second Taylor Swift pick bounces with an error naming the conflict. This check compares provider track ids first, then falls back to normalized title + artist matching.
  • Gated phase transitions. A round moves 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.