← Concepts & practices
Concept Design principles and language mechanisms

Composition over inheritance

Build it from parts you can list.

You already do this. You pass children into a dialog, stack checks in front of a handler, and if you write Go, you have never had extends to reach for. Let’s follow a small invoicing API from a tidy class tree to the day a payment webhook doesn’t fit it.

TypeScriptGo One invoicing API, two implementations.

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.

  • Route logs every request
    • SignedInRoute checks the session and the rate limit
      • Invoices returns 3 invoices
      • AdminRoute checks for an admin
        • Refunds queues a refund
Each class adds one layer of checks. A route gets every check above it.
Read the treeTypeScript · the code this lesson starts from
tree.ts
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.

TypeScriptReading
routes.ts
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' }));
GoAlongside
routes.go
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.

Composition

Behavior from a list you can read.

REQUESTGET /invoices · ana, member · no signature · from ana-laptop

GET /invoices class Invoices
  1. Log the request from Route
  2. Check the session from SignedInRoute
  3. Rate limit from SignedInRoute
  4. 3 invoices in Invoices
POST /refunds class Refunds
  1. Log the request from Route
  2. Check the session from SignedInRoute
  3. Rate limit from SignedInRoute
  4. Check for admin from AdminRoute
  5. 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.

01/ 05
Read invoices through the class tree

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
rateLimit knows nothing about sessions, signatures, or routes. In the tree, SignedInRoute.respond holds 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.respond and 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 an Invoices and go through Route.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
createServer can 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.

Which webhook route logs every delivery, including the refused ones?

The first part that answers ends the request. logged writes a line and never answers.

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.

Route table

Owns the lists

Which parts each route runs, and in which order.

Parts

Own one decision each

Session, role, signature, budget, log line.

Handler

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.

ReactAlready in your code
DeleteInvoiceDialog.tsx
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.

Familiar code built from a list of parts
Where you’ve seen itThe partsWhat the list decides
An Express routeapp.post('/refunds', auth, handler)Which checks run before this handler, in order.
A Go handlerhttp.StripPrefix("/api", mux)What happens to the request before your handler sees it.
A styled elementclass="btn btn-primary btn-small"Which rules apply, without a PrimarySmallButton class.
A test fixturewithInvoice(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.

Read the standard-library API ↗

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.

How a change affects a class tree and a list of parts
The changeA tree of route classesA list of parts per route
A new route needs the limit but not the sessionCopy 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 userChange 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 loggedAlready true. Route.handle logs first.True while logged comes first. Easy to see, easy to break.
Twelve signed-in routes need the same checksExtend SignedInRoute. Nothing to repeat.Name the list once and reuse it.
A reviewer asks what one route doesRead 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.StripPrefix is 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. clientKey is 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, createServer does it for every route.

Take the routes into your editor. Add a part that rejects a missing request body, decide where it goes in each list, and check which requests it stops.

Back to Concepts & practices →