Version routers
Time to get organized
The previous lesson got v1 and v2 running side by side. That works, but as you add more endpoints and more versions, things get messy fast. We need a project structure that keeps version-specific code separate while sharing everything that’s the same.
src/
v1/
routes.ts # v1 route definitions
schemas.ts # v1 Zod schemas
transformers.ts # v1 response formatters
v2/
routes.ts # v2 route definitions
schemas.ts # v2 Zod schemas
transformers.ts # v2 response formatters
shared/
queries.ts # Database queries (shared between versions)
db.ts # Database connection
app.ts # Combines all versions
server.ts Version-specific code lives in version directories. Shared code like database queries and business logic lives in shared/. This prevents duplication while keeping each version independent of the other. If you need to change how v2 formats its response, you only touch v2/transformers.ts. If you need to fix a database query, you fix it once in shared/queries.ts and both versions benefit.
Shared queries
Let’s build out the shared layer first. Both versions pull books from the same tables, so the query belongs in shared/queries.ts:
// src/shared/queries.ts
import db from "./db.js";
export interface BookRow {
id: string;
title: string;
genre: string;
description: string | null;
created_at: string;
author_id: string;
author_name: string;
avg_rating: number | null;
review_count: number;
}
export function getAllBooks(): BookRow[] {
return db
.prepare(
`
SELECT books.id, books.title, books.genre, books.description, 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 BookRow[];
}
export function getBookById(id: string): BookRow | null {
return (
(db
.prepare(
`
SELECT books.id, books.title, books.genre, books.description, 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
WHERE books.id = ?
`,
)
.get(id) as BookRow) ?? null
);
} BookRow is a TypeScript interface that describes what a row looks like when it comes back from the database. It includes every field either version might care about: the book basics, the author’s id and name, an average rating, and a review count. One row type, both versions.
getAllBooks returns every book, ordered by title. getBookById looks up a single book by id and returns it, or null if nothing matches. That’s all we need at the data layer, and neither function has any idea which version is calling it.
Version-specific transformers
The query gives us the same data for both versions. The difference is how each version shapes that data on the way out. A transformer is just a function that takes a BookRow and returns the response shape for that version.
Here’s v1:
// src/v1/transformers.ts
import type { BookRow } from "../shared/queries.js";
export function formatBookV1(row: BookRow) {
return {
id: row.id,
title: row.title,
author_name: row.author_name,
genre: row.genre,
description: row.description,
rating: row.avg_rating,
created_at: row.created_at,
};
}
export const formatBooksV1 = (rows: BookRow[]) => rows.map(formatBookV1); v1 is flat and uses snake_case. author_name is a string, rating is a single number, created_at stays snake_case. formatBookV1 picks fields out of a row and returns exactly that shape. formatBooksV1 runs the same function across an array.
And here’s v2:
// src/v2/transformers.ts
import type { BookRow } from "../shared/queries.js";
export function formatBookV2(row: BookRow) {
return {
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,
};
}
export const formatBooksV2 = (rows: BookRow[]) => rows.map(formatBookV2); Same input, different output. v2 nests the author into an object with id and name. It wraps ratings into average and count. And it switches to camelCase with createdAt. That’s the v2 contract.
The payoff is this: if you fix a bug in getAllBooks, both versions inherit the fix automatically. If you tweak v2’s response shape, v1 stays exactly where it was. Shared logic underneath, version-specific shapes on top.
The onRequest callback
Hectoday HTTP doesn’t have middleware in the traditional sense. But it does have an onRequest callback that runs before every route handler. We can use this for things that apply to all versions, like logging which API version each request uses:
// src/app.ts
import { setup } from "@hectoday/http";
import { v1Routes } from "./v1/routes.js";
import { v2Routes } from "./v2/routes.js";
export const app = setup({
onRequest: ({ request }) => {
const url = new URL(request.url);
const version = url.pathname.startsWith("/v2") ? "v2" : "v1";
console.log(`[${version}] ${request.method} ${url.pathname}`);
return { apiVersion: version };
},
routes: [...v1Routes, ...v2Routes],
}); Let’s walk through what this does. Every time a request comes in, onRequest fires before the route handler runs. It looks at the URL path to figure out which version is being called. If the path starts with /v2, it’s v2. Otherwise, it’s v1. It logs the version and method, then returns an object with apiVersion.
That returned object becomes available in route handlers via c.locals. So any handler can check c.locals.apiVersion to know which version is being served.
[!NOTE] The
onRequestcallback from Hectoday HTTP returns locals that are available in route handlers viac.locals. Here it setsapiVersionso any handler can check which version is being served. The Error Handling course’s global error handler lesson coveredonRequestin detail.
Adding version headers to responses
It’s good practice to include a version header in every response so clients can confirm which version they’re talking to. The onResponse callback is perfect for this:
export const app = setup({
onRequest: ({ request }) => {
const url = new URL(request.url);
const version = url.pathname.startsWith("/v2") ? "v2" : "v1";
return { apiVersion: version };
},
onResponse: ({ response, locals }) => {
response.headers.set("X-API-Version", locals.apiVersion ?? "v1");
// Add deprecation header for v1 (covered in Section 4)
if (locals.apiVersion === "v1") {
response.headers.set("Deprecation", "true");
response.headers.set("Sunset", "2025-06-01T00:00:00Z");
}
return response;
},
routes: [...v1Routes, ...v2Routes],
}); Try it out. Make a request and check the response headers:
curl -i http://localhost:3000/v2/books/book-1 The -i flag shows response headers. You should see X-API-Version: v2 in the output. Now try v1:
curl -i http://localhost:3000/v1/books/book-1 You’ll see X-API-Version: v1 along with Deprecation: true and the Sunset date. Every response now includes an X-API-Version header. Clients can check that header to verify they’re calling the version they expect. And notice the v1-specific part: if the request is for v1, we also add Deprecation and Sunset headers. We’ll cover those in detail in Section 4, but the idea is simple. v1 is going away, and these headers tell clients about it.
Making routes thin
With this structure, the routes themselves become very lean:
// src/v1/routes.ts
import { route } from "@hectoday/http";
import { z } from "zod/v4";
import { getAllBooks, getBookById } from "../shared/queries.js";
import { formatBookV1, formatBooksV1 } from "./transformers.js";
export const v1Routes = [
route.get("/v1/books", {
resolve: () => Response.json(formatBooksV1(getAllBooks())),
}),
route.get("/v1/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 book = getBookById(id);
if (!book) return Response.json({ error: "Not found" }, { status: 404 });
return Response.json(formatBookV1(book));
},
}),
]; Each route does three things: fetch data with a shared query, format it with a version-specific transformer, and return the response. No duplicated SQL. No complex branching.
That covers what goes out. But clients also send data in, and those request shapes change between versions too. That’s what we’ll tackle next.
Exercises
Exercise 1: Organize your project into v1/, v2/, and shared/ directories. Move the database queries into shared/queries.ts and build formatBookV1 and formatBookV2 transformers. Verify both versions still return their correct shapes.
Exercise 2: Add a new column to the database (for example, isbn). Add it to the shared query and to v2’s transformer, but not v1’s. Verify v2 includes isbn in its response and v1 is unchanged.
Exercise 3: Add an onRequest callback that logs the version, and use onResponse to set an X-API-Version header. Verify both work for v1 and v2 requests.
Why does shared code live outside the version directories?