← Concepts & practices
Concept Design principles and language mechanisms

Dependency injection

Let the caller choose the collaborators.

You already pass things into functions. Let’s follow a password-reset request whose code picks its own mailer, until a test run emails a real inbox and staging has no way to stop sending.

TypeScriptGo One password-reset flow, two implementations.

01 / The idea

A function that imports its own mailer is a fair start.

You’re building “Forgot password?” for a web app. requestPasswordReset(address) issues a token from the database and sends the link through the SMTP mailer, both imported at the top of the file. One function, no setup, and in production it does exactly the job.

Read the first reset requestTypeScript · the version this lesson starts from
resets.ts
// The first version: the function reaches for the production mailer and token store itself.
export function requestPasswordReset(address: string): void {
	const token = databaseTokens.issue(address);
	smtpMailer.send(resetEmail(address, token));
}

Go’s version calls package-level SMTPMailer and DatabaseTokens the same way. Both languages meet again at createPasswordResets in section 02.

Then the tests arrive. A test for the reset link calls the function, and a real email goes out on every CI run. QA triggers resets on staging, and those are real emails too. The only ways to stop it are to mock the module or put if (environment === 'staging') inside the reset code.

When code creates or imports its own collaborators, it has chosen them for every caller. Take them as parameters instead, and the choice moves to the code that assembles the app: tests pass fakes, staging passes a mailer that logs, production passes SMTP, and the reset code never changes. The place that makes those choices is the composition root. Martin Fowler’s 2004 article describes the idea as “a separate object, an assembler, that populates a field in the lister class with an appropriate implementation for the finder interface.”

Section 05 builds a forgot-password form that gets its API client from the app’s root, in React and Svelte.

02 / See the shape

Take collaborators as parameters, and choose them in one place.

The basic form takes a mailer and a token store, described by two small interfaces. In the wild adds implementations for tests and staging, and the composition root that picks one per environment. At the call site runs a test, staging, and the first version, and counts where each email went.

Both languages produce the same results.

Collaborators passed in. createPasswordResets takes a mailer and a token store, described by small interfaces, and uses whatever it’s given.

TypeScriptReading
resets.ts
export interface Mailer {
	send(email: Email): void;
}
export interface TokenStore {
	issue(address: string): string;
}

// The same work, with the collaborators passed in by whoever creates it.
export function createPasswordResets(deps: { mailer: Mailer; tokens: TokenStore }) {
	return {
		request(address: string): void {
			const token = deps.tokens.issue(address);
			deps.mailer.send(resetEmail(address, token));
		}
	};
}
GoAlongside
resets.go
type Mailer interface{ Send(Email) }
type TokenStore interface{ Issue(address string) string }

// PasswordResets does the same work with the collaborators passed in by whoever creates it.
type PasswordResets struct {
	mailer Mailer
	tokens TokenStore
}

func NewPasswordResets(mailer Mailer, tokens TokenStore) *PasswordResets {
	return &PasswordResets{mailer: mailer, tokens: tokens}
}

func (r *PasswordResets) Request(address string) {
	token := r.tokens.Issue(address)
	r.mailer.Send(ResetEmail(address, token))
}
Reading the TypeScriptStructural interfaces and a closure

memoryMailer() never says it implements Mailer. TypeScript’s types are structural, so any object with a matching send fits.

createPasswordResets returns an object whose request closes over deps. A class with a constructor parameter would do the same job.

Reading the GoConstructors and implicit interfaces

NewPasswordResets(mailer, tokens) stores its collaborators in unexported fields. Go types satisfy an interface by having its methods, so *MemoryMailer and LogMailer fit Mailer with no declaration.

ComposeResets plays the role that main usually plays: the one function that names concrete types.

03 / Follow the email

Watch who chooses the mailer, and where the email goes.

Five steps, each running the lesson’s code and counting where the email really went. Before each step, guess whether a real inbox receives it.

In Try it, send requests as a test, as staging, as production, and through the first version.

Dependency injection

Who chose the mailer?

A test calls the first version. Mailer: smtpMailer, chosen by the function itself; TokenStore: databaseTokens, chosen by the function itself. requestPasswordReset('mina@example.com'). Emails through SMTP: 1. Log lines: 0. In a memory mailer: 0. The test only wanted to check the link. The function chose its own mailer, so the email went to SMTP.

01/ 05
Call requestPasswordReset from a test

The first version picks its own mailer.

A test calls requestPasswordReset, and a real reset email goes out through SMTP.

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

Read this scene

A test calls requestPasswordReset, and a real reset email goes out through SMTP.

A test calls the first version. Mailer: smtpMailer, chosen by the function itself; TokenStore: databaseTokens, chosen by the function itself. requestPasswordReset('mina@example.com'). Emails through SMTP: 1. Log lines: 0. In a memory mailer: 0. The test only wanted to check the link. The function chose its own mailer, so the email went to SMTP.

Watch restarts when you return. Step through keeps your selected step. Try it starts with no emails sent each time you open it.

What passing collaborators in 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.

Tests that can’t send email
A test passes memoryMailer(), and SMTP counts zero.
Environments without if statements
Staging logs instead of sending, and the reset code has no idea which environment it’s in.
Signatures that say what’s needed
createPasswordResets can’t be called without a mailer and a token store.
One place to change wiring
Switching email providers is an edit to composeResets, not to the reset code.
Reuse with different parts
An admin tool can send resets through its own mailer with the same code.

The review words are dependency injection, collaborator or dependency for the mailer and token store, constructor injection for passing them when the object is made, composition root for composeResets, and test double for memoryMailer. Section 08 covers what they cost.

04 / Try a decision

A default that sends real email.

To shorten call sites, someone made the mailer optional. The code is in defaulted.ts, and the lesson’s tests pin what happens.

What happens when the new test runs?

To keep call sites short, someone wrote createResets({ tokens, mailer = smtpMailer }). A new test does const mailer = memoryMailer(), then createResets({ tokens: memoryTokens() }).request('mina@example.com'), then expect(mailer.sent).toHaveLength(1).

05 / Give it a real job

A form that gets its API client from the app, not from an import.

In the real app, the forgot-password form posts to the API. The designer wants a preview of the “try again later” message without tripping the real rate limit, and tests want to check the form without a server. The form shouldn’t know which client it’s talking to.

App root

Builds the real client once

From the platform’s fetch and the API’s base URL.

Form

Asks for a client

It reads one from context and never constructs one.

Preview and tests

Provide a fake

The same form, answering with a chosen outcome.

The example leaves out authentication, retries, and the email template. The preview is a plain component, not a particular design tool.

Build UIs?Every prop you pass is injection, and one day a component deep in the tree needs an API client that tests and previews can replace.

Where it already is in your components

Props are the everyday form: a parent passes what a child needs. React’s own guide says to “Start by passing props” before reaching for anything else. When a client is needed far down the tree, context lets a parent “make some information available to any component in the tree below it—no matter how deep—without passing it explicitly through props.”

Svelte’s context “allows components to access values owned by parent components without passing them down as props.” The textbook Svelte form uses createContext, whose getter throws when no parent has set a value, so a missing provider fails loudly.

When you have to own it

Now it’s the whole app. The root builds the real client once, from fetch and a base URL, and provides it. The preview provides fakeApiClient('rate-limited') around the same form, so the message can be reviewed without calling the API.

The root is the browser’s composition root: the one component that names createApiClient. Every other component asks.

api-client.ts
export type ResetOutcome = 'sent' | 'rate-limited' | 'failed';

export interface ApiClient {
	requestPasswordReset(email: string): Promise<ResetOutcome>;
}

// The real client, built once at the app's root from the platform's fetch and a base URL.
export function createApiClient(fetchFn: typeof fetch, baseUrl: string): ApiClient {
	return {
		async requestPasswordReset(email) {
			const response = await fetchFn(`${baseUrl}/password-resets`, {
				method: 'POST',
				headers: { 'content-type': 'application/json' },
				body: JSON.stringify({ email })
			});
			if (response.status === 429) return 'rate-limited';
			return response.ok ? 'sent' : 'failed';
		}
	};
}

// A stand-in for previews and tests: answers with a chosen outcome and remembers who asked.
export function fakeApiClient(outcome: ResetOutcome) {
	const requested: string[] = [];
	const client: ApiClient = {
		async requestPasswordReset(email) {
			requested.push(email);
			return outcome;
		}
	};
	return { client, requested };
}
api-context.ts
import { createContext } from 'svelte';
import type { ApiClient } from './api-client';

// Svelte: a typed pair for providing the API client to every component below the root.
// getApi throws if no parent has provided one, so a missing provider fails loudly.
export const [getApi, setApi] = createContext<ApiClient>();

A forgot-password form that reads the API client from context instead of importing and constructing one.

ReactAlready in your code
ForgotPasswordForm.tsx
import { createContext, useContext, useState } from 'react';
import type { ApiClient, ResetOutcome } from './api-client';

// The app's root provides the client; any component below can ask for it.
export const ApiContext = createContext<ApiClient | null>(null);

function useApi(): ApiClient {
	const api = useContext(ApiContext);
	if (!api) throw new Error('ForgotPasswordForm needs an ApiContext provider above it');
	return api;
}

export function ForgotPasswordForm() {
	const api = useApi(); // Supplied from outside, not imported and constructed here.
	const [email, setEmail] = useState('');
	const [outcome, setOutcome] = useState<ResetOutcome | null>(null);

	async function submit(event: React.FormEvent) {
		event.preventDefault();
		setOutcome(await api.requestPasswordReset(email));
	}

	return (
		<form onSubmit={submit}>
			<label>
				Email <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
			</label>
			<button type="submit">Send reset link</button>
			{outcome === 'sent' && <p role="status">Check your inbox.</p>}
			{outcome === 'rate-limited' && <p role="status">Try again in a few minutes.</p>}
		</form>
	);
}

06 / Recognize it elsewhere

Anywhere a part is handed in instead of looked up.

You’ve met all of these. For each one, find what’s supplied and who chooses it.

Familiar code that supplies collaborators, and who chooses them
Where you’ve seen itWhat’s suppliedWho chooses
Props on a componentData and callbacksThe parent
A context provider at the app rootA theme, the account, a clientThe component that renders the provider
A repository built with a database handleThe connection poolmain, at startup
An API client built with fetchHow requests are madeThe app, or a test with a fake
A framework’s DI containerWhatever was registeredThe container’s configuration

When a test has to mock a module to replace something, that something was looked up rather than supplied. When a function takes a mailer, the choice has already moved out.

07 / Already in your toolbox

Your frameworks already have a way to supply things.

Three places to look. For each one, find who creates the part and who uses it.

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

The 2004 article that named the pattern, compares constructor and setter injection, and contrasts it with a service locator.

Read the article ↗

React · Passing Data Deeply with Context

When to use context instead of props, with the advice to try props and children first.

Read the guide ↗

Svelte · Context

setContext, getContext, and the type-safe createContext, with the rule that context is read while a component initializes.

Read the docs ↗
A useful counterexample: a one-off maintenance scriptWhen importing directly is better

A script that resets one account by hand, runs once, and always uses production can import the mailer. There’s no second caller to choose differently.

08 / The parts to watch

Moving the choice out has costs of its own.

These are the places it still goes wrong.

A production default brings the hidden choice back

mailer = smtpMailer makes forgetting the argument silent. Keep collaborators required, and name production parts only in the composition root.

A service locator hides what’s needed

Fowler’s summary: “with a Service Locator every user of a service has a dependency to the locator.” services.get('mailer') inside the function works, but the signature no longer says a mailer is needed.

Fakes can drift from the real thing

memoryMailer never fails and never rate-limits. Keep a few tests that run the real mailer, or check both against the same contract.

Wiring is code, and it can be wrong

If composeResets gave production the log mailer, every other test would still pass. Test the root: production should get SMTP.

Parameter lists grow

Five collaborators on every function is a sign the work wants splitting. A container or context hides the length, not the coupling.

Lifetimes are a decision

One mailer for the whole app, or one per request? The composition root decides, and a per-request part passed into a long-lived object outlives its request.

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 imported and injected collaborators
The changeImports insidePassed in
A one-off scriptShortest.Wiring for nothing.
Tests mustn’t send emailMock the module.Pass memoryMailer().
Staging logs insteadAn environment check inside.The root picks a log mailer.
Switch email providersEdit the reset code.Edit the root.
Add a rate limiterImport it.Add a parameter, and wire it.

Pass collaborators in when they talk to the world or differ between callers. A test that mustn’t send email is the moment.

Import directly when there’s one caller and one implementation, for good.

The question I’d leave beside the code is: who should decide which one this code gets, and can they?

10 / Take the idea with you

Explain the test emails without saying “dependency injection.”

“The reset code picked the SMTP mailer itself, so everything that called it sent real email, tests included. Now it takes a mailer, and the app’s setup decides which one each environment gets.” In a review, the words are dependency injection, composition root, and test double.

Before moving on, jot down why the test sent a real email, why the default brought it back, and one import in your own code that a test has to mock.

Connections to follow nextRelated lessons

Take the reset code into your editor. Give it a rate limiter as a third collaborator, wire a real one for production and one that always allows for tests, and write the test for the root.

Back to Concepts & practices →