Dependency Injection and Inversion of Control

The ticket was two words: “handle declines.” When a customer’s card gets rejected, checkout should not sit on a spinner forever, it should show a real error and offer a retry. Reasonable. I opened the order service to write a test for the declined path, and I could not.

Here is what I was looking at:

class OrderService {
  async placeOrder(cart) {
    const stripe = new StripeClient(process.env.STRIPE_KEY);
    const charge = await stripe.charge(cart.total, cart.token);
    if (charge.status !== "succeeded") {
      throw new PaymentError("card_declined");
    }
    await this.saveOrder(cart, charge.id);
    return { ok: true, id: charge.id };
  }
}

To exercise the declined branch I need a real card that declines, on a real network, with a real API key that CI does not have. So the one path most worth a test, the money path that fails, is the exact path no test can reach. That new StripeClient(...) on the first line is the whole reason.

The service reaches out and builds its own dependency. It is welded to Stripe. You cannot test it offline, you cannot point it at a sandbox without editing this file, and you cannot swap Stripe for another processor without a rewrite. The coupling is invisible from the outside too: the constructor signature says OrderService needs nothing, while in truth it needs a payment gateway, an API key, and a network. The dependency is real. It is just hidden.

Pass it in

The fix is almost too small to have a name. Stop building the client inside. Take it as an argument.

class OrderService {
  constructor(payments) {
    this.payments = payments; // anything with a charge() method
  }

  async placeOrder(cart) {
    const charge = await this.payments.charge(cart.total, cart.token);
    if (charge.status !== "succeeded") throw new PaymentError("card_declined");
    await this.saveOrder(cart, charge.id);
    return { ok: true, id: charge.id };
  }
}

Same query, same branch, same logic. One difference: payments arrives through the door instead of being grabbed behind everyone’s back. The constructor now tells the truth about what this object needs to work.

In real JavaScript you often skip the class entirely. A factory function that closes over its dependencies does the same job with less ceremony, and it is the shape you will see most in modern codebases:

export function makeOrderService({ payments, orders }) {
  return {
    async placeOrder(cart) {
      const charge = await payments.charge(cart.total, cart.token);
      if (charge.status !== "succeeded") throw new PaymentError("card_declined");
      await orders.save(cart, charge.id);
      return { ok: true, id: charge.id };
    },
  };
}

That closure is dependency injection. There is no library, no annotation, no container. payments and orders are captured the same way any closure captures a variable, and every method built inside can reach them. If that feels familiar, it should: this is the factory pattern doing double duty, a function that takes its collaborators and returns an object built over them.

Production creates the real client and hands it in:

const orderService = makeOrderService({
  payments: new StripeClient(process.env.STRIPE_KEY),
  orders: orderRepo,
});

A test hands in a fake, in the test, with nothing to clean up afterward:

const declining = { charge: async () => ({ status: "card_declined" }) };
const service = makeOrderService({ payments: declining, orders: fakeOrders });

await expect(service.placeOrder(cart)).rejects.toThrow(PaymentError);

No network. No key. No 700ms round trip. The declined path is now a two-line fake and a deterministic assertion. The ticket that was impossible an hour ago is a ten-line test.

welded: builds its ownStripe · live networknew insideplaceOrder()new StripeClient()unit testno seamthe decline path has no testinjected: receives itreal clientfake clientin a testconstructor(payments)uses payments.charge()prod wires real, a test wires fakesame placeOrder(); only the source of the client changed
Left: the service builds its own client, so a test cannot get in. Right: it receives the client, so production wires the real one and a test wires a fake.

Notice what did not change. In production you still call new StripeClient() exactly once, and there is still one shared client for the whole process. The difference is that “one client” is now a wiring decision made in one file, not a rule every module reimplements by reaching for a global. This is the same lesson the singleton chapter arrives at from the other side: a global you reach for is the thing that hurts, and passing it in is the cure.

The fancy name: inversion of control

What just happened has a grander title than it deserves. Before, OrderService was in control of how its payment client came to exist. It called new. It picked the class, it passed the key, it owned the decision. After the refactor it has no idea and no opinion. Whoever assembles the app decides, and the service simply uses whatever it was handed.

Control over construction moved out of the component and up to the caller. That sentence is inversion of control. Dependency injection is one specific, common way to do it: the way where the dependency is handed in as an argument.

beforeOrderServiceStripeClientnewthe component builds its own client, and so is coupled to itafter · inversion of controlcomposition rootStripeClientOrderServicebuildsinjectsconstruction moved out to the root, and that move is the inversion
The act of constructing the Stripe client moves out of the order service and up to the composition root. That relocation is the inversion.

You already invert control constantly without calling it that. Every time you pass a callback to array.map, the array decides when to call your function and with what. Every time you write an Express handler, the framework calls you, you do not call it. This is the Hollywood principle, “don’t call us, we’ll call you,” and it is the same instinct that makes composition over inheritance work: you assemble behavior from parts you pass in, rather than baking it into a hierarchy you cannot rearrange.

Why this is really about tests

Every benefit of injection is nice, but one is the reason the pattern pays for itself: you can substitute a dependency in a test. That is the whole game. A unit test is only a unit test if you can isolate the unit, and you can only isolate it if its collaborators are things you handed it, not things it reached out and grabbed.

Once the payment gateway is a parameter, the full matrix opens up. A gateway that always approves. One that always declines. One that throws a network error. One that times out. One that approves the first call and declines the retry. Each is three or four lines, and none of them touches a real card.

const flaky = {
  calls: 0,
  charge() {
    this.calls += 1;
    if (this.calls === 1) return Promise.reject(new Error("ETIMEDOUT"));
    return Promise.resolve({ status: "succeeded", id: "ch_retry" });
  },
};

// now you can assert your retry logic actually retries

Try it below. The same makeOrderService runs in both modes. In welded mode it builds its own gateway and you are stuck with a real, slow, random outcome, which is exactly the corner I was painted into at the top. Flip to injected mode and you decide the outcome, instantly, every time.

interactiveWelded vs injected: inject the outcome you want to test

The point the demo makes with its hands: in welded mode the outcome is not yours to choose, so the decline branch is untestable without real infrastructure. In injected mode the branch is a value you pass. Same code. The only thing that moved is who supplies the gateway.

productionreal StripeClientmakeOrderServicecharges a live carda unit testfake: always declinesmakeOrderServicecard_declined, no networkdeterministic, fastthe exact same makeOrderService, only the injected client differs
The exact same makeOrderService, wired two ways. Production feeds it a real client; a unit test feeds it a fake that declines with no network in sight.

There is a second win hiding here, and it shows up the day a business decision lands on you. “We are moving off Stripe.” If forty files each did new StripeClient(), that is a forty-file change and a bad week. If the client is injected, the swap happens in one place, and as long as the new client honors the same charge() shape, nothing downstream notices. When the shapes do not match, an adapter makes the new client look like the old one, and the same seam that made testing easy makes swapping providers or failing over easy too.

The composition root

If every unit receives its dependencies instead of building them, one honest question follows: somebody still has to call new StripeClient(). Where does that live?

The answer is a single place at the very edge of the app, run once when the process starts, that builds the real objects and wires them together. It has a name, the composition root, and the discipline is simple: concrete choices happen here and nowhere else. Everything inside stays ignorant of which database, which payment processor, which clock.

// main.js: the composition root, the only file that names concretes
import { Pool } from "pg";
import { StripeClient } from "./stripe.js";
import { makeOrderRepo } from "./order-repo.js";
import { makeOrderService } from "./order-service.js";
import { startServer } from "./server.js";

// build the leaves first, then the things that depend on them
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const orderRepo = makeOrderRepo(pool);
const payments = new StripeClient(process.env.STRIPE_KEY);
const orderService = makeOrderService({ payments, orders: orderRepo });

startServer({ orderService });

Read the imports in every other file and you will not find pg, stripe, or process.env. Those names appear once, here. The core modules import each other’s factory functions and nothing concrete. That is the property you are buying: the code that holds your business rules can be understood, and tested, without knowing a database exists.

composition root · main.js · runs once at startupnew Pool() new StripeClient() reads process.envapplication coreorderServiceorderRepoemailerinjecteach unit depends only on what it is handedno concrete database, SDK, or env named in hereconcretes live at the edge; the core stays dependency-agnostic
Concrete objects are built once at the top edge and injected downward. The application core names no databases, no SDKs, no environment.

The composition root is also where “one instance” gets enforced honestly. You want one connection pool, so you build one pool and pass it to everything that needs it. No getInstance(), no static field, no global. Single-ness is a fact about how you wired the graph, not a rule enforced by ceremony scattered across the codebase.

Three ways to hand something in

There are exactly three shapes, and JavaScript makes all of them cheap.

Function parameters. The plainest. A function takes what it needs and returns a result. makeOrderService({ payments, orders }) is this. When a unit is really one operation, do not wrap it in a class, just take the dependency as an argument.

Constructor or factory arguments. When an object has several methods that all share the same collaborators, hand them in once at construction and let every method close over them. The class version stores them on this; the factory version captures them in the closure. Same idea, and in JavaScript the factory is usually the lighter tool. Reach for a class when identity, instanceof, or a base class genuinely earns it.

A config or options object. When there are more than two or three, name them. makeThing({ db, cache, clock, logger }) reads better at the call site than four positional arguments whose order you have to remember, and adding a fourth dependency does not shuffle the others.

// positional gets unreadable fast
makeReportService(db, cache, clock, logger, mailer);

// named survives a fifth dependency without a game of musical chairs
makeReportService({ db, cache, clock, logger, mailer });

That is the entire toolbox. No decorators, no reflection, no container. In JavaScript, dependency injection is mostly “add a parameter,” and if a lesson ever makes it sound heavier than that, the lesson was translated from Java and never adapted.

When a container earns its place

So why do DI frameworks exist at all, and why does half the industry use one?

Watch the composition root grow. Ten services is thirty tidy lines. Eighty services, each needing a repo, each repo needing the pool, half of them needing a cache and a clock and a metrics client, and now main.js is four hundred lines of hand-wiring where the order matters and a single misplaced line breaks boot. The wiring itself becomes the chore. That, and only that, is the problem a container solves.

A container is a registry of recipes. You tell it how to build each thing, and it works out the order and constructs the graph on demand.

import { createContainer, asFunction, asValue } from "awilix";

const container = createContainer();
container.register({
  config: asValue(loadConfig()),
  pool: asFunction(makePool).singleton(),                 // makePool({ config })
  orderRepo: asFunction(makeOrderRepo),                   // makeOrderRepo({ pool })
  payments: asFunction(({ config }) => new StripeClient(config.stripeKey)),
  orderService: asFunction(makeOrderService),             // needs { payments, orderRepo }
});

// ask for the top of the graph, get everything under it built for you
const orderService = container.resolve("orderService");

You never wrote “orderService needs orderRepo needs pool needs config.” The container reads each dependency off the factory’s destructured parameters and assembles the graph bottom-up. On eighty services that is a real relief.

resolve(‘orderService’) builds everything under it for youconfigpoolorderRepopaymentsorderServicearrows point from a dependency to the thing that needs it; the container builds leaves firstthe cost: wiring is resolved by name at runtime, so a typo fails at runtime, not at compile time
Register how to build each node, then ask for the top one. The container walks the graph and constructs the dependencies underneath it in order.

The frameworks people reach for are worth naming so you recognize them. NestJS and Angular put the container front and center: you tag a class with @Injectable(), declare dependencies as constructor parameters, and the framework resolves them for you. Awilix and InversifyJS are standalone containers you bolt onto anything.

Nothing is free. A container buys you less wiring and charges you indirection. With hand-wiring you can click from a use of orderRepo straight to the line that built it. With a container the connection is a string or a type resolved at runtime, and your editor cannot follow it. Awilix’s default mode injects a “cradle” object and matches dependencies by property name, which means a rename in one file and not the other fails when the code runs, not when it compiles. The graph you used to read top-to-bottom in main.js now lives implicitly across decorators and registrations, and new engineers have to learn the framework before they can trace a single dependency.

My honest advice: start with hand-wiring and a composition root. It is zero dependencies, fully typed, and trivial to trace. Add a container the day the wiring genuinely hurts, not the day you read about one. Plenty of large, healthy JavaScript codebases never add one at all.

Over-injection, the other failure mode

Once injection clicks, the temptation is to inject everything, and that is its own mess.

The tell is a constructor with seven parameters, or a “just in case” interface wrapped around something that has exactly one implementation and always will. Someone read that loose coupling is good and applied it with a fire hose. Now every pure function is hidden behind an injected IStringFormatter, the wiring is enormous, and reading the code means chasing five indirections to find the one line that does the work.

Do not inject things that do not vary.

// over-injected: none of these will ever be a different thing
function makeInvoice({ json, math, dateFormatter, stringUtils, currency }) {
  // ...five parameters to add two numbers and format a string
}

// honest: import the stable stuff, inject only what changes per environment
import { formatCurrency } from "./format.js";

function makeInvoice({ clock }) {
  const now = clock.now(); // injected, because tests need to freeze time
  // formatCurrency imported directly, because it is pure and never swapped
}

The question to ask about every dependency is blunt: would you ever hand this unit a different one? A payment gateway, yes, a fake in tests and maybe a second processor later. A database pool, yes. The clock, often, because time-dependent logic is a nightmare to test otherwise, so a tiny clock with a now() is worth its keep. But JSON, Math, a pure formatting helper, a constant? You will never swap them. Import them directly and move on. Injecting a thing that has one forever-implementation adds a parameter, a wiring line, and a layer of fog, and buys nothing.

There is a real cost to interfaces-for-everything beyond the noise: it makes the code lie about its flexibility. A seam implies “this can be swapped,” so a reader spends effort wondering what the alternatives are. When the answer is “there are none and never will be,” every one of those seams was a small false promise. Keep the seams where substitution is real. Everywhere else, a direct import is clearer and more honest.

Summary

  • Dependency injection is one move: instead of a unit building or importing the things it needs, it receives them as arguments, and the caller decides what to hand over. In JavaScript that is usually just a function parameter or a factory that closes over its collaborators.
  • The payoff is testability. A dependency you passed in is a dependency you can replace with a fake, so the hard paths (a declined card, a timeout, a retry) become deterministic tests with no network. Code that calls new or imports a global internally has no seam, and its most important branches stay untested.
  • Inversion of control is the general idea: control over how a dependency is built moves out of the component and up to whoever assembles the app. DI is the slice of IoC that is about wiring dependencies. Callbacks and frameworks are IoC too.
  • Concretes get built in one place, the composition root, at the edge of the app, run once at startup. Everything inside stays ignorant of which database or SDK is in use, which is what keeps the core testable and swaps (Stripe to something else) a one-line change. Pair it with an adapter when shapes differ.
  • Three shapes cover it: function parameters, constructor or factory arguments, and a named options object once there are more than two or three. Prefer factories and closures over classes unless a class genuinely earns it.
  • A container (Awilix, InversifyJS, the one inside NestJS) earns its place only when a hand-wired root gets genuinely unwieldy on a deep graph. It trades less wiring for more indirection: name-based runtime resolution your editor cannot follow, and magic new engineers must learn. Nest’s constructor injection still rides the legacy reflect-metadata path in 2026, separate from the standard decorator syntax.
  • Resolving from a container inside business code is a service locator, which is the singleton problem again. Keep the container at the root; keep units on plain arguments.
  • Over-injection is the opposite failure: seven-parameter constructors and interfaces around things with one forever-implementation. Inject what would ever vary or need faking; import the stable and pure stuff directly; pass values as values.
  • When to use: anything that touches the network, disk, clock, or a third party, and anything a test needs to control. When to avoid: pure functions, constants, config values, and any dependency you would never swap. If you would mock it, inject it; otherwise, import it.