Back to path
BeginnerTradepost · Project 3 of 12 ~6h· 5 milestones

Design the contract: REST, errors and versioning

Continues from the last build: The schema models merchants, orders, line items and payments with real constraints.

Last rung you got the schema right. Orders, line items and payments are backed by real constraints and integer cents.

REST resource designFastify JSON Schema validationCentralized error handlingKeyset paginationOpenAPI 3.1 authoringAPI error taxonomy designPostgres index verification with EXPLAIN ANALYZE

What you'll build

By the end, /v1/orders and /v1/orders/:id/payments read as nouns a stranger can guess on sight. Every 400, 404, 409 and 500 comes back as the exact same {error:{code,message,details}} shape with zero stack traces reaching a client. Every route rejects malformed input before a handler function is ever entered. List endpoints page with the same limit and cursor convention everywhere, backed by a real index. And openapi.yaml, served at /docs, matches what curl actually returns, field for field.

See how we teach, before you sign up

You don't just get code dumped on you. Every starter file and every solution is explained line-by-line, in plain English. Here's one real file from this project:

src/routes/orders.tstypescript
import type { FastifyPluginAsync } from 'fastify';
import { db } from '../db/client';
import { orders } from '../db/schema';
import { eq } from 'drizzle-orm';

const ordersRoutes: FastifyPluginAsync = async (fastify) => {
  fastify.post('/v1/createOrder', async (request, reply) => {
    const body = request.body as any;
    const [order] = await db.insert(orders).values(body).returning();
    return order;
  });

  fastify.get('/v1/orderStatus', async (request, reply) => {
    const { id } = request.query as any;
    const [order] = await db.select().from(orders).where(eq(orders.id, id));
    if (!order) {
      reply.code(404);
      return { message: 'not found' };
    }
    return order;
  });

  fastify.post('/v1/payOrder', async (request, reply) => {
    const body = request.body as any;
    try {
      return { ok: true };
    } catch (err) {
      reply.code(500);
      return { error: String(err) };
    }
  });
};

export default ordersRoutes;

Reading this file

  • fastify.post('/v1/createOrder'A verb in the path, this is the naming problem milestone 1 fixes.
  • const body = request.body as any;No schema means any malformed body reaches the database driver untouched.
  • return { message: 'not found' };A different error shape than the one below, this is the inconsistency milestone 2 removes.
  • return { error: String(err) };Every failure invents its own error shape, this is exactly what milestone 2 replaces with one envelope.

The messy starting point: verb-based paths, no schemas, and three different error shapes for three different failures.

That's 1 of 8 explained code blocks in this single project.

The build, milestone by milestone

  1. 1

    Turn actions into nouns

    6 guided steps

    Verb-based routes force every client to memorize your internal function names. Nouns plus HTTP methods let a stranger guess GET /v1/orders/:id/payments the moment they have seen POST /v1/orders, with no documentation required.

  2. 2

    Build one error handler for every failure

    6 guided steps

    A merchant integrator should be able to write one piece of error-handling code on their side that works for every endpoint. If each route invents its own error shape, they have to special-case every single one, and any 500 that leaks a stack trace is an information disclosure bug, not just an inconvenience.

  3. 3

    Reject bad input before it reaches a handler

    6 guided steps

    If validation lives inside the handler as if-statements, every route reinvents it slightly differently and it is easy to forget a check. Schema validation at the boundary means the handler function body only ever runs with a body shaped exactly the way you declared.

  4. 4

    Standardize how every list endpoint pages

    6 guided steps

    OFFSET-based pagination gets slower the deeper you page because Postgres still has to scan and discard every prior row, and page numbers shift under a client if a row is inserted or deleted mid-scroll. A cursor keyed on (created_at, id) lets Postgres seek directly via an index and gives every client a stable position.

  5. 5

    Write the spec, then prove it isn't lying

    6 guided steps

    A spec that was written once and never touched again is worse than no spec, it actively misleads. Documenting the same status codes and shapes the error handler and route schemas already enforce turns the spec into a contract you can trust instead of a guess.

What's inside when you start

3 starter files, ready to clone
5 guided milestones
5 full reference solutions
8 code blocks explained line-by-line
5 "is it working?" checks
6 interview questions it prepares you for

You'll walk away with

/v1/orders and /v1/orders/:id/payments live as resource-oriented routes with no verbs left in any path
A single Fastify error handler producing {error:{code,message,details}} for every 400, 404, 409 and 500, with zero stack traces reaching a client
Route schemas (params, querystring, body) on every orders and payments route, rejecting unknown fields with additionalProperties: false
Cursor-based pagination on GET /v1/orders and GET /v1/orders/:id/payments backed by a (created_at, id) index, capped at limit=100
openapi.yaml served at /docs that matches real curl responses field for field, with examples for every documented response

This is portfolio-grade. Build it free.

Sign up to unlock every milestone step-by-step, the code skeletons, full reference solutions, and checkable tasks, with your progress saved as you build.

Start building