Backend Foundations
Objectives
By the end of this chapter, you should be able to:
- Stand up a Fastify server with environment config, CORS, and a health check
- Establish the route โ repo layering pattern used for every resource in this app
- Build the first full CRUD resource end to end: watch regions
๐ก Why this matters: Every resource you add from here on โ in this course and in a real app โ flows through the same route โ repo pattern this lesson establishes. Get it right here, and a later module can wrap a repo function’s internals in a transaction without touching the route file that calls it at all.
Why Fastify, and Why a Repo Layer
Fastify over Express here mainly for two concrete things this app needs: first-class async route handlers without wrapper boilerplate, and a WebSocket plugin (@fastify/websocket, covered in the next lesson) that shares the same server instance instead of standing up a second one. Neither is a dramatic difference from Express, but both remove small amounts of friction that add up across a dozen routes.
The repo layer (src/db/repos/*.ts) exists to keep one rule consistent everywhere: route handlers never import Drizzle table objects directly. A route parses and validates input, calls a repo function, and shapes the HTTP response โ it never writes a db.select().from(...) itself. This isn’t ceremony for its own sake; it’s what makes it possible to wrap a repo function’s internals in a transaction later without touching the route file that calls it at all, as you’ll see below with setWatchRegionDefault and deleteWatchRegion. The row-shape-to-API-shape mapping (toApi() in every repo) lives in exactly one place per table too, instead of being repeated at every call site.
Environment Config
// apps/server/src/env.ts
import "dotenv/config";
function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing required environment variable: ${name}`);
return value;
}
export const env = {
DATABASE_URL: required("DATABASE_URL"),
SERVER_PORT: Number(process.env.SERVER_PORT ?? 4000),
POLL_INTERVAL_MS: Number(process.env.POLL_INTERVAL_MS ?? 15000),
WEB_ORIGIN: process.env.WEB_ORIGIN ?? "http://localhost:3000",
};
Failing fast on a missing DATABASE_URL at import time (not at first query) means a misconfigured deploy fails in the first second of startup with a clear message, not three requests later with a cryptic connection error.
The Fastify App Shell
cd apps/server
npm install fastify @fastify/cors zod
// apps/server/src/index.ts
import Fastify from "fastify";
import cors from "@fastify/cors";
import { env } from "./env.js";
import { registerApiRoutes } from "./routes/index.js";
async function main() {
const app = Fastify({ logger: true });
await app.register(cors, { origin: env.WEB_ORIGIN });
app.get("/health", async () => ({ ok: true, ts: Date.now() }));
await registerApiRoutes(app);
const shutdown = async (signal: string) => {
app.log.info(`received ${signal}, shutting down...`);
await app.close();
process.exit(0);
};
process.on("SIGINT", () => void shutdown("SIGINT"));
process.on("SIGTERM", () => void shutdown("SIGTERM"));
await app.listen({ port: env.SERVER_PORT, host: "0.0.0.0" });
}
main().catch((err) => {
console.error("Fatal startup error:", err);
process.exit(1);
});
logger: true turns on Fastify’s built-in pino logger โ every request gets a structured log line for free, which matters later when you’re debugging the poller and the WebSocket layer running concurrently and need to tell which log line came from which subsystem.
The SIGINT/SIGTERM handler matters more once the poller (next lesson) and the dedicated LISTEN connection (the lesson after that) exist: both are long-lived resources that need an explicit, ordered shutdown (stop polling, close the dedicated listener connection, close the pool) or a restart during development leaves orphaned connections against Postgres. It’s set up here, doing almost nothing yet, so later lessons only add one line each instead of retrofitting shutdown handling after the fact.
The Route Registry Pattern
// apps/server/src/routes/index.ts
import type { FastifyInstance } from "fastify";
import { registerWatchRegionRoutes } from "./watchRegions.js";
export async function registerApiRoutes(app: FastifyInstance): Promise<void> {
await app.register(
async (api) => {
registerWatchRegionRoutes(api);
},
{ prefix: "/api" }
);
}
Every resource gets one register*Routes(app) function, listed in this one file. This is the file every later module touches exactly once (one new import, one new call) when it adds a resource โ saved locations, airports, weather, and so on all land here the same way. Keeping the registry in one place means “what API surface does this server expose” is answerable by reading one short file, not grepping the whole routes directory.
The Watch Region Type
The shared type first, since both the repo and the route depend on it:
// packages/shared/src/watchRegion.ts
export interface WatchRegion {
id: number;
name: string;
lat: number;
lon: number;
radiusNm: number;
enabled: boolean;
isDefault: boolean;
createdAt: string;
}
(Add export * from "./watchRegion.js"; to packages/shared/src/index.ts.)
The Watch Regions Repo
// apps/server/src/db/repos/watchRegionsRepo.ts
import { asc, eq } from "drizzle-orm";
import type { WatchRegion } from "@skywatch/shared";
import { db } from "../client.js";
import { watchRegions } from "../schema.js";
function toApi(row: typeof watchRegions.$inferSelect): WatchRegion {
return {
id: row.id,
name: row.name,
lat: row.lat,
lon: row.lon,
radiusNm: row.radiusNm,
enabled: row.enabled,
isDefault: row.isDefault,
createdAt: row.createdAt.toISOString(),
};
}
export async function listWatchRegions(): Promise<WatchRegion[]> {
const rows = await db.select().from(watchRegions).orderBy(watchRegions.createdAt);
return rows.map(toApi);
}
export async function createWatchRegion(input: {
name: string;
lat: number;
lon: number;
radiusNm: number;
}): Promise<WatchRegion> {
// The very first region a fresh install creates has nothing to be
// default *relative to* -- make it the default immediately rather than
// leaving defaultRegionView null until someone remembers to set one by
// hand. Every later region defaults to isDefault: false via the schema.
const existing = await db.select({ id: watchRegions.id }).from(watchRegions).limit(1);
const isFirstRegion = existing.length === 0;
const [row] = await db
.insert(watchRegions)
.values({ ...input, isDefault: isFirstRegion })
.returning();
return toApi(row);
}
export async function setWatchRegionEnabled(id: number, enabled: boolean): Promise<WatchRegion | null> {
const [row] = await db.update(watchRegions).set({ enabled }).where(eq(watchRegions.id, id)).returning();
return row ? toApi(row) : null;
}
/**
* Makes `id` the default region, demoting whichever region currently holds
* that title. Wrapped in a transaction so "clear the old default" and "set
* the new one" commit or fail together -- without the transaction, a crash
* or concurrent request between the two writes could leave the table with
* either zero default regions or (briefly) two, and the partial unique
* index from Module 1 would reject the second write anyway, leaving the
* operation half-applied. `db.transaction` gives both statements one
* atomic outcome: either the new default is set and the old one is
* cleared, or neither write happens.
*/
export async function setWatchRegionDefault(id: number): Promise<WatchRegion | null> {
return db.transaction(async (tx) => {
await tx.update(watchRegions).set({ isDefault: false }).where(eq(watchRegions.isDefault, true));
const [row] = await tx
.update(watchRegions)
.set({ isDefault: true, enabled: true })
.where(eq(watchRegions.id, id))
.returning();
return row ? toApi(row) : null;
});
}
/**
* Deletes a region. If it happened to be the default, promotes the oldest
* remaining region rather than leaving the table with no default at all --
* a silently-defaultless state that would leave selectPrimaryAircraft (built
* in the frontend module) with nothing to prefer and "return to watch
* region" with nowhere to point.
*/
export async function deleteWatchRegion(id: number): Promise<boolean> {
return db.transaction(async (tx) => {
const [deleted] = await tx.delete(watchRegions).where(eq(watchRegions.id, id)).returning();
if (!deleted) return false;
if (deleted.isDefault) {
const [oldest] = await tx
.select({ id: watchRegions.id })
.from(watchRegions)
.orderBy(asc(watchRegions.createdAt))
.limit(1);
if (oldest) {
await tx.update(watchRegions).set({ isDefault: true }).where(eq(watchRegions.id, oldest.id));
}
}
return true;
});
}
.returning() on the insert/update/delete is what lets each of these functions hand back the actual row state in one round trip, instead of writing then doing a second select to find out what happened โ Postgres supports this natively and Drizzle exposes it directly. db.transaction(async (tx) => {...}) is the same idea extended across multiple statements: every query inside the callback runs against tx (not db), and either all of them commit or none do.
The Watch Regions Route
// apps/server/src/routes/watchRegions.ts
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import {
createWatchRegion,
deleteWatchRegion,
listWatchRegions,
setWatchRegionDefault,
setWatchRegionEnabled,
} from "../db/repos/watchRegionsRepo.js";
const postBodySchema = z.object({
name: z.string().trim().min(1).max(80),
lat: z.number().min(-90).max(90),
lon: z.number().min(-180).max(180),
radiusNm: z.number().min(1).max(250).default(200),
});
const idParamsSchema = z.object({ id: z.coerce.number().int().positive() });
const patchBodySchema = z.object({ enabled: z.boolean() });
export function registerWatchRegionRoutes(app: FastifyInstance): void {
app.get("/watch-regions", async () => {
const regions = await listWatchRegions();
return { regions };
});
app.post("/watch-regions", async (req, reply) => {
const parsed = postBodySchema.safeParse(req.body);
if (!parsed.success) {
return reply.status(400).send({ error: "invalid watch region", issues: parsed.error.issues });
}
const region = await createWatchRegion(parsed.data);
return reply.status(201).send({ region });
});
app.patch("/watch-regions/:id", async (req, reply) => {
const params = idParamsSchema.safeParse(req.params);
const body = patchBodySchema.safeParse(req.body);
if (!params.success || !body.success) return reply.status(400).send({ error: "invalid request" });
const region = await setWatchRegionEnabled(params.data.id, body.data.enabled);
if (!region) return reply.status(404).send({ error: "not found" });
return { region };
});
app.delete("/watch-regions/:id", async (req, reply) => {
const parsed = idParamsSchema.safeParse(req.params);
if (!parsed.success) return reply.status(400).send({ error: "invalid id" });
const deleted = await deleteWatchRegion(parsed.data.id);
if (!deleted) return reply.status(404).send({ error: "not found" });
return reply.status(204).send();
});
app.post("/watch-regions/:id/default", async (req, reply) => {
const parsed = idParamsSchema.safeParse(req.params);
if (!parsed.success) return reply.status(400).send({ error: "invalid id" });
const region = await setWatchRegionDefault(parsed.data.id);
if (!region) return reply.status(404).send({ error: "not found" });
return { region };
});
}
A dedicated POST /:id/default endpoint, rather than folding isDefault into the existing PATCH /:id body, is deliberate: setting a default is a different kind of operation (it has a side effect on a completely different row โ the previous default gets demoted) from patching one region’s own enabled flag, and giving it its own route makes that transactional, multi-row behavior visible at the API surface instead of hiding it inside a generic patch handler.
Notice idParamsSchema uses z.coerce.number() โ route params arrive as strings (req.params.id is "3", not 3), and coercing at the validation boundary means every downstream line of code works with a real number, not a string that happens to look numeric. This is a small pattern, but it’s the same idea you’ll see matter a lot more in later modules: validate and coerce once, at the edge, and trust the type everywhere after that.
Wire it into the registry:
// apps/server/src/routes/index.ts
import { registerWatchRegionRoutes } from "./watchRegions.js";
// ...
registerWatchRegionRoutes(api);
Try It
npm run dev:server
then, in another terminal:
curl http://localhost:4000/healthโ should return a 200.- Create a region:
The response should include a generated
curl -X POST http://localhost:4000/api/watch-regions \ -H "Content-Type: application/json" \ -d '{"name":"Test Region","lat":40.6413,"lon":-73.7781,"radiusNm":100}'id,createdAt, and โ since this is the first region ever created โisDefault: true, with no extra step required. - Create a second region the same way and confirm it comes back
isDefault: false. POST /api/watch-regions/:id/defaultagainst the second region’s id, thenGET /api/watch-regionsand confirm exactly one region hasisDefault: trueโ the second one, not the first.- POST with
lat: 200and confirm you get a 400 with Zod’s validation detail rather than a raw database error โ that’s the validation boundary doing its job before anything reaches the repo layer.
Recap
- Route handlers parse and validate, call a repo function, and shape the response โ they never touch a Drizzle table directly. That separation is what let
setWatchRegionDefaultanddeleteWatchRegiongrow real transactional logic without the route file changing at all. - The first region created is automatically the default; every write path that could leave zero or two default regions (creating the first region, deleting the current default) accounts for it, backed by a partial unique index in the schema as a last line of defense.
db.transactionis how a repo function makes two writes succeed or fail together.
Next lesson: the background poller that actually fetches live aircraft data for these regions, and the detection logic that watches it for emergency squawks and overflights.