Skip to content

    Frontend System Design

    Dependency Injection and Separation of Concerns in the Front End

    Learn dependency injection in the front end: pass dependencies as parameters, through React context or a small container, and test UI code with simple fakes.

    By Published 13 min read

    Share

    Standard lessonintermediateTypeScript labFrontend System Design

    Before this: Layered and Clean Architecture for the UI (coming soon)

    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

    1. A dependency is anything your code needs but does not create itself: the network, the clock, storage, a logger, another module. Calling fetch inside a function means the function reaches out for it.
    2. 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.
    3. 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

    A composition root fills the cart's four sockets with real adapters in production and with fakes in a testComposition root to Cart logic (passes in); Cart logic to Real adapters (calls interface); Cart logic to Fakes (calls interface)PRODUCTIONTESTComposition root: wires socketsCompositionrootwires socketsCart logic: knows interfaces onlyCart logicknows interfacesonlyReal adapters: fetch, Date, localStorageReal adaptersfetch, Date,localStorageFakes: recorded calls, manual clockFakesrecorded calls,manual clockpasses incalls interfacecalls interface

    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

    flowchart TD accTitle: Context providers nested in a component tree accDescr: An outer provider supplies services to a page. Inside the page an inner provider supplies different services to one section. Components read the nearest provider above them, and a component outside all providers receives the default value. P1["Outer ServicesProvider
    (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

    sequenceDiagram accTitle: A container building the cart and its dependencies accDescr: The composition root asks the container for the cart. The cart factory asks for prices, which asks for fetch and the base URL, then for the clock, store and analytics. Each is built once and cached, then the cart is built and returned. participant R as Composition root participant C as Container participant F as Factories R->>C: resolve(cart) C->>F: cart factory F->>C: resolve(prices) C->>F: prices factory F->>C: resolve(fetch), resolve(baseUrl) C-->>F: built and cached F->>C: resolve(clock), resolve(store), resolve(analytics) C-->>F: built and cached F-->>C: cart(prices, clock, store, analytics) C-->>R: cart (singleton, cached)

    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

    flowchart TD accTitle: Choosing a way to inject dependencies accDescr: If the code is one function or module, pass parameters. If many components in one subtree need the same services, use context. If many modules need building in dependency order with lifetimes, use a container, kept in the composition root. Q1{"Who needs the
    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.

    1. Run the tests. In test/cart.test.ts every test builds a cart from four fakes and asserts behaviour; in test/services.test.tsx the nearest-provider test expects two buttons to show $4.50 and $10.00, and the last test expects the error for a missing provider.
    2. Run node src/demo.ts and compare with the output below. Section 2 uses a fake fetch, so a 503 answer and a malformed body become errors without any network.
    3. 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

    NOTE

    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

    Starting architecture: one component calls the network, the clock, storage and analytics directlyCheckout UI to fetch (global); Checkout UI to Date.now (global); Checkout UI to analytics (global)Checkout UI: calls globalsCheckout UIcalls globalsfetch (global): pricesfetch (global)pricesDate.now (global): offer bannerDate.now(global)offer banneranalytics (global): page eventsanalytics(global)page events

    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

    1. Extract behind the existing component and keep its public props, so behaviour does not change.
    2. Write the fake-based tests first, including the failure path (a 503) and a restored saved cart.
    3. Switch the app root to the composition root and provider, and run the old end-to-end tests unchanged.
    4. 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 " from the order-confirmation pop-up, which is mounted by a helper into a separate React root created outside the app root (illustrative). Unit tests never saw it, because they always wrapped the component in a provider. A context provider reaches only components below it in the same tree, and the pop-up's root has no provider above it.

    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 PriceApi is 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 resolve is 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 formatCents needs 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 null and 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.

    In this course:

    Elsewhere on the site:

    Official documentation

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments