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. Now, we swap that Map for a Prisma-backed repository. Because we are putting it behind the same interface we built in chapter 6, the migration from memory to database is surprisingly clean.

PrismaUrlRepository that talks to Postgres but satisfies the same UrlStore contract the route already depends on.await twice, and nothing else.Before we write any code, here is a quick look at the two main ideas driving this change.
The repository pattern (the seam). The route depends only on a small interface called UrlStore. It expects a save method and a findByCode method. Any class that implements those methods can be plugged in. That interface acts as a seam. It is a deliberate boundary where you can swap one implementation for another without touching the rest of the code.
Synchronous vs. Asynchronous. A Map lookup is synchronous. When you call map.get(code), it returns immediately. A database call is asynchronous. It crosses a network socket, so you have to await the answer, which comes back as a Promise.
The UrlStore interface we added back in chapter 6 makes this migration easy. Because the route depends on the interface instead of the Map, we just write a new Prisma-backed class that satisfies the contract and hand it to the app. The route's entire diff against the previous chapter is exactly 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);
Running 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 are identical. The storage backend moved from an in-memory Map to a real PostgreSQL table, and the route had no idea. This is the seam doing exactly the job we designed it for.
So why are there any changes to the route at all? It comes down to the difference between a synchronous Map and an asynchronous database.
A Map lookup is synchronous, returning immediately. A database query is not. It usually crosses a network socket, and you have to wait for the answer. There is no honest way to keep a synchronous interface in front of an asynchronous backend. The one unavoidable change is 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. Notice where it lives. It sits right at the seam, the interface every storage backend implements. Because the route already funnels its storage calls through that interface, making the store async ripples no further than adding await to those two calls. The in-memory UrlService becomes async to match, and the uniqueness helper generateUniqueShortCode awaits its lookup. Nothing else in the codebase learns that storage became asynchronous.
This is a practical argument for designing a seam before you strictly need it. A change that could have been invasive is contained to the one boundary that already abstracts storage. The blast radius is just a handful of await keywords instead of a full rewrite.
We start from tests that fail for the right reason. The start branch ships two new integration tests. Both import 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 creates a new 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 and fires a real POST /shorten request through app.inject(). Then it queries the urls table directly to confirm the row actually landed in Postgres rather than just in memory:
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. On the start branch, that file does not exist. Run the integration suite:
bashnpm run test:integration

The tests fail to compile with Cannot find module '../../src/services/prisma-url.repository'. This is an honest failure. The container is up, the migration is applied, and the two pre-existing integration suites (db.test.ts and url-model.test.ts) still pass against the live test database. The failure is not a connectivity issue. It is exactly the gap we are about to close. Running npm run typecheck agrees, reporting error TS2307 for the missing module and complaining that urlStore is not yet a valid option on BuildAppOptions.
The fast unit suite (npm test) is untouched and still green. Its 6 suites and 32 tests never reach for the repository. They exercise the route with the in-memory store, and keeping them that way is a major focus for 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 using 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;}}
There are three specific decisions in this file worth pointing out.
First, the interface stays narrow and the repository absorbs the gap. The UrlStore contract only knows about two strings: a short code and a URL. The Prisma Url row is richer, containing 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. It lets the database fill in clicks (default 0), createdAt (default now()), and the serial id. The richer row is an implementation detail of the repository, completely 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, but 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. The two backends would no longer be interchangeable. To fix this, findByCode normalizes the miss with row?.originalUrl ?? undefined. This guarantees the same contract regardless of which store is behind it.
Third, the PrismaClient is injected through the constructor. The repository never imports the shared client directly. Instead, it takes one as a constructor argument. This allows the integration tests to hand in a client pointed at the test database. It also allows future tests to pass in a client wrapped in a transaction. In production, buildApp passes the shared client from src/db/prisma. This follows the same dependency injection pattern as the store itself: push the choice of the concrete implementation out to the edge.
The interface now returns a Promise, so the in-memory UrlService updates to match. The change is mechanical. We add async and leave the function bodies untouched.
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 this file at all now that production uses Postgres? Because it is the cheapest possible UrlStore. The route's unit tests inject it so they can exercise real route logic—schema validation, code generation, and response shaping—without a database and without Docker. It takes well under a second to run. It is no longer the production store, but it remains the fastest backend for the unit suite.
The generateUniqueShortCode function regenerates a code until the store reports it is free. Its lookup is now async, so it awaits the result and becomes async itself. The pure generator (generateShortCode) and its injected randomness source stay synchronous. This keeps the deterministic short-code tests perfectly 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 the wiring. buildApp now accepts an optional urlStore and defaults to the Prisma repository when none is given. This means the real, running app persists to Postgres automatically. Tests can pass in whichever backend they need.
typescript// src/app.ts (excerpt)interface BuildAppOptions {logger?: boolean;random?: RandomSource;urlStore?: UrlStore;}const urlStore = opts.urlStore ?? new PrismaUrlRepository(prisma);
We default to Prisma because that is the production behavior we want. When you start the server, shortened URLs should persist without requiring any special flags. The ?? new PrismaUrlRepository(prisma) fallback makes the database the zero-config path. Injection becomes the deliberate exception. This is the right approach for a service whose main job is to persist data.
This default behavior actually introduced a bug, which our test strategy caught immediately.
The instant buildApp defaults to the Prisma store, the existing route unit tests (shorten.test.ts and shorten.validation.test.ts) fall through to that default and try to reach Postgres. Previously, they got an in-memory Map for free. Now, they have a hidden Docker dependency. They might even pass, but only because the development database happens to be running. A unit test silently talking to a database is a unit test in name only. It becomes slow, flaky, and breaks 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 block:
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 is the seam being used correctly. Injecting the store means each test picks its backend on purpose. The bug was leaving that choice implicit. By making it explicit, the unit suite is provably Docker-free. You can 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 and 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 from end to end:
bashnpm test # fast unit suite, no Dockernpm run test:integration # with the container up

Both are green. The unit count is unchanged at 6 suites and 32 tests. This is exactly as it should be. We swapped the production backend without changing a single thing the route actually does, so the route's unit tests assert the same behavior they always did. The integration suite grew from chapter 11's 2 suites and 4 tests to 4 suites and 8 tests. The two new suites add four tests: three exercising PrismaUrlRepository directly, and one driving a full POST /shorten request through buildApp to confirm the row landed in the urls table.
Running npm run typecheck passes with no output, too. The prisma-url.repository module that the start branch was missing is now real and fully typed. The urlStore is a recognized BuildAppOptions field. The two testing layers do complementary jobs. The unit suite proves the route's logic quickly and without Docker, while the integration suite proves the request path actually persists to a real database.
When you are 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, complete with a clean 404 for unknown codes.