Skip to content

Dependency Injection

Why a class should never build its own collaborators, and how one habit makes code testable and swappable.

Intermediate16 minUpdated 27 September 2026

You have written a PaymentService, and you want to test that it rejects negative amounts. Simple enough:

class PaymentService {
  constructor() {
    this.stripe = new StripeClient(process.env.STRIPE_KEY);
    this.db = new PostgresConnection(process.env.DATABASE_URL);
    this.mailer = new SendGridMailer(process.env.SENDGRID_KEY);
  }

  async charge(userId, amount) {
    if (amount <= 0) throw new Error('Amount must be positive');
    // ...
  }
}

Now write the test:

const service = new PaymentService();  // <- this line is the problem

That single line opens a database connection, creates a Stripe client, and needs three API keys — before your test has even called the method. To test one if statement you must configure an entire production environment. And if the test does reach the charging code, it will attempt a real payment.

The class is untestable. Not because the logic is complicated, but because of where its collaborators came from.

The one-word diagnosis

PaymentService uses new inside itself. It does not merely use Stripe — it decides to use Stripe, and hardcodes that decision.

A class that constructs its own dependencies has welded itself to specific implementations. You cannot substitute anything, because there is no seam to substitute at.

The fix: pass them in

Dependency injection is one small change — take collaborators as parameters instead of creating them.

class PaymentService {
  constructor(paymentGateway, db, mailer) {
    this.gateway = paymentGateway;
    this.db = db;
    this.mailer = mailer;
  }

  async charge(userId, amount) {
    if (amount <= 0) throw new Error('Amount must be positive');
    const result = await this.gateway.charge(userId, amount);
    await this.db.recordPayment(userId, amount, result.id);
    await this.mailer.sendReceipt(userId, amount);
    return result;
  }
}

Nothing clever happened. The class stopped choosing and started receiving. But look at the test now:

test('rejects negative amounts', async () => {
  const service = new PaymentService(null, null, null);
  await expect(service.charge(1, -50)).rejects.toThrow('Amount must be positive');
});

test('records the payment after a successful charge', async () => {
  const fakeGateway = { charge: async () => ({ id: 'ch_123' }) };
  const recorded = [];
  const fakeDb = { recordPayment: async (...args) => recorded.push(args) };
  const fakeMailer = { sendReceipt: async () => {} };

  const service = new PaymentService(fakeGateway, fakeDb, fakeMailer);
  await service.charge(1, 100);

  expect(recorded).toEqual([[1, 100, 'ch_123']]);
});

No database. No API keys. No real money moved. Milliseconds to run.

Where the decision goes instead

Someone still has to choose Stripe. That choice moves to a single place near the start of your program, often called the composition root.

// main.js — the one place that knows which implementations to use
const gateway = new StripeClient(process.env.STRIPE_KEY);
const db = new PostgresConnection(process.env.DATABASE_URL);
const mailer = new SendGridMailer(process.env.SENDGRID_KEY);

const paymentService = new PaymentService(gateway, db, mailer);
const orderService = new OrderService(paymentService, db);

startServer({ paymentService, orderService });
Conceptual diagramreceivesreceivesmain.jschooses implementationsPaymentServiceOrderServiceStripeClientPostgres
Diagram, as text
  • main.js chooses implementations leads to PaymentService
  • main.js chooses implementations leads to OrderService
  • PaymentService leads to StripeClient (receives)
  • PaymentService leads to Postgres (receives)

Concentrating construction here has a pleasant side effect: you can see the shape of your whole application in one file. Swapping Stripe for Adyen is one line, and no service changes at all.

The three ways to inject

Constructor injection — pass dependencies when creating the object. This is the default and the one to reach for. It makes requirements explicit and guarantees the object is fully formed the moment it exists.

Setter injection — assign after construction. Useful for genuinely optional collaborators, but it allows an object to exist in a half-built state, so use sparingly.

Method injection — pass the dependency to the specific method that needs it. Right when only one method uses it, or when the choice varies per call.

// Method injection: which formatter to use varies per export
class Report {
  export(data, formatter) {
    return formatter.format(data);
  }
}

Why this is not just about tests

Testability is the benefit people notice first, but there are three others.

Swapping implementations. Development uses an in-memory store; production uses Postgres. Same service, different constructor argument.

Honest dependencies. A constructor taking six collaborators is telling you the class does six things. That discomfort is useful information — hidden new calls conceal the same complexity without the warning.

Breaking import cycles. When A imports B and B imports A, injection lets both depend on an interface instead of each other.

The clock: the example that convinces people

A subtle case worth internalising. This code cannot be tested reliably:

class Subscription {
  isExpired() {
    return new Date() > this.expiresAt;   // depends on the real clock
  }
}

To test the expired case you must either wait, or manipulate the machine's time. Inject the clock instead:

class Subscription {
  constructor(clock = () => new Date()) {
    this.clock = clock;
  }
  isExpired() {
    return this.clock() > this.expiresAt;
  }
}

// In a test: total control
const sub = new Subscription(() => new Date('2030-01-01'));

Anything non-deterministic — time, randomness, network, filesystem — is a dependency, even when it does not look like an object.

Check your understanding

Common mistakes

The service locator anti-pattern. Passing a container and calling container.get('db') inside the class hides dependencies again. The constructor no longer tells you what the class needs, and you are back to guessing.

Injecting concrete classes when you meant an interface. Depending on StripeClient specifically buys you little; depending on "something with a charge method" is what makes it swappable.

Constructors with fifteen parameters. DI did not cause this; it revealed it. The class does too much, and the fix is to split it.

Reaching for a DI framework immediately. In most languages, passing arguments to constructors is all you need. Frameworks help in large codebases; in small ones they add magic that obscures what is happening.

Practice

A class takes nine constructor dependencies. A colleague says dependency injection has made the code worse. Diagnose properly.

Write your own answer first, on paper or in a note. Then compare.

Show a hint

The count is a symptom. Ask what it is a symptom of.

Show a strong answer

The colleague is reading the symptom as the disease. Injection did not create nine dependencies; it made nine dependencies visible. Before, the same class constructed them internally and looked tidy while being exactly as coupled.

What nine dependencies actually indicates: the class has too many responsibilities. Something needing an email client, a payment gateway, three repositories, a cache, a clock and a metrics recorder is doing several jobs, and each one is a reason to change it.

How I would address it:

  1. Group the dependencies by which methods use them. Almost always they form clusters — four used only by one method, three by another. Those clusters are the classes hiding inside this one, and splitting along them reduces both the count and the responsibilities.
  2. Look for dependencies that belong behind one abstraction. Three repositories used together in one workflow may really be one domain concept.
  3. Question the cross-cutting ones. Metrics and logging often do not belong as constructor parameters at all — they can be applied around the class rather than inside it.

What I would not do: hide them again. Reintroducing internal construction, or passing a service-locator or container into the class, makes the count look better and the design worse — now the dependencies are unknowable and untestable.

The useful framing: treat the constructor as a complexity meter that cannot be gamed. When it gets uncomfortable, that discomfort is the signal working correctly.

Compare constructor, setter and method injection for a service that needs a database repository and an optional feature-flag checker. Choose for each dependency.

Write your own answer first, on paper or in a note. Then compare.

Show a hint

Ask whether the object can be valid without it.

Show a strong answer

The deciding question for each dependency is: can the object be in a valid state without it?

The repository — constructor injection. The service cannot do anything meaningful without it, so it is required. Constructor injection means:

  • An instance either exists fully formed or does not exist at all; there is no half-initialised window where a caller can get a null-reference failure.
  • The dependency can be held in a final or read-only field, which makes the object safe to share across threads.
  • The requirement is visible and compiler-enforced.

The feature-flag checker — also constructor injection, but with a null-object default. Setter injection would seem to fit "optional", but it reintroduces a window in which the object exists and is not fully configured, and it makes the field mutable for no benefit. A better approach is to require it and provide a default implementation that always returns "off". The object stays immutable, callers who do not care pass nothing, and the service has no if (checker != null) scattered through it.

Method injection fits neither of these, and does fit a third case worth naming: a dependency that varies per call rather than per instance — the current request's user, or a cancellation token. Passing those in the constructor would freeze a per-call value into a shared object, which is a genuine bug rather than a style preference.

Summary: constructor for what the object needs to exist, method parameter for what varies per call, and setter injection for almost nothing — its main legitimate use is frameworks that must construct objects before they can configure them.

Key takeaways

  • A class that calls new on its collaborators has welded itself to them — there is no seam left to substitute a fake.
  • Dependency injection just means passing collaborators in rather than constructing them internally. The class stops choosing and starts receiving.
  • The decision moves to a composition root near program start, where the shape of the whole application becomes visible in one file.
  • Prefer constructor injection; use setter or method injection only for optional or per-call dependencies.
  • Inject anything non-deterministic or environment-bound — databases, HTTP clients, mailers, and especially clocks and random sources. Do not inject pure utilities.
  • A constructor with too many parameters is honest feedback that the class does too much.

Further reading

  • Martin Fowler, "Inversion of Control Containers and the Dependency Injection pattern" (2004) — where the terms were fixed.
  • Robert C. Martin, "The Dependency Inversion Principle" (C++ Report, 1996).
  • Mark Seemann and Steven van Deursen, Dependency Injection Principles, Practices, and Patterns (Manning, 2019) — including why service locators are an anti-pattern.
  • Michael Feathers, Working Effectively with Legacy Code (Prentice Hall, 2004) — seams, and injecting dependencies into code that has none.
  • Bobby Woolf, "Null Object" in Pattern Languages of Program Design 3 (Addison-Wesley, 1997).

Check yourself

3 questions in this lesson. Answer each once to complete it.

No problem practices this lesson yet. Browse the practice problems

Next lessonCode Organization & Modularization
Lessons in this track