Chapter 11 gave us a real urls table and a typed Prisma client, but the app still served every request from the in-memory Map. This is the chapter where that changes. We swap the Map for a Prisma-backed repository behind the same interface from chapter 6, completing the in-memory → database migration.

PrismaUrlRepository that talks to Postgres but satisfies the same UrlStore contract the route already depends on.await twice, and nothing else.Two ideas carry this chapter. Here they are in plain terms before any code.
The repository pattern, or the seam. The route depends only on a small interface, UrlStore, with save and findByCode, so any class implementing it can be plugged in. That interface is the seam: a deliberate boundary where you swap one implementation for another without touching the other side.
sync → async. A Map lookup is synchronous; map.get(code) returns immediately. A database call is asynchronous. It crosses a socket, so you await the answer, which comes back as a Promise.
The UrlStore interface we added back in chapter 6 is what makes this migration cheap. The route depends on the interface, not on the Map, so we just write a new Prisma-backed class that satisfies the same contract and hand it to the app. The route's entire diff against the previous chapter is two lines:
diffdiff --git a/src/routes/shorten.ts b/src/routes/shorten.ts@@ -66,9 +66,9 @@ export const shortenRoute: FastifyPluginAsync<ShortenRouteOptions> = async (};}- const shortCode = generateUniqueShortCode(urlStore, random);+ const shortCode = await generateUniqueShortCode(urlStore, random);- urlStore.save(shortCode, url);+ await urlStore.save(shortCode, url);
git diff --numstat confirms it: 2 2 src/routes/shorten.ts. The only edit on each line is the word await. The route's schema, validation, response shape, and status codes (every byte of its logic) are identical. The storage backend moved from an in-memory Map to a real PostgreSQL table, and the route had no idea. That is the seam from chapter 6 doing exactly the job we designed it for.
So why are there any changes to the route at all? Because of the sync → async shape difference between a Map and a database, which is worth its own section.
A Map lookup is synchronous: map.get(code) returns immediately. A database query is not. It crosses a socket and you wait for the answer. There is no honest way to keep a synchronous interface in front of an asynchronous backend, so the one unavoidable change was making UrlStore return promises:
typescript// src/services/url.service.tsexport interface UrlStore {save(shortCode: string, url: string): Promise<void>;findByCode(shortCode: string): Promise<string | undefined>;}
That is the entire breaking change in this chapter, and notice where it lives. It sits at the seam, the interface every storage backend implements. Because the route already funnels both of its storage calls through that interface, "async-ifying" the store ripples no further than adding await to those two calls. The in-memory UrlService becomes async to match (its bodies stay trivial), and the uniqueness helper generateUniqueShortCode awaits its lookup. Nothing else in the codebase learns that storage became asynchronous.
This is the practical argument for designing a seam before you need it. A change that could have been invasive, "make storage async", is contained to the one boundary that already abstracts storage. The blast radius is a handful of await keywords, not a rewrite.
As always, we start from tests that fail for the right reason. The start branch ships two new integration tests, both importing a repository module that does not exist yet. Check it out with the Postgres container running:
bashgit checkout 12-migrate-to-database-startnpm installcp .env.example .env # first time onlydocker compose up -d --wait # starts Postgres, blocks until healthy
The first new test drives the repository directly against the real database. It news up PrismaUrlRepository, saves a URL, reads it back, and checks the database defaults:
typescript// __tests__/integration/prisma-url.repository.test.tsimport { PrismaUrlRepository } from "../../src/services/prisma-url.repository";import { prisma } from "../../src/db/prisma";import { pool } from "../../src/db/pool";describe("PrismaUrlRepository", () => {let repository: PrismaUrlRepository;beforeEach(() => {repository = new PrismaUrlRepository(prisma);});afterAll(async () => {await prisma.$disconnect();await pool.end();});it("saves a url and reads it back by its short code", async () => {await repository.save("abc123", "https://dalabs.academy");expect(await repository.findByCode("abc123")).toBe("https://dalabs.academy");});it("returns undefined for an unknown short code", async () => {expect(await repository.findByCode("does-not-exist")).toBeUndefined();});it("persists the row with the database defaults (clicks = 0)", async () => {await repository.save("xyz789", "https://example.com/path");const row = await prisma.url.findUnique({where: { shortCode: "xyz789" },});expect(row).not.toBeNull();expect(row?.originalUrl).toBe("https://example.com/path");expect(row?.clicks).toBe(0);expect(row?.createdAt).toBeInstanceOf(Date);});});
The second new test goes one level higher. It wires the Prisma repository into buildApp, fires a real POST /shorten through app.inject(), then queries the urls table directly to confirm the row actually landed in Postgres rather than just in a Map:
typescript// __tests__/integration/shorten-persists.test.tsimport { FastifyInstance } from "fastify";import { buildApp } from "../../src/app";import { PrismaUrlRepository } from "../../src/services/prisma-url.repository";import { prisma } from "../../src/db/prisma";import { pool } from "../../src/db/pool";describe("POST /shorten persists to the database", () => {let app: FastifyInstance;afterEach(async () => {await app.close();});afterAll(async () => {await prisma.$disconnect();await pool.end();});it("writes the shortened URL to the urls table", async () => {const store = new PrismaUrlRepository(prisma);app = await buildApp({ logger: false, urlStore: store, random: () => 0 });await app.ready();const url = "https://dalabs.academy/courses/test-driven-development-with-nodejs";const response = await app.inject({method: "POST",url: "/shorten",payload: { url },});expect(response.statusCode).toBe(201);const body = response.json();expect(body.shortCode).toBe("000000");const row = await prisma.url.findUnique({where: { shortCode: body.shortCode },});expect(row).not.toBeNull();expect(row?.originalUrl).toBe(url);expect(row?.clicks).toBe(0);});});
Both tests import ../../src/services/prisma-url.repository, and on the start branch that file does not exist. Run the integration suite:
bashnpm run test:integration

Red. Both new suites fail to compile with Cannot find module '../../src/services/prisma-url.repository'. This is an honest red: the container is up, the migration is already applied, and the two pre-existing integration suites (chapter 9's db.test.ts and chapter 11's url-model.test.ts) still pass against the live test database. The failure is not connectivity; it is exactly the gap we are about to close. npm run typecheck agrees, reporting error TS2307 for the same missing module plus a complaint that urlStore is not yet a valid option on BuildAppOptions.
The fast unit suite (npm test) is untouched and still green. Its 6 suites / 32 tests never reach for the repository; they exercise the route with the in-memory store, and keeping them that way is the focus of the rest of the chapter.
Switch to the finish branch to see what makes it pass.
bashgit checkout 12-migrate-to-database-finishnpm install
The new file is a class that implements UrlStore against Prisma. It is small precisely because the interface is narrow.
typescript// src/services/prisma-url.repository.tsimport { PrismaClient } from "@prisma/client";import { UrlStore } from "./url.service";export class PrismaUrlRepository implements UrlStore {constructor(private readonly prisma: PrismaClient) {}async save(shortCode: string, url: string): Promise<void> {await this.prisma.url.create({data: { shortCode, originalUrl: url },});}async findByCode(shortCode: string): Promise<string | undefined> {const row = await this.prisma.url.findUnique({where: { shortCode },});return row?.originalUrl ?? undefined;}}
Three decisions in that file are worth their why.
First, the interface stays narrow and the repository absorbs the gap. The UrlStore contract knows two strings: a short code and a URL. The Prisma Url row is richer; it also has an id, clicks, and createdAt. We deliberately do not widen the interface to expose those columns. Instead, save maps the interface's url argument to the originalUrl column and lets the database fill clicks (default 0), createdAt (default now()), and the serial id. The richer row is an implementation detail of the repository, invisible to the route. Keeping the interface narrow is what kept the route diff to two lines.
Second, findUnique returns null where the contract promises undefined. The in-memory Map returns undefined for a miss; Prisma's findUnique returns null. If the repository leaked null, the route would suddenly have to handle a value the in-memory store never produced, and the two backends would no longer be interchangeable. So findByCode normalises the miss with row?.originalUrl ?? undefined. Same contract, whichever store is behind it.
Third, the PrismaClient is injected through the constructor. The repository never imports the shared client directly; it takes one as a constructor argument. That is what lets the integration tests hand in a client pointed at the test database, and lets a later test pass one wrapped in a transaction. In production, buildApp passes the shared client from src/db/prisma. It is the same dependency-injection instinct as the store itself: push the choice of concrete thing out to the edge.
The interface is now Promise-returning, so the in-memory UrlService updates to match. The change is mechanical: add async, leave the bodies untouched. The comments make the new role explicit, that this store is no longer the production store but the one the fast unit tests inject.
typescript// src/services/url.service.tsexport class UrlService implements UrlStore {private readonly urls = new Map<string, string>();async save(shortCode: string, url: string): Promise<void> {this.urls.set(shortCode, url);}async findByCode(shortCode: string): Promise<string | undefined> {return this.urls.get(shortCode);}}
Why keep it at all, now that production uses Postgres? Because it is the cheapest possible UrlStore. The route's unit tests inject it so they exercise real route logic (schema validation, code generation, response shaping) without a database, without Docker, in well under a second. It is no longer the production store, but it remains the fastest backend for the unit suite.
generateUniqueShortCode regenerates a code until the store reports it's free. Its lookup is now async, so it awaits it and becomes async itself. The pure generator (generateShortCode) and its injected randomness source stay synchronous, so the deterministic short-code tests remain stable.
diff-export const generateUniqueShortCode = (+export const generateUniqueShortCode = async (store: UrlStore,random: RandomSource = Math.random-): string => {+): Promise<string> => {let code = generateShortCode(random);- while (store.findByCode(code) !== undefined) {+ while ((await store.findByCode(code)) !== undefined) {code = generateShortCode(random);}return code;};
buildApp to Prisma, Inject Per TestThe last piece is wiring. buildApp now accepts an optional urlStore and defaults to the Prisma repository when none is given, so the real, running app persists to Postgres with no extra ceremony. Tests pass whichever backend they need.
typescript// src/app.ts (excerpt)interface BuildAppOptions {logger?: boolean;random?: RandomSource;urlStore?: UrlStore;}const urlStore = opts.urlStore ?? new PrismaUrlRepository(prisma);
Why default to Prisma? Because that is the production behaviour you want by default: start the server and shortened URLs persist, no flag required. The ?? new PrismaUrlRepository(prisma) makes the database the zero-config path and injection the deliberate exception, which is the right way around for a service whose job is to persist data.
This is a real bug that the test strategy caught during this chapter.
The instant buildApp defaults to the Prisma store, the existing route unit tests, shorten.test.ts and shorten.validation.test.ts, which previously got an in-memory Map for free, would fall through to that default and try to reach Postgres. They would have a hidden Docker dependency. They might even pass, but only because the dev database happened to be running. A "unit" test silently talking to a database is a unit test in name only: slow, flaky, and broken the moment someone runs it without Docker.
The fix is to make those tests inject the in-memory store explicitly:
typescript// __tests__/shorten.test.ts (excerpt)it("should return 201 with a generated short code and short URL", async () => {app = await buildApp({logger: false,random: () => 0,urlStore: new UrlService(),});await app.ready();});
The validation suite does the same in its beforeAll:
typescript// __tests__/shorten.validation.test.ts (excerpt)beforeAll(async () => {app = await buildApp({ logger: false, urlStore: new UrlService() });await app.ready();});
This is not the seam failing — it's the seam being used correctly. Injecting the store means each test picks its backend on purpose, and the bug was leaving that choice implicit. With it explicit, the unit suite is provably Docker-free: re-run npm test with DATABASE_URL and TEST_DATABASE_URL pointed at an unreachable host and it still passes.
bashDATABASE_URL=postgresql://user:pass@localhost:9999/db \TEST_DATABASE_URL=postgresql://user:pass@localhost:9999/db \npm test
All 6 suites / 32 tests still pass against a database that isn't there, because the route unit tests injected the Map and never tried to connect.
Now run both suites the normal way. The unit suite (Docker-free) and the integration suite (Docker-required) together prove the migration end to end:
bashnpm test # fast unit suite, no Dockernpm run test:integration # with the container up

Green on both. The unit count is unchanged at 6 suites / 32 tests, exactly as it should be: we swapped the production backend without changing a single thing the route does, so the route's unit tests assert the same behaviour they always did. The integration suite grew from chapter 11's 2 suites / 4 tests to 4 suites / 8 tests. The two new suites add four tests: three exercising PrismaUrlRepository directly, one driving a full POST /shorten through buildApp and confirming the row landed in urls.
npm run typecheck passes with no output, too. The prisma-url.repository module the start branch was missing is real and fully typed, and urlStore is now a recognised BuildAppOptions field. The two layers do complementary jobs: the unit suite proves the route's logic fast and without Docker, while the integration suite proves the request path actually persists to a real database.
When you're done, stop the container:
bashdocker compose down -v
We can still only create short links, with nowhere to send a visitor who follows one. Next we add the read side: a GET /:code redirect to the original URL, with a clean 404 for unknown codes.