CodingNic

Backend Services

Backend Foundations

Backend Services 40 min read

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

ts
// 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

bash
cd apps/server
npm install fastify @fastify/cors zod
ts
// 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

ts
// 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:

ts
// 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

ts
// 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

ts
// 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:

ts
// apps/server/src/routes/index.ts
import { registerWatchRegionRoutes } from "./watchRegions.js";
// ...
registerWatchRegionRoutes(api);

Try It

bash
npm run dev:server

then, in another terminal:

  1. curl http://localhost:4000/health โ€” should return a 200.
  2. Create a region:
    bash
    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}'
    
    The response should include a generated id, createdAt, and โ€” since this is the first region ever created โ€” isDefault: true, with no extra step required.
  3. Create a second region the same way and confirm it comes back isDefault: false.
  4. POST /api/watch-regions/:id/default against the second region’s id, then GET /api/watch-regions and confirm exactly one region has isDefault: true โ€” the second one, not the first.
  5. POST with lat: 200 and 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 setWatchRegionDefault and deleteWatchRegion grow 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.transaction is 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.