What you'll learn: By the end of this lesson you will be able to explain what a dependency is and what dependency injection changes, hand dependencies to front-end code in three ways (function parameters, React context and a small container), keep the container in one place so it does not turn into a hidden global, split a feature into concerns that can be tested separately, and write deterministic tests that replace the network, the clock, storage and analytics with fakes of a few lines each.
Think of a table lamp. One lamp has its cord wired straight into the wall of one particular house. Another has a plug. To check that the second lamp works you can carry it to any socket, or to a test bench that supplies power; to check the first you must be in that house with the electricity on. Nothing about the lamp's light is different. What differs is who decides where the power comes from.
Front-end code is often wired into the wall. A function that calls fetch, reads Date.now(), writes to localStorage and sends an analytics event can only be exercised where all four exist and behave. This lesson is about the plug: letting code say what it needs, and letting something else decide what fills each socket.
The running example is a product page with an Add to basket button. Behind the button is a cart that asks a server for the price of an item, remembers the basket with a time stamp, and reports what happened to analytics. The prices, the time, the storage and the analytics are the four things the cart needs from outside itself.
The core idea
- A dependency is anything your code needs but does not create itself: the network, the clock, storage, a logger, another module. Calling
fetchinside a function means the function reaches out for it. - Dependency injection (DI) means the code receives what it needs. Martin Fowler's description is that a separate assembler object supplies an implementation of an interface, so the class does not create that implementation itself. The code is written against a small interface; whoever builds it decides which implementation fills it.
- Separation of concerns is what makes injection cheap. If the cart's rules, the HTTP details and the rendering live in different modules, each can be built and tested with the others replaced.
Three illustrations run through the lesson: a minimal example (the cart with four fakes), a realistic example (the product page wired through context and a container) and a failure example (a missing provider and a container used as a service locator).
What hard-wired code costs
Here is the cart's core written the usual way. This is an illustration, not part of the lab, and it was not run.
export async function addToCart(sku: string, quantity: number) {
const response = await fetch(`/api/prices/${sku}`);
const { cents } = await response.json();
cart.set(sku, { quantity, cents });
localStorage.setItem("cart", JSON.stringify({ savedAt: Date.now(), lines: [...cart.values()] }));
analytics.track("cart_updated", { sku });
}
Nothing here is wrong in production. The cost appears in tests. To check the saved time stamp, a test must freeze the global clock; to check price handling, replace the global fetch; to check the stored value, provide storage. Each replacement is a global edit that must be undone, and a test that forgets can break a later one. The test describes how to rig the environment more than what the cart should do.
Three ways to hand over dependencies
Figure 1. The same cart, wired in production and in a test
How to read this: the cart box is identical in both columns. Only the right-hand side changes, and the composition root is the single place that decides which side is used.
Style 1: parameters
The simplest style is an ordinary argument. The cart declares four small interfaces (the lab's ports.ts) and takes them as one object. This excerpt is from the lab's cart.ts:
export interface CartDeps {
readonly prices: PriceApi;
readonly clock: Clock;
readonly store: KeyValueStore;
readonly analytics: Analytics;
}
export function createCart(deps: CartDeps): Cart {
Fowler's article names constructor, setter and interface injection for classes. A factory function that takes its dependencies is the same idea in the shape most front-end code has: the dependencies are visible in the signature, and there is nothing to configure. For most feature logic, this is all you need.
Style 2: context
Passing four arguments down through six components is the props-drilling problem. React context is DI scoped to a part of the component tree. React's documentation describes it as letting a parent make information available to any component below it, however deep, without passing it through props. This excerpt is from the lab's services.tsx:
const ServicesContext = createContext<Services | null>(null);
export function ServicesProvider(props: { services: Services; children: ReactNode }) {
return <ServicesContext value={props.services}>{props.children}</ServicesContext>;
}
export function useServices(): Services {
const services = useContext(ServicesContext);
if (services === null) throw new Error("useServices must be used inside <ServicesProvider>");
return services;
}
Three facts from the React reference matter here. A component reads the value of the nearest matching provider above it. The default value passed to createContext is a last-resort fallback, used only when there is no provider above. And in React 19 you render the context itself as the provider (older versions use <Context.Provider>). The lab picks null as the default, which React documents as the choice when there is no meaningful default, and makes the hook fail loudly. A "default" that quietly used the real network would be a worse surprise.
Context has a cost: the dependency no longer appears in a component's props, and readers re-render when the value changes (see Context and Its Render Cost in React).
Figure 2. The nearest provider wins
(real services)"] --> Page["Page"] Page --> A["AddToCartButton A
reads outer"] Page --> P2["Inner ServicesProvider
(different services)"] P2 --> B["AddToCartButton B
reads inner"] X["Component with no provider above
gets the default: null, then throws"]
How to read this: the lab has a test with exactly this shape. The two buttons end up with different prices because they read different providers, and a probe outside any provider throws.
Style 3: a small container
When many modules depend on many things, writing the wiring by hand gets long. A container is a registry that knows how to build each dependency and builds them in the right order. The lab's container is under 80 lines: a token names a slot, register(token, factory, { lifetime }) says how to build it, resolve(token) returns it.
export function createAppContainer(config: { fetch: FetchFn; baseUrl: string }): Container {
return createContainer()
.register(TOKENS.fetch, () => config.fetch)
.register(TOKENS.clock, () => systemClock)
.register(TOKENS.prices, (r) => createHttpPriceApi({ fetch: r.resolve(TOKENS.fetch), baseUrl: r.resolve(TOKENS.baseUrl) }))
// ... store, analytics and baseUrl are registered the same way, then:
.register(TOKENS.cart, (r) => createCart({ prices: r.resolve(TOKENS.prices), clock: r.resolve(TOKENS.clock), store: r.resolve(TOKENS.store), analytics: r.resolve(TOKENS.analytics) }));
}
Two lifetimes cover most needs: a singleton is built once and reused, a transient is built on every resolve. Angular's injector is a framework-built version of the same idea; its guide describes DI as a pattern for organising and sharing code and notes that it lets unit tests use test doubles.
Figure 3. A container resolving the cart
How to read this: dependencies are built before the things that need them. The lab's demo prints "build c, build b, build a" for a chain a, b, c, and a cycle is reported as an error naming the path.
The danger is how you use it. Fowler's comparison is with a service locator, an object that knows how to get all the services an application needs, which classes ask for explicitly. If every module imports the container and calls resolve wherever it likes, you have rebuilt the hard-wired version with an extra step: every module depends on the locator, and its needs are hidden inside its body. Mark Seemann's rule is that modules are composed in one place, the composition root, as close to the entry point as possible, and that only the composition root references the container. In the lab, createCart never sees the container.
Separation of concerns: where the seams go
A concern here is one kind of decision. Splitting by concern gives each module a narrow job and a short list of dependencies:
| Concern | Decides | Depends on (as interfaces) | Tested with |
|---|---|---|---|
| Rendering | What the user sees for a given state | Cart logic | Component tests with a provider and fakes |
| Application logic | Cart rules, totals, when to save | Prices, clock, store, analytics | Plain function tests with four fakes |
| Adapters | How to talk HTTP, storage, a vendor SDK | A fetch function, a base URL |
Tests with a fake fetch |
| Composition root | Which adapter fills each socket | Everything (it is the only one that may) | A smoke test that the graph resolves |
The detailed layering rules belong to the planned lessons on layered and clean architecture and on ports and adapters in this module. This lesson needs only the seam: the interfaces in the middle column.
Testing with fakes
A test double is any stand-in for a real dependency. The lab's fakes are ordinary code: a manual clock whose advance(ms) moves time, a price API that records which SKUs were asked for and can be told to fail, an in-memory store, an analytics recorder. Tests then assert behaviour, for example the saved time stamp equals the clock's start plus 90,000 ms, instead of asserting how the code was wired.
Because every source of nondeterminism is injected, the tests are deterministic: the lab runs one scenario twice and gets identical stored output.
Figure 4. Which style when
dependency?"} -->|"one function
or module"| P["Parameters
(default choice)"] Q1 -->|"many components
in one subtree"| C["React context
(null default, loud hook)"] Q1 -->|"many modules, built in
order, with lifetimes"| K["Container
(composition root only)"] K --> W["Pass built objects to the rest
as parameters or context"]
How to read this: the styles combine. A composition root often uses a container to build the graph and then hands the results to the app through context.
| Style | Needs visible in | Scope | Main risk |
|---|---|---|---|
| Parameters | The signature | The call | Long argument lists |
| Context | The component body | A subtree | Hidden needs, missing provider |
| Container | The registration | The container | A locator in disguise, cycles |
Build it: swap every dependency for a fake
Goal. Run the cart with four fakes, then with an HTTP adapter over a fake fetch, then through the container, and watch a fork of the container give the same wiring with different pieces.
Setup. The lab is in the course's example workspace (Node 26.12.1, pnpm).
cd examples/frontend
pnpm install
cd labs/di-composition
pnpm test # 25 tests: 21 in node, 4 in jsdom
node src/demo.ts
Steps.
- Run the tests. In
test/cart.test.tsevery test builds a cart from four fakes and asserts behaviour; intest/services.test.tsxthe nearest-provider test expects two buttons to show $4.50 and $10.00, and the last test expects the error for a missing provider. - Run
node src/demo.tsand compare with the output below. Section 2 uses a fakefetch, so a 503 answer and a malformed body become errors without any network. - In section 4, the forked container reuses every registration of the app container and replaces only prices, clock, store and analytics. The original is untouched.
Expected output, captured from a real run (Node 26.12.1, 2026-11-08):
=== 1. DI by parameters: the cart with four fakes
add biscuits failed: price biscuits: HTTP 503
total: $17.25
price lookups: tea, coffee, biscuits
analytics events:
cart_updated {"sku":"tea","quantity":2,"totalCents":900}
cart_updated {"sku":"coffee","quantity":1,"totalCents":1725}
cart_error {"sku":"biscuits","reason":"price biscuits: HTTP 503"}
saved at: 2026-11-08T09:01:00.000Z
restored in a new cart from the same store: $17.25
=== 2. The HTTP adapter with a fake fetch (no network)
total: $4.50
add coffee failed: price coffee: HTTP 503
add oops failed: price oops: unexpected response body
requests: https://shop.example/api/prices/tea | https://shop.example/api/prices/coffee | https://shop.example/api/prices/oops
=== 3. A tiny container: construction order, lifetimes, errors
construction order: build c, build b, build a
singleton returns the same object: true
transient returns a new object each time: 1, 2
cycle: Circular dependency: x -> y -> x
missing: No registration for "y" (needed by x)
=== 4. fork: the same wiring with fakes swapped in
app cart (fetch says 999): $9.99
forked cart (fake prices): $4.50
different cart objects: true
What the lab does not show. No browser: jsdom is a JavaScript model of the DOM with no layout or painting. The container is a model of the idea, not a production library, and the HTTP adapter is exercised only with a fake fetch.
Break it. (1) In src/cart.ts, replace deps.clock.now() with Date.now(). The test "stamps the saved cart with the injected clock" fails: a test can no longer decide what time it is. (2) In src/container.ts, change build(new Map(registrations)) in fork to build(registrations). The fork now edits the original's table, and "fork swaps pieces for the fork only" fails. Revert both.
Stretch. Add a scoped lifetime (one instance per fork), or split the Services context into two and see which tests change.
In practice: a checkout summary whose tests stopped being trusted
This is a realistic scenario, not a case history. It is a composite built from common production patterns. It describes no specific company or product, and every number in it is an illustrative assumption.
The situation
A storefront's checkout summary component calls a global analytics object, reads Date.now() for an "offer ends in" banner, and fetches prices inside the component. Its tests stub three globals and use fake timers. About one run in ten fails in CI and passes on retry (illustrative), so engineers re-run the pipeline without reading the failure.
Starting architecture
Figure 5. Starting point: the component reaches for everything
How to read this: every arrow is a reach into a global, so every test has to rig that global and put it back.
Options considered
| Option | What it fixes | What it costs | Risk |
|---|---|---|---|
| Parameters for the logic, a provider for the components | Tests use fakes, no global edits | An interface per socket, a provider in tests | Components outside the provider |
| A full container for the whole app | One place for all wiring | Tokens and lifetimes to learn | A locator used everywhere |
The decision
| Field | Decision record |
|---|---|
| Context | Tests depend on global state and real timers, so failures are intermittent and unexplained. |
| Decision | Extract Clock, PriceApi and Analytics interfaces. Move the logic to a factory that takes them as parameters. Provide the built services through one React context at the app root. Keep the wiring in a single composition-root module. |
| Consequences, good | Logic tests need no global edits and are deterministic; components are tested with a provider and fakes. |
| Consequences, bad | Components now depend on a provider being present; one more module to maintain. |
| Revisit when | More than about ten services are wired by hand, or construction order starts to cause bugs. Then add a container, still only in the composition root. |
Rollout and verification
- Extract behind the existing component and keep its public props, so behaviour does not change.
- Write the fake-based tests first, including the failure path (a 503) and a restored saved cart.
- Switch the app root to the composition root and provider, and run the old end-to-end tests unchanged.
- Delete the global stubs once none are used, and watch the flake rate for a few weeks.
The 2 a.m. failure
A week later, error tracking shows a burst of "useServices must be used inside
First three checks: (1) find where the component is mounted and whether it is under the provider; (2) list every place that creates its own root; (3) confirm the error is the hook's own message, not a downstream null error. Mitigation: wrap the pop-up's root in the provider through a shared mount helper exported by the composition root. Follow-up: add a test that mounts every entry point the way production does, and keep the hook's loud failure: a silent default that sent events to the real analytics endpoint from the wrong place would have been harder to find.
Architect's notes
- Decide the sockets, not the library. The interfaces (
Clock,PriceApi) outlive any container or framework choice. - Start with parameters. Add context when drilling hurts and a container when hand wiring does.
- One composition root per entry point, with a helper that every entry point (pop-ups and embedded widgets too) uses.
- Keep interfaces small and in your terms. A one-method
PriceApiis easier to fake than a wrapper around a vendor SDK. - Make a missing provider an error, not a default.
Common mistakes
- Passing the container around. Every module that calls
resolveis a service-locator client with hidden needs. - Interfaces that mirror the vendor SDK. The fake grows as large as the SDK and the seam protects nothing.
- A default context value that is the real implementation. Wiring errors vanish.
- Injecting everything. A pure function such as
formatCentsneeds no injection. - Tests that assert call order of fakes instead of outcomes, so refactors break them.
Key takeaways
- A dependency is anything code needs but does not create. Injection means it is handed in, written against a small interface.
- Three styles: parameters (default), context (a subtree), container (many modules, lifetimes).
- Context uses the nearest provider; the default is a last-resort fallback, so use
nulland fail loudly. - Keep the container in the composition root; everywhere else, receive plain objects.
- Separate concerns (render, logic, adapters, wiring) so each is tested with the others faked.
- Injected clocks, storage and network make tests deterministic.
- Test behaviour, not call order, and test every entry point as production mounts it.
Practice exercises
Exercise 1 — Starter (45 min): Take a function in your own code that calls fetch or Date.now. Write the interface it needs, change it to take the dependency, and write two tests with a fake.
Exercise 2 — Intermediate (2 hours): Add a Notifier port to the lab's cart that is called when a price lookup fails, with a fake and a test. Then add it to the container and the composition root.
Exercise 3 — Advanced (half day): Add a scoped lifetime to the container so each fork has its own instance of a scoped token and shares none with its parent. Test it, including a cycle through a scoped token.
Related reading
In this course:
- Context and Its Render Cost in React, what a context change costs its readers.
- Purity, Strict Mode, Refs and Escape Hatches, why components should not depend on hidden mutable state.
- Monolith vs Micro-Frontends, where the composition root becomes a shell.
Elsewhere on the site:
- Decomposing Microservices: Data Ownership and Distributed Monoliths, the same seam idea on the back end.
Official documentation
- Martin Fowler: Inversion of Control Containers and the Dependency Injection pattern, DI, its three styles and the service locator comparison.
- Mark Seemann: Composition Root, where to compose and where a container may appear.
- React: Passing Data Deeply with Context and createContext, providers, the default value and nearest-provider rules.
- Angular: Dependency injection, DI as a framework feature and its link to test doubles.