We can now create, redirect, count, list, and inspect URLs. This chapter closes the loop with removal. We will build DELETE /urls/:code to remove a short URL. Then, we will write a test to prove the database row is actually gone, rather than just trusting the HTTP status code.

delete(shortCode) method on the UrlStore interface, placed behind a thin DELETE /urls/:code handler.true if it removed a row, and false if the code was missing. The handler maps this directly to a 204 or 404 status.Before we write the code, you should understand three concepts that shape this endpoint.
Idempotency. An operation is idempotent if running it twice leaves the system in the exact same state as running it once. Deleting abc123 and then deleting it again both result in abc123 no longer existing. The only question is what the second call should report back to the user.
204 vs 404. A 204 No Content status means success, but there is no data to send back. We removed the row, so the response body is empty. A 404 Not Found means there was nothing there to delete. We will return a 404 for a missing code.
Soft vs hard delete. A soft delete keeps the database row but flips a deletedAt flag. A hard delete physically removes the row using DELETE FROM urls. We will use a hard delete. This means there is no leftover data to filter out during future database reads.
Deleting a record sounds simple, but it requires two specific design choices.
First, how does the handler know what status code to return? If you look the code up and then delete it, you make two trips to the database. This creates a race condition if the row changes in between. Instead, our delete method will return a simple boolean: did I remove a row? This collapses the check and the deletion into one safe operation. That boolean is all the handler needs to choose between a 204 and a 404.
Second, what does "delete" actually mean? If you hard-delete, the row is gone and all your existing read queries continue to work perfectly. If you soft-delete, the history survives, but every single query in your app must now filter out dead rows. If you forget one filter, you have a bug. We use a boolean return and a hard delete because they keep the code simple and consistent.
delete Method on the SeamEvery endpoint so far has gone through the UrlStore interface we built in chapter 6. Delete is no different. We will add one new 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 most important detail here. The delete method returns a Promise<boolean>. A true result means a row was removed, and false means there was nothing to remove. We do not call findByCode first to check if the record exists. The delete operation itself reports whether it found a match.
The in-memory store gets this behavior almost for free. JavaScript's 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);}
This takes just one line. Map.prototype.delete returns true if the key existed and was removed, and false otherwise. This perfectly matches the contract our interface promises.
deleteMany over deleteThe Prisma store requires a bit more thought. 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 is prisma.url.delete({ where: { shortCode } }). However, this method throws a P2025 error if no row matches. To use it, you would have to wrap the call in a try/catch block, look for that specific error code, and translate it into a false. Using exceptions for normal control flow—like a missing code—makes the code harder to read.
deleteMany never throws an error on a miss. Instead, it returns an object with a count property showing the number of rows removed. Because shortCode is marked as @unique in our schema, that count will only ever be 0 or 1. We can safely check if count > 0 to get the boolean our interface wants. There are no try/catch blocks and no error codes to match.
We mentioned an open question earlier. Deleting an existing code returns a 204. But what should happen if you try to delete a missing or already-deleted code? There are two valid approaches.
204. This is a good choice if clients retry requests blindly and you want every retry to look successful.404 tells the caller the resource was not there to delete. This provides useful feedback if a user double-clicks a button or types the wrong code. An idempotent 204 hides this information, making it impossible for the caller to tell a real deletion from a non-action.We chose the 404 for two reasons. First, it is informative. The response clearly distinguishes between "I removed it" and "there was nothing to remove". Second, it is consistent with the rest of the API. The redirect and stats endpoints already return a 404 for an unknown code. Making DELETE behave the same way keeps a uniform rule across every route: an unknown code always results in a 404.
The route handler encodes this 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. The 404 shape reuses the same { error, message } body that our other endpoints return. The handler calls urlStore.delete(code). A false result becomes a 404, while a true result triggers reply.code(204); return null. Because a 204 carries no body, returning null is the correct behavior.
We chose a hard delete for this project. Here is the trade-off behind that choice.
deletedAt timestamp column and update it instead of removing the row. History survives, which is useful for audit trails or preserving historical stats. However, the complexity spreads everywhere. Every read query must now include a WHERE deletedAt IS NULL filter. If you forget that filter on even one query, deleted URLs will leak back into your results.As a general rule, choose a soft delete when you genuinely need the history and are willing to add filters to every read query. Choose a hard delete when simplicity is more important and losing the row's history is acceptable. For our URL shortener, a deleted link is meant to be gone. A hard delete is the right default, and it means we do not have to write a database migration to alter the urls table.
DELETE /urls/:code shares a similar path shape with the redirect's /:code catch-all. Fortunately, there is no collision. DELETE is a distinct HTTP method, so Fastify routes it completely separately from the GET requests. We register it alongside the other URL routes in app.ts:
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 the dependencies, and bring up the database:
bashgit checkout 17-delete-url-startnpm installdocker compose up -d --wait
The start branch includes the tests but no implementation. The unit suite uses the in-memory UrlService and covers five cases: a successful 204 with an empty body, proof that reading a deleted code returns a 404, a 404 for a code that never existed, a 404 for an already-deleted code, and proof that a 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);});
The second test case is the most important. It does not stop at checking the 204 status. It deletes the code, then tries to read it through two other endpoints to ensure both return a 404. If our code returned the right status but left the row behind, this test would catch the mistake. It proves the code is actually gone.
The already-deleted case tests our idempotency decision. It deletes twice0, expects a 204, deletes it again, and expects a 404. The second delete sees no row to remove, so the boolean is false and the handler returns the informative 404.
The integration suite tests the happy path against the real database using the PrismaUrlRepository. It confirms the deletion in 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 a critical assertion. It does not trust the 204 response. Instead, it queries the database directly with prisma.url.findUnique and asserts that the result is null. The status code claims the record was deleted, but findUnique proves it. Always confirm the actual effect, not just the HTTP response.
The third integration case is the mirror image. It deletes an unknown code, expects a 404, then queries an untouched row (keep01) to assert it is still there. This proves the delete operation did not accidentally remove the wrong data.
Run both suites:
bashnpm testnpm run test:integration

There is a small nuance worth noticing in the test output. Because we have not registered the delete route yet, DELETE /urls/missing matches no handler. Fastify automatically returns a 404 for missing routes. This means the two unknown-code test cases actually pass on the start branch. The cases that fail are the ones expecting a real deletion.
delete and the Route (Green)Switch to the finish branch:
bashgit checkout 17-delete-url-finishnpm install
The finish branch adds the delete method to the UrlStore interface and both store implementations. It also includes the new deleteRoute, registered in app.ts. The handler is very thin. It reads the validated code, asks the store to delete it, and maps the boolean result to a status code. There is no second database lookup and no error handling around the Prisma call, because deleteMany cleanly provides the boolean we need.
Run everything:
bashnpm testnpm run test:integration

Both suites now pass. The unit suite proves the boolean-to-status mapping works without Docker. 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 now complete. We have built five endpoints, all fully test-driven. Next, we will add centralized error handling. We will create custom error classes and a single setErrorHandler that maps errors to a consistent response body without leaking sensitive stack traces.