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.
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:
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
Turn actions into nouns
6 guided stepsVerb-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
Build one error handler for every failure
6 guided stepsA 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
Reject bad input before it reaches a handler
6 guided stepsIf 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
Standardize how every list endpoint pages
6 guided stepsOFFSET-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
Write the spec, then prove it isn't lying
6 guided stepsA 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
You'll walk away with
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