The read side of the API is complete — we can create, redirect, count, list, and inspect URLs. This chapter closes the loop with removal: DELETE /urls/:code removes a short URL and proves, with a test, that the row is actually gone rather than just trusting the status code.

delete(shortCode) method on the chapter-6 UrlStore seam, behind a thin DELETE /urls/:code handler.true if it removed a row, false if the code wasn't there — which the handler maps to 204 or 404.Before the code, here are the three terms every decision in this chapter turns on.
Idempotency. An operation is idempotent when running it twice leaves the same end state as running it once. Deleting abc123 and then deleting it again both end with "abc123 no longer exists" — the open question is only what that call should report.
204 vs 404. A 204 No Content is success with nothing to send back (we removed the row; empty body). A 404 Not Found says there was nothing there to delete — which is what we return for a missing code, rather than a blindly idempotent 204.
Soft vs hard delete. A soft delete keeps the row and flips a deletedAt flag; a hard delete physically removes it with DELETE FROM urls and the data is gone. We hard-delete, so there's nothing to filter on later reads — and an integration test re-queries Postgres to prove the row is actually gone.
Removal sounds like the easy endpoint, but it hides two real decisions. The first is how the handler knows what to return: without a single source of truth, you'd look the code up, then delete it — two round-trips and a race in between. Returning a boolean from delete ("did I remove a row?") collapses that into one operation, exactly enough to pick 204 from 404. The second is what "delete" even means: hard-delete and the row is gone and every read just works; soft-delete and history survives but every query must now filter the dead rows, and one missed filter is a correctness bug. We pick the boolean seam and the hard delete because they keep the contract uniform — unknown code → 404 — with the least machinery.
delete Method on the SeamEvery endpoint so far has gone through the chapter-6 UrlStore interface, and delete is no different. We add one method:
typescript// src/services/url.service.tsexport interface UrlStore {save(shortCode: string, url: string): Promise<void>;findByCode(shortCode: string): Promise<string | undefined>;findRecordByCode(shortCode: string): Promise<UrlRecord | undefined>;incrementClicks(shortCode: string): Promise<void>;list(params: ListUrlsParams): Promise<ListUrlsResult>;delete(shortCode: string): Promise<boolean>;}
The return type is the load-bearing choice. delete returns Promise<boolean> — true means a row was removed, false means there was nothing to remove. That single boolean is exactly enough for the handler to pick between 204 and 404, with no second lookup. We don't call findByCode first to check existence and then delete; the delete itself reports whether it hit anything.
The in-memory store gets this almost for free, because Map.delete already returns a boolean telling you whether the key was present:
typescript// src/services/url.service.tsasync delete(shortCode: string): Promise<boolean> {return this.records.delete(shortCode);}
One line. Map.prototype.delete returns true if the key existed and was removed, false otherwise — the precise contract our interface promises.
deleteMany over deleteThe Prisma store is where the interesting decision lives. Prisma gives you two ways to delete a row, and the choice matters:
typescript// src/services/prisma-url.repository.tsasync delete(shortCode: string): Promise<boolean> {const { count } = await this.prisma.url.deleteMany({where: { shortCode },});return count > 0;}
The obvious method, prisma.url.delete({ where: { shortCode } }), throws when no row matches — a P2025 "record not found" error. To use it, you'd have to wrap the call in a try/catch, catch P2025 specifically, and translate it into a false. That's control flow by exception for a perfectly ordinary case (a code that isn't there).
deleteMany never throws on a miss. It returns a { count } — the number of rows removed. Because shortCode is @unique, that count is only ever 0 or 1, so count > 0 collapses cleanly into the boolean the interface wants. No try/catch, no error-code matching, no exceptional path. Same observable behaviour, simpler code.
We flagged the open question earlier: deleting an existing code returns 204, but what should deleting a missing or already-deleted code return? There are two defensible answers, and it's worth weighing both before picking one.
204. The end state — "this code no longer exists" — is identical whether or not a row was actually removed. By this reading, every DELETE of a given code converges to the same result, so every call could return 204. This is the better choice when clients retry deletes blindly and you want every retry to look successful regardless of whether the first one already did the work.404 (what we chose). A 404 tells the caller the resource wasn't there to delete. That's useful feedback — a stale dashboard, a double-clicked button, a typo'd code. An idempotent 204 would hide all of that: the caller couldn't tell a real deletion from a no-op.We pick the 404 for two reasons. First, it's informative — the response distinguishes "I removed it" from "there was nothing to remove." Second, it's consistent with the rest of the API: the redirect (GET /:code) and stats (GET /urls/:code/stats) endpoints already return 404 for an unknown code. Making DELETE behave the same keeps one uniform contract — unknown code → 404 — across every route. For a teaching API where clear, consistent responses matter more than blind-retry tolerance, that's the more defensible default.
The thin handler encodes the decision directly:
typescript// src/routes/delete.tsapp.delete<{ Params: DeleteRouteParams }>("/urls/:code", {schema: {description: "Delete a single short URL by its code",tags: ["URLs"],params: {type: "object",required: ["code"],properties: {code: { type: "string" },},},response: {204: { type: "null" },404: {type: "object",required: ["error", "message"],properties: {error: { type: "string" },message: { type: "string" },},},},},handler: async (request, reply) => {const { code } = request.params;const deleted = await urlStore.delete(code);if (!deleted) {reply.code(404);return {error: "Not Found",message: `No URL found for code "${code}"`,};}reply.code(204);return null;},});
The response schema declares both outcomes: 204: { type: "null" } documents the empty-body success, and the 404 shape reuses the same { error, message } body the other endpoints return. The handler calls urlStore.delete(code), and a false becomes the 404 while a true becomes reply.code(204); return null — a 204 carries no body, so we return null.
We chose a hard delete up front; here's the trade-off behind that choice.
deletedAt TIMESTAMP NULL column and set it instead of removing the row. History survives — useful for audit trails, an "undo," or preserving historical stats. But the cost spreads everywhere: every read query now has to add WHERE deletedAt IS NULL, or deleted URLs leak straight back into results. The redirect's findByCode, the list, the stats' findRecordByCode — each one needs the filter, and a single missed one is a correctness bug.The rule of thumb: choose soft delete when you genuinely need the history or reversibility and are willing to pay the "filter everywhere" tax on every read. Choose hard delete when simplicity wins and losing the row's history is acceptable. For a URL shortener, a deleted link is meant to be gone — so hard delete is the right default, and there's no migration to run because we use the existing urls table unchanged.
DELETE /urls/:code shares its path shape with the redirect's bare /:code catch-all, but there's no collision: DELETE is a distinct HTTP method, so Fastify's radix router keys it on a completely separate tree from the GET routes. We register it alongside the other URL routes, before the redirect catch-all:
typescript// src/app.tsawait app.register(healthRoute);await app.register(shortenRoute, { urlStore, random: opts.random });await app.register(listRoute, { urlStore });await app.register(statsRoute, { urlStore });await app.register(deleteRoute, { urlStore });await app.register(redirectRoute, { urlStore });
Check out the start branch, install, and bring up the database:
bashgit checkout 17-delete-url-startnpm installdocker compose up -d --wait
The start branch ships the tests first and no implementation. The Docker-free unit suite injects the in-memory UrlService and covers five cases: the happy-path 204 with an empty body, the proof that a deleted code's later reads 404, the 404 for a code that never existed, the 404 for an already-deleted code, and the proof that delete only touches the targeted code.
typescript// __tests__/delete.test.tsit("returns 204 with an empty body when deleting an existing code", async () => {await store.save("abc123", "https://dalabs.academy");const response = await app.inject({method: "DELETE",url: "/urls/abc123",});expect(response.statusCode).toBe(204);expect(response.body).toBe("");});it("removes the code so a later redirect returns 404", async () => {await store.save("gone01", "https://dalabs.academy");await app.inject({ method: "DELETE", url: "/urls/gone01" });const redirect = await app.inject({ method: "GET", url: "/gone01" });expect(redirect.statusCode).toBe(404);const stats = await app.inject({ method: "GET", url: "/urls/gone01/stats" });expect(stats.statusCode).toBe(404);});
That second case is the one that matters most. It doesn't stop at the 204 — it deletes the code, then re-reads it through two other endpoints and asserts both 404. A delete that returns the right status but leaves the row behind would pass the status check and fail here. The test proves the code is actually gone by trying to use it again and finding it absent.
The already-deleted case nails down the idempotency decision: delete twice0, assert 204, delete it again, assert 404. The second delete sees no row to remove, so the boolean is false and the handler returns the informative 404 we chose over an idempotent 204.
The integration suite mirrors the happy path against the real database, wiring PrismaUrlRepository — and it confirms deletion the strongest way possible:
typescript// __tests__/integration/delete.test.tsit("returns 204 and removes the row from the database", async () => {await prisma.url.create({data: { shortCode: "del001", originalUrl: "https://dalabs.academy" },});const response = await app.inject({method: "DELETE",url: "/urls/del001",});expect(response.statusCode).toBe(204);expect(response.body).toBe("");const row = await prisma.url.findUnique({where: { shortCode: "del001" },});expect(row).toBeNull();});
This is the load-bearing assertion of the chapter. It does not trust the 204 — it re-queries the database directly with prisma.url.findUnique and asserts the result is null. The status code says "I deleted it"; the findUnique proves it. If the handler had returned 204 without actually issuing the DELETE, the status check would pass and this re-query would catch the lie. Confirm the effect, not just the response.
The third integration case is the mirror image — delete an unknown code, assert 404, then re-query an untouched row (keep01) and assert it's still there. Delete removed exactly nothing it shouldn't.
Run both suites:
bashnpm testnpm run test:integration

There's a nuance in the red worth catching. With no delete route registered, DELETE /urls/missing matches no handler, so Fastify returns its own 404. That means the two unknown-code 404 cases pass on the start branch — a missing endpoint and a missing code both produce a 404. The cases that fail are the ones expecting a real deletion. The red is purely behavioural.
delete and the Route (Green)Switch to the finish branch:
bashgit checkout 17-delete-url-finishnpm install
The finish branch adds delete to the UrlStore interface and both stores (shown above), plus the new deleteRoute, registered in app.ts. The handler is thin: read the validated code, ask the store to delete it, and map the boolean to a status. There's no second lookup and no error handling around the Prisma call — deleteMany already gave us the boolean we need.
Run everything:
bashnpm testnpm run test:integration

Green on both. The unit suite proves the boolean-to-status mapping Docker-free; the integration suite re-queries Postgres to prove the row is actually gone.
When you're done, stop the container:
bashdocker compose down -v
The API surface is complete — five endpoints, all test-driven. Next we add centralized error handling: custom error classes and one setErrorHandler that maps each to a consistent body without leaking a stack trace.