01 / The idea
A class tree is a perfectly good first answer.
You’re building the API for an invoicing app. Every request gets a log line. Most routes need someone signed in, and each person gets a small request budget so a stuck script can’t hammer the server. Refunds also need an admin.
A tree of route classes says that neatly. Route logs. SignedInRoute extends it with the session check and the budget. AdminRoute adds the role check. Invoices and Refunds only have to say what they return.
Routelogs every requestSignedInRoutechecks the session and the rate limitInvoicesreturns 3 invoicesAdminRoutechecks for an adminRefundsqueues a refund
Read the treeTypeScript · the code this lesson starts from
export abstract class Route {
private readonly write: (line: string) => void;
constructor(write: (line: string) => void) {
this.write = write;
}
handle(request: Request): Response {
this.write(`${request.method} ${request.path}`);
return this.respond(request);
}
protected abstract respond(request: Request): Response;
}
export abstract class SignedInRoute extends Route {
private readonly seen = new Map<string, number>();
protected respond(request: Request): Response {
if (!request.session) return { status: 401, body: 'Sign in first' };
const count = (this.seen.get(request.session.user) ?? 0) + 1;
this.seen.set(request.session.user, count);
if (count > 2) return { status: 429, body: 'Slow down' };
return this.signedIn(request.session);
}
protected abstract signedIn(session: Session): Response;
}
export abstract class AdminRoute extends SignedInRoute {
protected signedIn(session: Session): Response {
if (session.role !== 'admin') return { status: 403, body: 'Needs admin' };
return this.admin();
}
protected abstract admin(): Response;
}
export class Invoices extends SignedInRoute {
protected signedIn(): Response {
return { status: 200, body: '3 invoices' };
}
}
export class Refunds extends AdminRoute {
protected admin(): Response {
return { status: 200, body: 'Refund queued' };
}
} Go has no class inheritance, so this starting point is TypeScript only. Both languages meet again at the list of parts in section 02.
Then the payment provider needs a webhook. It should be logged and rate-limited like everything else, but it has no session. The provider proves who it is with a signature instead. Where does it go?
Under Route, it gets the log but not the limit. Under SignedInRoute, it gets the limit, plus a session check it can never pass. You
could copy the limit into a new class, or add a skipSession flag to the base. Each
of those works once. None of them unsticks the limit from the session check.
Composition builds a behavior from parts you hand it, instead of from a parent it inherits. Each route lists the checks it needs, in order. The webhook takes the log and the limit, and swaps the session check for a signature.
If you write components, you already work this way. You have never written class DeleteDialog extends Dialog. You render a <Dialog> and hand it the body and the buttons. React’s docs are blunt about it: they “haven’t found any use cases where we would recommend creating component inheritance
hierarchies.” Section 05 follows that into a settings form.
02 / See the shape
Start with the loop. Then hand it parts.
The basic form is the whole mechanism: a list of parts, a handler, and a loop that stops at the first part that answers. Switch to In the wild for the parts themselves, and At the call site for the three routes built from them.
Both languages build the same routes and pass the same fourteen request scenarios. Each says it in its own way.
A route is a list of parts and a handler. The first part that answers ends the request. That loop is all the machinery this needs.
export function route(parts: Part[], handler: Handler): Handler {
return (request) => {
// The first part that answers ends the request, so the order is part of the route.
for (const part of parts) {
const answer = part(request);
if (answer) return answer;
}
return handler(request);
};
}
export const requireSession: Part = (request) =>
request.session ? null : { status: 401, body: 'Sign in first' };
const invoices = route([requireSession], () => ({ status: 200, body: '3 invoices' })); func Route(parts []Part, handler Handler) Handler {
return func(request Request) Response {
// The first part that answers ends the request, so the order is part of the route.
for _, part := range parts {
if response, answered := part(request); answered {
return response
}
}
return handler(request)
}
}
func RequireSession(request Request) (Response, bool) {
if request.Session == nil {
return Response{401, "Sign in first"}, true
}
return Response{}, false
}
var invoices = Route([]Part{RequireSession}, func(Request) Response {
return Response{200, "3 invoices"}
}) Reading the TypeScriptFunctions as parts, closures as state
A Part is a function type. requireSession is a part as it
stands. rateLimit(2, clientKey) is a function that returns a part,
and the Map it creates lives on in that returned function. Call it twice and
you get two budgets.
null means “keep going.” A Response means “this part has
answered.” The parts are synchronous here to keep the loop readable. In a real server
they would return promises, and the loop would await each one.
Reading the Go(Response, bool), closures, and a lock
(Response, bool) is Go’s comma-ok shape. The bool says whether the part
answered, so a zero Response never has to stand for “no answer.”
RateLimit guards its map with a sync.Mutex. Go’s HTTP server
calls handlers from separate goroutines, so two requests can reach the same part at
once. The tests send a hundred concurrent requests through a budget of fifty and check
that exactly fifty get through.
Struct embedding looks a little like extends, but it isn’t overriding. When
a method of the embedded type runs, its receiver is the inner value, so it never calls
the outer type’s version of a method. That’s one reason Go code reaches for lists of
handlers rather than trees. See Effective Go on embedding.
03 / Follow the request
Watch the webhook stop fitting, then fit.
Five steps. The first two use the class tree, and the last three use the composed routes, all running the TypeScript you just read. Before each step, guess which part answers.
In Try it, build the route yourself. Move a part, remove one, and send the same request three times.
Behavior from a list you can read.
REQUESTGET /invoices · ana, member · no signature · from ana-laptop
GET /invoices class Invoices- Log the request from Route
- Check the session from SignedInRoute
- Rate limit from SignedInRoute
- 3 invoices in Invoices
POST /refunds class Refunds- Log the request from Route
- Check the session from SignedInRoute
- Rate limit from SignedInRoute
- Check for admin from AdminRoute
- Refund queued in Refunds
Class tree. GET /invoices · ana, member · no signature · from ana-laptop. Log the request passed; Check the session passed; Rate limit passed. The handler ran. Response 200, 3 invoices.
The tree works.
Route logs. SignedInRoute checks the session and the limit. Invoices answers, and ana gets 200.
Reduced motion: choose a scene to see its completed state.
Read this scene
Route logs. SignedInRoute checks the session and the limit. Invoices answers, and ana gets 200.
Class tree. GET /invoices · ana, member · no signature · from ana-laptop. Log the request passed; Check the session passed; Rate limit passed. The handler ran. Response 200, 3 invoices.
Watch restarts when you return. Step through keeps your selected step. Try it starts a fresh server each time you open it.
What the list buys you
Now put names on what you just watched. These are the words you’ll hear in a design review, and each one points at something on this page.
- Decoupled parts
rateLimitknows nothing about sessions, signatures, or routes. In the tree,SignedInRoute.respondholds the session check and the limit in one method, so taking one means taking both. That coupling is the whole webhook problem.- No fragile base class
- Edit
SignedInRoute.respondand every route beneath it changes, including the ones you weren’t thinking about. Edit one route’s list and only that route changes. - Parts you can test alone
rateLimit(2, clientKey)takes a plain request and returns an answer, so its test needs nothing else. To test the tree’s limit, you build anInvoicesand go throughRoute.handle.- Mixes without a class for each
- Five parts can make many different routes. A tree needs a class for every mix it supports. A list just names the parts.
- Chosen when the server starts
createServercan build different lists from configuration, say no limit in local development, without writing a new class.
None of that is free. Section 08 is the other side of the ledger: the decisions the tree used to make for you.
04 / Try a decision
A tidy-looking reorder loses the evidence.
Someone reorders the webhook so the signature check runs first: [verifySignature(secret), logged, rateLimit(2, clientKey)]. Cheap check up
front, and the tests still pass. Two weeks later the payment provider asks whether you
received their retries signed with an old secret. Your log has nothing.
05 / Give it a real job
Build the parts once. Hand them to the routes.
In the real server, createServer runs once at startup. It reads the webhook secret
from configuration, creates each part, and hands every route its list. Requests arrive later and
only ever run those lists.
Owns the lists
Which parts each route runs, and in which order.
Own one decision each
Session, role, signature, budget, log line.
Does the route’s work
Runs only when every part lets the request through.
Startup is where sharing gets decided. Each rateLimit(2, clientKey) call makes a
fresh budget, so invoices and refunds count separately. Hand the same limiter to both routes and
they share one. Either can be right. The list makes the choice visible. The tree made it too,
by giving every route instance its own map.
The example leaves out real signature verification over the request body, time windows for the limit, async handlers, and a router that matches paths. None of those change where the parts live.
Build UIs?Every dialog you fill with children is composition, and one day a settings form makes you own the parts.
Where it already is in your components
You pass parts into components all day. A delete-invoice dialog doesn’t extend Dialog. It renders one and fills its slots: the body as children, the buttons as an actions prop. React’s docs call this specialization:
the specific component renders the general one and configures it through props.
Svelte 5 leaves you no other road. Its migration guide says components “are functions,” so there is no component class to extend. Snippets are how the parts go in. The dialog frame is the base class you never had to write. The body and the actions are the list.
When you have to own it
Now it’s the profile settings form, and the fields want different things. The bio grows as
you type, checks its length, and saves itself a moment after you stop. The email checks
its format but never autosaves, because changing it sends a confirmation link. The
password checks its length and nothing else. Try that as AutosavingValidatedInput extends ValidatedInput and the email field breaks the
tree on day one. It’s the webhook again, in a form.
So each field lists its parts. In React they are hooks, useAutoresize and useAutosave, next to plain check functions, because not every part needs to
be a hook. In Svelte they are attachments, and an element takes as
many as you give it.
Two things from this lesson come along. Order is yours: autosave checks the value before its timer starts, so an invalid bio is never sent. That’s section 04’s decision, written into the part. And a part doesn’t share state. React’s docs put it exactly: “Custom Hooks let you share stateful logic but not state itself.” Each field gets its own autosave timer, which is what you want. “Is anything unsaved?” asks about every field at once, so that answer lives in the form and drives one unsaved-changes warning, listening only while something is unsaved.
A delete-invoice dialog renders the general frame and fills its slots. React passes children and an actions prop. Svelte passes snippets, because there is no component class to extend.
import { useId, type ReactNode } from 'react';
type Invoice = { id: string; number: string };
// The frame every dialog shares: the title, the layout, and Cancel.
// It knows nothing about what goes inside.
export function Dialog({
title,
actions,
children,
onClose
}: {
title: string;
actions: ReactNode;
children: ReactNode;
onClose: () => void;
}) {
const titleId = useId();
// A real modal opens with showModal(), so focus and Escape work. This sketch keeps to the slots.
return (
<dialog open aria-labelledby={titleId}>
<h2 id={titleId}>{title}</h2>
{children}
<footer>
{actions}
<button type="button" onClick={onClose}>
Cancel
</button>
</footer>
</dialog>
);
}
// Not `class DeleteInvoiceDialog extends Dialog`. The specific dialog renders
// the general one and fills its slots: children for the body, a prop for the actions.
export function DeleteInvoiceDialog({
invoice,
onDelete,
onClose
}: {
invoice: Invoice;
onDelete: (id: string) => void;
onClose: () => void;
}) {
return (
<Dialog
title={`Delete draft ${invoice.number}?`}
onClose={onClose}
actions={
<button type="button" onClick={() => onDelete(invoice.id)}>
Delete draft
</button>
}
>
<p>The draft and its line items are removed. Sent invoices can only be voided.</p>
</Dialog>
);
}
06 / Recognize it elsewhere
Lists of parts show up wherever cases need different mixes.
Routes are one example. Here are a few places you have probably built behavior from a list without calling it composition.
| Where you’ve seen it | The parts | What the list decides |
|---|---|---|
| An Express route | app.post('/refunds', auth, handler) | Which checks run before this handler, in order. |
| A Go handler | http.StripPrefix("/api", mux) | What happens to the request before your handler sees it. |
| A styled element | class="btn btn-primary btn-small" | Which rules apply, without a PrimarySmallButton class. |
| A test fixture | withInvoice(withUser(emptyState())) | What this test starts with, without a fixture hierarchy. |
Similar-looking code isn’t enough on its own. A list earns its place when different cases genuinely need different combinations.
07 / Already in your toolbox
The libraries you use already made this choice.
Three APIs to look at. For each one, find the parts and who decides the list.
Go · http.StripPrefix
Takes a handler and returns a handler that trims the path first. TimeoutHandler and MaxBytesHandler have the same shape, so you can
stack them in front of your own. Each is a part; your code decides the list.
React · children
A general component with a slot, and specific components that render it with the slot filled in. The docs build a card this way and let any content go inside.
Open the children example ↗Svelte · {@attach}
Functions that run when an element mounts and clean up when it leaves. An element can have any number of them, and a wrapper component can pass them through to the element it renders.
Look at attachments ↗A useful counterexample: custom elementsSometimes the platform asks for a class
To define an autonomous custom element, the browser needs a class that extends HTMLElement, with lifecycle callbacks such as connectedCallback that it calls for you. That inheritance is the contract the platform
offers, one level deep, and using it is the right call. What happens inside the element can
still be built from parts.
The same goes for a framework base class with a few well-defined hooks. See MDN on custom elements.
08 / The parts to watch
A list moves decisions into view. You still have to make them.
The class tree made several choices for you, quietly. Composition hands them back.
Order is now your decision
In the tree, Route.handle logged before anything else could answer, so every request
was logged by construction. In a list, a part placed after an answering part never runs.
Put the parts that must always run first, and cheap refusals ahead of expensive work. When those two rules pull in different directions, the list is where you settle it.
A part that keeps state
rateLimit keeps its counts in the function it returns. Each call is a new budget.
One value handed to two routes is one shared budget. Decide which you mean, and create the part
where that sharing is obvious.
In Go, a part that more than one request can reach at once needs its own lock. The closures and captured state lesson covers where that state lives and for how long.
The same parts on every route
If every signed-in route starts with logged, requireSession, and a limit,
name that list once. Make it a function, so each route still gets its own budget: const signedIn = () => [logged, requireSession, rateLimit(2, clientKey)],
then [...signedIn(), requireRole('admin')].
You get the tree’s “say it once” back without sticking the parts together. If you catch yourself adding flags to switch a part off, the list wants splitting instead.
When the tree is the better model
If nothing needs to cross branches, a tree is shorter to read and there’s nothing to wire. A framework that hands you a base class with two hooks is offering a stable contract; take it.
The list earns its place the first time a behavior needs to show up on routes that don’t share a parent.
09 / Make the call
What would you have to change tomorrow?
Give both designs a plausible change and follow the work it creates.
| The change | A tree of route classes | A list of parts per route |
|---|---|---|
| A new route needs the limit but not the session | Copy the limit, add a flag to the base, or reshape the tree. | Put rateLimit(…) in its list. |
| Invoices should count requests per IP, not per user | Change SignedInRoute and every signed-in route changes with it, or add an
override hook for one subclass. | Give the invoices route rateLimit(2, byIp). No other route moves. |
| Every refused request must be logged | Already true. Route.handle logs first. | True while logged comes first. Easy to see, easy to break. |
| Twelve signed-in routes need the same checks | Extend SignedInRoute. Nothing to repeat. | Name the list once and reuse it. |
| A reviewer asks what one route does | Read up through each parent. | Read one line. |
Reach for parts when a behavior needs to show up on routes that don’t share a parent. The webhook is the moment: logging and a limit, without the session check they came welded to.
Keep the tree when its branches still describe your routes. If nothing crosses them, it’s the shorter design. Plenty of code uses both: a framework base class on the outside, parts on the inside.
The question I’d leave beside the code is: where does this route’s behavior come from, and can I point at it?
10 / Take the idea with you
Explain the webhook without saying “composition.”
“Each route lists the checks it runs, in order. The webhook keeps the log and the limit, and checks a signature instead of a session.” That tells a reviewer more than the principle’s name does. When the reviewer wants the word, it’s coupling: the limit no longer comes attached to the session check.
Before moving on, jot down why the webhook didn’t fit the tree, why logged has to come first, and one place in your own code where a behavior is stuck to a parent it doesn’t
belong to. Your last settings form counts.
Connections to follow nextRelated lessons
- Decorator wraps one object to add behavior
around it.
http.StripPrefixis one: a part that holds the next handler. - Chain of responsibility passes a request along until something claims it. The loop that stops at the first answer has that shape.
- Strategy swaps one decision.
clientKeyis a small one: change how the limit counts without touching the limit. - Factory gives creation a home.
rateLimit(2, clientKey)is a factory function, and each call makes a fresh part. - Dependency injection asks who hands a component its collaborators. Here,
createServerdoes it for every route.