CodingNic

Backend Services

Directing the Fastify Server and First CRUD Resource

Backend Services 40 min read

Directing the Fastify Server and First CRUD Resource

Objectives

By the end of this chapter, you should be able to:

  • Direct an AI assistant to stand up a Fastify server with env config, CORS, a health check, and graceful shutdown
  • Get an AI assistant to establish a route → repo layering pattern, and verify it actually respects that pattern instead of just naming files that suggest it does
  • Review a full CRUD resource (watch regions) for the one write path that needs a real database transaction, not two statements that happen to usually run in order

💡 Why this matters: Every resource added from here on, in this course and in a real app, flows through whatever pattern gets established in this lesson. If you let an AI assistant get sloppy about the route → repo boundary here, you won’t notice for weeks, and by then a dozen routes have quietly inlined their own queries.

Why the Repo Layer Is the Thing to Watch

An AI assistant asked for “a Fastify CRUD endpoint for watch regions” will happily produce something that works on the first request. What it might not do, unless you say so directly, is keep every Drizzle table access behind a repo function. It’s completely plausible for a model to write db.select().from(watchRegions).where(...) straight inside a route handler, because that’s shorter, it’s a common pattern in a lot of training data, and it answers the prompt “make this endpoint work” just fine. The problem shows up later: the entire reason for a repo layer is that a repo function’s internals can grow real logic, like wrapping two writes in a transaction, without the route file that calls it ever changing. A route that queries the table directly can’t get that upgrade for free. It has to be rewritten, and if you don’t notice it needs rewriting, it just doesn’t get the transaction.

So the prompt below states the layering rule explicitly, the same way the schema lesson stated database invariants explicitly: don’t assume the AI will infer an architectural convention from a request that would work fine without it.

The Prompt

code
I'm building the SKYWATCH backend in apps/server, on top of the Drizzle schema and client from the previous lesson (db/schema.ts, db/client.ts exporting `pool` and `db`). Set up the Fastify server shell and the first full CRUD resource. Server shell: - src/env.ts: fail fast at import time if DATABASE_URL is missing. Also read SERVER_PORT (default 4000), POLL_INTERVAL_MS (default 15000), WEB_ORIGIN (default http://localhost:3000). - src/index.ts: Fastify app with logger enabled, @fastify/cors registered against env.WEB_ORIGIN (not a wildcard), a GET /health route returning { ok: true, ts }, and SIGINT/SIGTERM handlers that close the app cleanly and exit. Structure it so later lessons can add one line each to this shutdown handler for a poller and a dedicated Postgres listener connection, without restructuring it. - src/routes/index.ts: a single registerApiRoutes(app) function that registers every resource under an /api prefix. This is the one file every future resource touches to get wired in. Hard architectural rule: route handlers must NEVER import a Drizzle table or call db.select/insert/update/delete directly. Every route calls a function from a repo module in src/db/repos/, and the repo module owns all query logic and the row-to-API-shape mapping. This needs to hold for every route below, not just the simple ones. First resource: watch regions, full CRUD, in src/db/repos/watchRegionsRepo.ts and src/routes/watchRegions.ts. - GET /api/watch-regions -- list all, ordered by createdAt - POST /api/watch-regions -- create one. Validate with Zod: name (1-80 chars), lat (-90 to 90), lon (-180 to 180), radiusNm (1-250, default 200). If this is the very first region in the table, it must come back isDefault: true automatically, no separate call required. Every later region defaults to isDefault: false. - PATCH /api/watch-regions/:id -- body { enabled: boolean }, coerce the :id param to a number at the validation boundary - DELETE /api/watch-regions/:id -- if the deleted region was the default, promote the oldest remaining region to default so the table is never left with zero default regions - POST /api/watch-regions/:id/default -- makes :id the default, demoting whichever region currently holds that title. This has to be atomic: either both the demotion and the promotion happen, or neither does. A crash between the two writes must not be able to leave the table with zero or two default regions. Add the WatchRegion type to packages/shared. Show me every file.

What It Built

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",
};
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);
});
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" }
  );
}
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;
}
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> {
  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;
}

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;
  });
}

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;
  });
}
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 };
  });
}

Review This

Did every route actually go through the repo, or did one sneak in a direct query? This is the single most important thing to check in this whole lesson, and it’s easy to miss because a route that inlines db.select().from(watchRegions).where(eq(watchRegions.id, id)) works exactly as well as one that calls listWatchRegions(). Nothing fails, nothing looks wrong in a manual test. Read every handler in watchRegions.ts and confirm the only things it imports from ../db/ are functions from watchRegionsRepo.ts, never schema.js or client.js directly. If you find a direct query, the fix is: “the GET/PATCH handler for watch-regions queries the table directly, move that query into watchRegionsRepo.ts as a named function and call it from the route instead.”

Is setWatchRegionDefault actually wrapped in db.transaction, or is it two awaits in a row? Two sequential db.update() calls with no transaction will pass every happy-path test you throw at them, because in a quick manual check nothing ever crashes between the two writes. The bug only shows up under real conditions: a process restart, a thrown error, or a second concurrent request landing between “clear the old default” and “set the new one.” Open setWatchRegionDefault and confirm both writes happen against a tx parameter inside a db.transaction(async (tx) => {...}) callback, not against db directly. If they’re two bare await db.update(...) calls, the fix is: “setWatchRegionDefault needs to wrap both writes in a single db.transaction so they commit or fail together, not run as two independent statements.”

Does deleteWatchRegion actually re-promote a new default, or does it just delete the row? This one is easy for an AI to skip entirely, because “delete a region” sounds complete once the row is gone, and the requirement to backfill a new default only applies in the specific case where the deleted region happened to be the default one. A version that just runs db.delete(watchRegions).where(eq(watchRegions.id, id)) will work fine for every non-default region you delete in testing, and only reveal the gap the one time you delete the actual default and the app is left with zero default regions. Check for the if (deleted.isDefault) branch and the follow-up select for the oldest remaining region. If it’s missing, the fix is: “deleteWatchRegion needs to check whether the deleted region was the default, and if so promote the oldest remaining region to default, wrapped in the same transaction as the delete.”

Try It

  1. Run the prompt above against your AI coding assistant, with the Module 1 monorepo and schema already in place.
  2. Read every file it produced against the three checks above before running anything.
  3. Start the server with npm run dev:server, then curl http://localhost:4000/health and confirm a 200.
  4. 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}'
    
    Confirm the response comes back isDefault: true with no separate call needed, since it’s the first region.
  5. Create a second region the same way and confirm it comes back isDefault: false.
  6. 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.
  7. POST with lat: 200 and confirm a 400 with Zod’s validation detail, not a raw database error.
  8. Delete the current default region, then GET /api/watch-regions and confirm some other region picked up isDefault: true automatically, not none of them.

Recap

  • The route → repo boundary is the thing most worth checking by hand in AI-generated backend code, because a direct query in a route handler works fine right up until the day a repo function underneath it needs to grow real logic.
  • setWatchRegionDefault and deleteWatchRegion are the two write paths that need an actual db.transaction, not two statements that happen to usually run in order. Both look identical to their transactional versions in a quick manual test.
  • None of these three checks show up as a crash on a fresh install. They only show up under a restart, a concurrent request, or the specific delete-the-default case, which is exactly why Try It ends by deliberately deleting the default region.

Next lesson: directing the poller and the edge-triggered detection engine that watches its output for emergency squawks and overflights.