Side-by-side versions
Both versions, running together
We’ve picked URL path versioning. Now let’s build it. The key principle here is that v1 and v2 run in the same application, share the same database, and serve requests at the same time. There’s no separate server for v2. It’s all one process.
The v2 response shape
Let’s compare what v1 returns today with what we want v2 to return.
v1:
{
"id": "book-1",
"title": "The Left Hand of Darkness",
"author_name": "Ursula K. Le Guin",
"genre": "science-fiction",
"rating": 4.5,
"created_at": "2024-01-15T10:30:00"
} v2:
{
"id": "book-1",
"title": "The Left Hand of Darkness",
"author": {
"id": "author-1",
"name": "Ursula K. Le Guin"
},
"genre": "science-fiction",
"ratings": {
"average": 4.5,
"count": 2
},
"createdAt": "2024-01-15T10:30:00"
} Look at the differences. author_name (a flat string) becomes author (an object with both id and name). rating (a single number) becomes ratings (an object with average and count). created_at (snake_case) becomes createdAt (camelCase).
Every single one of these changes is a breaking change. A client reading author_name would get undefined in v2. A client doing Math.round(response.rating) would get NaN because rating is now an object. This is exactly why we need both versions running at the same time.
Building the v2 routes
// src/v2/routes.ts
import { route } from "@hectoday/http";
import { z } from "zod/v4";
import db from "../db.js";
export const v2Routes = [
route.get("/v2/books", {
resolve: () => {
const rows = db
.prepare(
`
SELECT books.id, books.title, books.genre, books.created_at,
authors.id AS author_id, authors.name AS author_name,
(SELECT AVG(rating) FROM reviews WHERE book_id = books.id) AS avg_rating,
(SELECT COUNT(*) FROM reviews WHERE book_id = books.id) AS review_count
FROM books JOIN authors ON books.author_id = authors.id
ORDER BY books.title
`,
)
.all() as any[];
const books = rows.map((row) => ({
id: row.id,
title: row.title,
author: { id: row.author_id, name: row.author_name },
genre: row.genre,
ratings: { average: row.avg_rating, count: row.review_count },
createdAt: row.created_at,
}));
return Response.json(books);
},
}),
route.get("/v2/books/:id", {
request: { params: z.object({ id: z.string() }) },
resolve: (c) => {
if (!c.input.ok) return Response.json({ error: c.input.issues }, { status: 400 });
const { id } = c.input.params;
const row = db
.prepare(
`
SELECT books.*, authors.id AS author_id, authors.name AS author_name,
(SELECT AVG(rating) FROM reviews WHERE book_id = books.id) AS avg_rating,
(SELECT COUNT(*) FROM reviews WHERE book_id = books.id) AS review_count
FROM books JOIN authors ON books.author_id = authors.id
WHERE books.id = ?
`,
)
.get(id) as any;
if (!row) return Response.json({ error: "Not found" }, { status: 404 });
return Response.json({
id: row.id,
title: row.title,
author: { id: row.author_id, name: row.author_name },
genre: row.genre,
description: row.description,
ratings: { average: row.avg_rating, count: row.review_count },
createdAt: row.created_at,
});
},
}),
]; Let’s walk through what’s happening here. The SQL query is almost the same as v1, but it also pulls the author’s id (not just their name) and calculates both the average rating and the count of reviews. The raw database rows get transformed into the v2 shape: nested author object, nested ratings object, and camelCase field names.
The data comes from the same database. The same books, the same authors, the same reviews. The only difference is how that data gets shaped for the response.
Combining both versions
Update src/app.ts to include the v2 routes:
// src/app.ts
import { setup } from "@hectoday/http";
import { v1Routes } from "./v1/routes.js";
import { v2Routes } from "./v2/routes.js";
export const app = setup({
routes: [...v1Routes, ...v2Routes],
}); Both sets of routes are spread into the same routes array. /v1/books hits the v1 handler. /v2/books hits the v2 handler. Same database, same server, same process.
Verifying both work
Restart the server and try both versions against the same book:
curl http://localhost:3000/v1/books/book-1 You’ll see the v1 shape: flat author, single rating, snake_case. Now try v2:
curl http://localhost:3000/v2/books/book-1 Nested author, ratings object, camelCase. Same book, same data, formatted differently. The mobile app keeps calling v1 and sees no change. A new client can call v2 and get the improved structure.
But there’s an issue with this approach. Look at the v1 and v2 route files. The SQL queries are nearly identical, and so is the error handling. If we fix a bug in the v1 query, we’d have to remember to fix it in v2 too. That duplication is going to cause problems as the API grows. The next lesson introduces a project structure that solves this.
Exercises
Exercise 1: Add v2 routes alongside v1. Verify both work by calling them with curl.
Exercise 2: Add a new book. Verify it appears in both /v1/books and /v2/books with their respective formats.
Exercise 3: Compare the v1 and v2 responses for the same book. List every difference.
Why do v1 and v2 share the same database?