← Concepts & practices
Concept Design principles and language mechanisms

Inversion of control

Hand over the flow, and write the parts it calls.

You already write functions you never call: tests, components, and click handlers. Let’s follow the password-reset checks from a script that calls every step itself to tests a runner calls, and see what changes when your code stops owning the order.

TypeScriptGo One set of reset checks, two implementations.

01 / The idea

A script that runs every check is a fair start.

Your app emails a link when someone forgets their password, and that reset code needs checks. It takes its mailer as a parameter, so a check can hand it one that keeps emails in memory instead of sending them. runResetChecks builds that memory mailer, requests a few resets, and writes pass or fail for each check. It reads top to bottom, needs no framework, and anyone can follow it.

Read the first checksTypeScript · the version this lesson starts from
runner.ts
// The first version: one script owns the whole flow, calling each check in order.
export function runResetChecks(report: string[]): void {
	const mailer = memoryMailer();
	const resets = createPasswordResets({ mailer, tokens: memoryTokens() });

	resets.request('mina@example.com');
	report.push(mailer.sent.length === 1 ? 'pass: sends one email' : 'fail: sends one email');

	resets.request('qa@example.com');
	report.push(
		mailer.sent.length === 1
			? 'pass: sends one email per address'
			: 'fail: sends one email per address'
	);

	resets.request('not-an-address');
	report.push('pass: handles a mistyped address');

	report.push(
		mailer.sent[0].body.startsWith('https://app.example.com/reset')
			? 'pass: links to the reset page'
			: 'fail: links to the reset page'
	);
}

Go’s version panics where TypeScript throws, the way an unchecked error escapes a script. Both languages meet again at the runner in section 02.

Then it grows. The second check fails because it reads the first check’s email. A check for a mistyped address throws, and the report ends after two lines with nothing about the rest. CI wants to run only the link check, and every new check means editing the script’s order.

Inversion of control moves the flow out of your code. Instead of calling each step, you hand the steps to something else, and it decides when to call them, how often, in what order, and what happens when one fails. Your code gets smaller and more uniform; in exchange, it runs on someone else’s schedule and has to fit their contract. Martin Fowler calls it “a common characteristic of frameworks.”

Section 05 hands a virtual list your row renderer and lets it decide which rows to draw, in React and Svelte.

02 / See the shape

Register the parts, and let the runner call them.

The basic form is a runner: test() hands it a name and a function, and run() calls each one. In the wild the runner also calls setup before every test and chooses which tests run. At the call site runs the script and the suite on the same four checks.

Both languages produce the same results.

A runner. test() registers a name and a function; run() calls each one and records what it throws.

TypeScriptReading
runner.ts
export type Result = { name: string; passed: boolean; error?: string };

// Register tests with test(); the runner decides when to call them, and catches what they throw.
export function createRunner() {
	const tests: { name: string; fn: () => void }[] = [];
	return {
		test(name: string, fn: () => void): void {
			tests.push({ name, fn }); // Registered, not run.
		},
		run(): Result[] {
			return tests.map(({ name, fn }) => {
				try {
					fn();
					return { name, passed: true };
				} catch (error) {
					return {
						name,
						passed: false,
						error: error instanceof Error ? error.message : String(error)
					};
				}
			});
		}
	};
}
GoAlongside
runner.go
type Result struct {
	Name   string
	Passed bool
	Err    string
}

// Runner registers tests with Test; Run decides when to call them and recovers what they panic with.
type Runner struct {
	tests []namedTest
}

type namedTest struct {
	name string
	fn   func()
}

func (r *Runner) Test(name string, fn func()) {
	r.tests = append(r.tests, namedTest{name, fn}) // Registered, not run.
}

func (r *Runner) Run() []Result {
	results := make([]Result, 0, len(r.tests))
	for _, t := range r.tests {
		results = append(results, call(t.name, t.fn))
	}
	return results
}

func call(name string, fn func()) (result Result) {
	result = Result{Name: name, Passed: true}
	defer func() {
		if recovered := recover(); recovered != nil {
			result = Result{Name: name, Err: fmt.Sprint(recovered)}
		}
	}()
	fn()
	return result
}
Reading the TypeScriptRegistering and calling

test() pushes { name, fn } onto an array and returns. Nothing about the reset code runs until run() maps over that array, calling each fn inside its own try.

createSuite(setup) calls setup() right before each test and passes the result in. Handing each test its fixtures is dependency injection; calling the tests is inversion of control.

Reading the Gogo test already works this way

Go’s testing package is “intended to be used in concert with the ‘go test’ command, which automates execution of any function of the form func TestXxx(*testing.T).” The lesson’s own runner_test.go is inverted: you never call TestSharedExpectedRun.

The small runner uses defer and recover so a panicking test becomes a failed result instead of ending the run.

03 / Follow the calls

Watch who calls whom.

Five steps, each running the lesson’s code. On the left is every call in order, and who made it; on the right, what happened to each check. Before each step, guess which checks run.

In Try it, run the checks either way and ask for only some of them.

Inversion of control

Who calls whom?

Your script calls every check. In control: runResetChecks, your code. Calls: script → request('mina@example.com'); script → request('qa@example.com'); script → request('not-an-address'). Results: sends one email: pass; sends one email per address: fail; handles a mistyped address: fail (threw “invalid address”; the script stopped); links to the reset page: not run. The second check saw the first check’s email, and the third threw “invalid address”. Nothing after it ran.

01/ 05
Run the hand-written script

Your script owns the flow.

runResetChecks calls each check in order. The second shares the first one’s mailer, and the third throws and stops the rest.

Reduced motion: choose a scene to see its completed state.

Read this scene

runResetChecks calls each check in order. The second shares the first one’s mailer, and the third throws and stops the rest.

Your script calls every check. In control: runResetChecks, your code. Calls: script → request('mina@example.com'); script → request('qa@example.com'); script → request('not-an-address'). Results: sends one email: pass; sends one email per address: fail; handles a mistyped address: fail (threw “invalid address”; the script stopped); links to the reset page: not run. The second check saw the first check’s email, and the third threw “invalid address”. Nothing after it ran.

Watch restarts when you return. Step through keeps your selected step. Try it starts with the runner and every test each time you open it.

What handing over the flow 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.

Failures that stay contained
The mistyped address fails its own test, and the link test after it still runs.
Fresh fixtures every time
The runner calls setup before each test, so no test reads another test’s email.
Choosing what runs, without editing tests
run({ only: "link" }) picks one test from the outside.
Every part has the same shape
Each test is a name and a function that receives fixtures.
New parts without touching the flow
Adding a fifth test is one more test() call, not an edit to an ordered script.

The review words are inversion of control, framework versus library (you call a library; a framework calls you), callback, hook for setup, and the Hollywood principle: “don’t call us, we’ll call you.” Section 08 covers what they cost.

04 / Try a decision

A check that runs before its test.

A file registers a test and checks its result on the next line. The code is in collected.ts, and the lesson’s tests pin what happens.

What does the line after test() see?

A file declares let sent = -1, registers suite.test('sends one email', ...) whose body requests a reset and sets sent = mailer.sent.length, and on the very next line reads sent to check it. suite.run() comes after that.

05 / Give it a real job

A list that decides which rows to draw.

The admin area shows every password-reset request, tens of thousands of them. Drawing every row would freeze the page, so a virtual list draws only the rows in view and swaps them as you scroll. You write what one row looks like; the list decides which rows exist and when to draw them.

Virtual list

Owns the flow

Scrolling, which rows are in view, and when to draw them.

Row renderer

Called for visible rows

Many times, for any row, in whatever order the list chooses.

Your data

Handed over whole

The list slices it; the row sees one item.

The example leaves out variable row heights, keyboard navigation through off-screen rows, and loading more data as you scroll.

Build UIs?Every component and handler you write is called by the framework, and one day a library calls your code more often, or less, than you assumed.

Where it already is in your components

React’s docs put it plainly: “Rendering” is React calling your components. You never write ResetRow(); you return <ResetRow /> and React decides when to call it. A click handler is handed over the same way, and called when someone clicks.

In Svelte you write the markup for a row inside {#each}, and Svelte runs it for each item, and again when the list changes.

When you have to own it

Now it’s the admin log. The React version passes a renderRow function; the Svelte version passes a snippet, and Svelte’s docs note that snippets “are values just like any other. As such, they can be passed to components as props.” Either way, the list calls your renderer only for the rows in view. Both lists ask visibleRange, shown below with the Svelte list, which rows those are.

That’s the contract you accept: the renderer may run for any row, many times, in any order. Keep it to drawing one row, and don’t count on how often it’s called.

visible-range.ts
// Which rows a virtual list renders: the ones in view, plus a few either side.
export function visibleRange(options: {
	total: number;
	rowHeight: number;
	viewportHeight: number;
	scrollTop: number;
	overscan?: number;
}): { start: number; end: number } {
	const { total, rowHeight, viewportHeight, scrollTop, overscan = 2 } = options;
	const first = Math.floor(scrollTop / rowHeight);
	const visible = Math.ceil(viewportHeight / rowHeight);
	return {
		start: Math.max(0, first - overscan),
		end: Math.min(total, first + visible + overscan)
	};
}

export type ResetRequest = { id: string; address: string; requestedAt: string };
VirtualList.svelte
<script lang="ts" generics="T">
	import type { Snippet } from 'svelte';
	import { visibleRange } from '../visible-range';

	// A small virtual list. It owns scrolling and decides which rows exist; it renders the row
	// snippet for those.
	let {
		items,
		rowHeight,
		height,
		row
	}: { items: T[]; rowHeight: number; height: number; row: Snippet<[T, number]> } = $props();

	let scrollTop = $state(0);
	const range = $derived(
		visibleRange({ total: items.length, rowHeight, viewportHeight: height, scrollTop })
	);
</script>

<div
	style:height="{height}px"
	style:overflow-y="auto"
	onscroll={(event) => (scrollTop = event.currentTarget.scrollTop)}
>
	<div style:height="{items.length * rowHeight}px" style:position="relative">
		{#each items.slice(range.start, range.end) as item, offset (range.start + offset)}
			<div
				style:position="absolute"
				style:top="{(range.start + offset) * rowHeight}px"
				style:height="{rowHeight}px"
			>
				{@render row(item, range.start + offset)}
			</div>
		{/each}
	</div>
</div>

A reset-history list whose row component and click handler are handed to the framework, which calls them.

ReactAlready in your code
ResetHistory.tsx
import type { ResetRequest } from './visible-range';

// You write ResetRow, but you never call it. React calls it for each item when it renders.
function ResetRow({ request }: { request: ResetRequest }) {
	return (
		<li>
			{request.address} <time>{request.requestedAt}</time>
		</li>
	);
}

export function ResetHistory({
	requests,
	onResend
}: {
	requests: ResetRequest[];
	onResend: (id: string) => void;
}) {
	return (
		<ul>
			{requests.map((request) => (
				<ResetRow key={request.id} request={request} />
			))}
			{/* Handing over a function: React calls it when the button is clicked, not now. */}
			<button type="button" onClick={() => onResend(requests[0].id)}>
				Resend the latest link
			</button>
		</ul>
	);
}

06 / Recognize it elsewhere

Anywhere you hand over a function instead of calling it.

You’ve met all of these. For each one, find what you hand over and who calls it.

Familiar code that hands over a function, and who calls it
Where you’ve seen itWhat you hand overWho calls it, and when
test('…', fn)The test bodyThe test runner, after collecting every test
addEventListener('click', handler)The handlerThe browser, on each click
A route handler registered with a routerThe handlerThe router, once per matching request
A componentThe component function or markupThe framework, whenever it renders
items.map(callback)The callbackmap, once per element, right now

The last row is the small end of the same idea: map owns the loop. The larger the thing that calls you, the more of the flow you’ve handed over.

07 / Already in your toolbox

Your tools already call you.

Three places to look. For each one, find when your code runs and who decides.

Martin Fowler · Inversion of Control Containers and the Dependency Injection pattern

Where the distinction between inversion of control in general and dependency injection in particular was drawn, in 2004.

Read the article ↗

Jest · Setup and Teardown

The order a runner calls your code in, including that it collects every describe block before it runs any test.

Read the docs ↗

Go · Package testing

How go test finds and calls your TestXxx functions, and what t.Fatal and t.Error tell it.

Read the docs ↗
A useful counterexample: a library you callWhen nothing is inverted

Formatting the reset email is a function you call, with arguments you choose, when you choose. Keep it that way. Not every helper needs to be a hook.

08 / The parts to watch

Your code runs on someone else’s schedule.

These are the places it still goes wrong.

Registering isn’t running

Code next to test() runs when the file loads; the test body runs later. Jest “executes all describe handlers in a test file before it executes any of the actual tests.”

The call site isn’t in your code

A stack trace from inside a test or a row renderer starts in the framework. Reading the flow means reading its docs, not your file.

Their contract is now yours

The signature, whether a function may be async, and what a throw means are decided by the thing that calls you. A throw here fails one test; in a route handler it might be a 500.

Order you didn’t choose

This runner keeps registration order. Runners that run tests in parallel or shuffle them won’t, so a test that depends on another test is waiting to fail.

State outside the hooks still leaks

Fresh fixtures only help when they come from setup. A mailer created at the top of the file is shared again.

You may be called more often than you think

Renderers and components can run many times for one screen. Keep what they do safe to repeat, as in Pure functions and side effects.

09 / Make the call

What would you have to change tomorrow?

Give both versions a plausible change and follow the work it creates.

How a change affects a hand-written script and a runner
The changeYour scriptA runner
A migration that runs onceReads top to bottom.Ceremony for one run.
One check throwsEverything after it is lost.Recorded; the rest run.
Run only the link checkEdit the script.Pass a filter.
Each check needs a clean mailerBuild one by hand in each.Setup does it.
Step through one failureOne function, top to bottom.A breakpoint in a callback the runner calls.

Hand over the flow when many small pieces share it: tests, routes, rows, handlers. A fourth check with its own fixtures is the moment.

Keep the flow in your code when you run it once and want to read it top to bottom.

The question I’d leave beside the code is: who calls this, when, and what do they expect back?

10 / Take the idea with you

Explain the half-finished report without saying “inversion of control.”

“Our script called every check itself, so when one threw, it stopped and nothing said what happened to the rest. We gave the checks to a runner. It calls each one, gives it a fresh mailer, and records what fails.” In a review, the words are inversion of control, framework, callback, and hook.

Before moving on, jot down why the report stopped after two lines, why the top-level check saw -1, and one function in your own code that something else calls.

Connections to follow nextRelated lessons
  • Dependency injection is the other half of this code: who supplies the mailer, not who calls the tests.
  • Template method keeps the flow in a base sequence and calls the steps you fill in.
  • Observer hands a subject your listener, which it calls when something changes.
  • Dependency direction asks which of this code imports which, even when the calls go the other way.

Take the runner into your editor. Add an afterEach hook that checks no email was left unsent, and decide what the runner should do when the hook itself throws.

Back to Concepts & practices →