Dependency Injection
Handing a function or class the things it needs from outside, rather than letting it create those things itself — making it far easier to swap or test.
What is it?
Imagine a function that sends a welcome email, and inside that function it directly creates a connection to a real email-sending service. That seems fine, until you try to write a test for it: every single test run would actually try to send a real email, hit a real network, and depend on a real third-party service being up and configured correctly. You can't easily swap in a fake version, because the function decided, all by itself, exactly which email service to talk to — and that decision is buried inside it.
Dependency injection flips that: instead of a function or class creating the things it depends on, those dependencies are handed to it from the outside — usually as parameters — so whoever is calling it controls what gets used. In a test, you can hand it a fake, predictable version of the email service instead of the real one; in production, you hand it the real one.
Explain like I'm 10
A restaurant kitchen that insists on growing its own vegetables, raising its own chickens, and mining its own salt would be nearly impossible to inspect or adjust. A kitchen that instead receives its ingredients from outside suppliers can easily swap one supplier for another — including, for a health inspection, temporarily swapping in ingredients specifically prepared for testing.
Examples
Without dependency injection — hard to test
const realEmailService = require("./realEmailService");
async function sendWelcomeEmail(user) {
// The function decides, internally, exactly which service to use
await realEmailService.send(user.email, "Welcome!");
}This function can only ever use the real email service — there's no way to substitute anything else without editing this file itself.
With dependency injection — swappable and testable
async function sendWelcomeEmail(user, emailService) {
await emailService.send(user.email, "Welcome!");
}
// Production
await sendWelcomeEmail(user, realEmailService);
// Test
const fakeEmailService = { send: jest.fn() };
await sendWelcomeEmail(user, fakeEmailService);
expect(fakeEmailService.send).toHaveBeenCalledWith(user.email, "Welcome!");The function no longer decides which email service to use — it just uses whatever is passed in, letting a test supply a fake, observable version instead of touching anything real.
How it works
A function or class that needs something (a database connection, an email service, a clock, a logger) declares that need as a parameter (or a constructor argument, in an object-oriented style) instead of constructing or importing it directly inside its own body. Whoever calls the function — production code, a test, or a wiring layer set up at app startup — decides what concrete implementation to supply. In larger applications, a dependency injection container can automate that wiring, but the core idea is the same at any scale: the dependency comes from outside, not from within.
Why does it exist?
Code that constructs its own dependencies internally is rigid: it can only ever be tested and run against those exact, real dependencies, which is often slow, flaky, or outright impossible in a test environment (you don't want tests actually charging real credit cards). Dependency injection exists to decouple what a piece of code needs from which specific implementation satisfies that need, making code dramatically easier to test in isolation and to reconfigure without editing its internals.
When to use it
Reach for dependency injection for anything that talks to the outside world — databases, external APIs, the filesystem, the current time — especially in code you want to unit test without those real dependencies being involved.
When not to use it
For small, self-contained utility functions with no external dependencies at all (like a pure function that formats a date), there's nothing to inject — adding indirection here just adds noise without any testability benefit.
Common mistakes
Importing and using a real dependency directly inside a function, then being surprised it's impossible to test in isolation.
Over-engineering dependency injection for simple, pure logic that has no external dependencies to swap in the first place.
Injecting a dependency but still reaching for a global or imported version of it somewhere else in the same code path, defeating the purpose.
Practice exercises
- Easy:
Rewrite a function that directly calls
Date.now()internally so that the current time is instead passed in as a parameter. - Medium:
Take a function that directly imports and uses a real database client, and refactor it to accept the database client as a parameter instead.
- Hard:
Write a test for a
chargeCustomer(order, paymentService)function using a fakepaymentService, and explain what real-world problems that avoids compared to testing against the real payment provider.
Interview questions
What is dependency injection?
A design approach where a function or class receives the things it depends on from outside — usually as parameters or constructor arguments — instead of creating or importing them internally.
Why does dependency injection make code easier to test?
Because a test can supply a fake, predictable version of a dependency (like a fake email service or database) instead of the real one, avoiding slow, flaky, or unsafe real-world side effects.
Give an example of a dependency worth injecting rather than hardcoding.
An external service client (email, payments, a database connection) or even something like the current time — anything that a test would want to replace with a controlled, fake version.
What's the difference between constructor injection and parameter injection?
Constructor injection passes dependencies in when an object is created and stores them on the instance (e.g. this.db), available to every method; parameter injection passes a dependency directly into one function call, scoped only to that call.
Why might constructor injection suit a class better than parameter injection, if many of its methods all need the same database connection?
It avoids threading the same parameter through every single method's signature — the dependency is supplied once, at construction time, and every method can simply reach for this.db afterward.
What is inversion of control, and how does dependency injection relate to it?
Inversion of control is the broader principle that a component shouldn't decide how its own dependencies get constructed or located. Dependency injection is one concrete technique for achieving that — the decision of which implementation to use is inverted outward, to whoever wires the component up.
What problem does a dependency injection container solve as an application grows?
It automatically constructs and wires together an entire graph of dependencies based on registered rules, so you don't have to manually thread every dependency down through every layer of the app by hand.
How does NestJS build dependency injection into the framework itself?
A NestJS class declares what it needs as constructor parameters, and NestJS's own container automatically constructs and supplies matching instances at runtime — you rarely construct a dependency by hand at all.
In FastAPI, how does `Depends()` implement dependency injection for a route handler?
A route parameter's default value is set to Depends(some_function); FastAPI calls some_function itself and injects its return value as the argument, so the route handler never constructs that dependency directly.
What's the difference between a stub, a mock, and a fake, as types of test doubles?
A stub just returns preprogrammed values with no checks on how it was called; a mock records calls so a test can assert specific interactions happened; a fake is a simplified but genuinely working implementation, like an in-memory database standing in for a real one.
In the sendWelcomeEmail example, why is `jest.fn()` specifically useful as the fake `emailService.send`?
jest.fn() creates a mock function that records every call made to it, so the test can assert exactly what arguments it was called with — a plain stub that just returns a value couldn't verify that.
What is the service locator pattern, and why is it often considered worse than dependency injection?
A class asks a central registry for its dependencies by name or type at runtime, instead of receiving them as parameters. This hides the class's real dependencies inside its body and still complicates testing, since a test must configure the shared locator rather than simply passing in a fake directly.
Why does dependency injection often go hand-in-hand with depending on an interface rather than a concrete class?
The consumer only needs to know the shape it depends on — e.g. 'something with a .send() method' — not which concrete implementation satisfies it, and that's exactly what makes swapping a real service for a fake possible without touching the consumer's code.
Debugging: a test injects a fake database, but the function under test still hits the real one. What's a likely cause?
The function is probably still importing and using the real database client directly somewhere in its body, alongside the injected parameter — injecting one dependency while still reaching for a global or imported version elsewhere defeats the point.
Given `async function sendWelcomeEmail(user, emailService) { await emailService.send(...) }`, what happens if it's called as `sendWelcomeEmail(user)` with no second argument?
emailService is undefined, so calling .send(...) on it throws a TypeError at runtime. Dependency injection shifts responsibility for supplying a valid dependency onto the caller, and this is what happens when a caller forgets to.
Trap: a developer adds dependency injection to a pure function like `add(a, b)`. What's wrong with that?
There's nothing to swap or fake in the first place — a pure function with no external dependencies gains no testability from injection, just an extra parameter and indirection with no real benefit.
What is a singleton dependency lifetime in a DI container, and when is it appropriate?
The container constructs the dependency once and hands the same shared instance to every consumer that asks for it — appropriate for something stateless or expensive to build, like a database connection pool, where constructing a fresh one per use would be wasteful or incorrect.
How does a transient lifetime differ from a singleton one?
A transient dependency is constructed brand-new every single time it's requested, rather than shared — appropriate when instances must not carry over state between uses, at the cost of paying construction overhead repeatedly.
Why is dependency injection considered especially important for code that talks to the outside world, like databases or the filesystem?
Those are exactly the dependencies that are slow, non-deterministic, stateful, or unsafe to exercise for real inside an automated test — being able to substitute a controlled fake for them is what makes that code testable at all.
Scenario: you need to test a function that checks 'is this coupon still valid?' based on the current time. How does injecting the clock help?
The test can pass a fixed, known timestamp instead of calling the real current time, making the test deterministic and repeatable no matter when it actually runs — including right at a boundary like midnight, which a real clock couldn't reliably reproduce.
What's a real cost of taking 'program to an interface' too far when applying dependency injection?
Introducing an interface and an injection point for a dependency that will realistically only ever have one implementation adds boilerplate and indirection without any real testability or flexibility payoff.
How does dependency injection support the single responsibility principle?
A class that constructs its own dependencies is doing two jobs — its actual logic, and deciding how to build or configure a collaborator. Injecting the dependency removes that second job, letting the class focus purely on its own logic.
Why inject a logger instance instead of importing a global logger directly?
A test can inject a no-op or in-memory logger to keep test output clean and assert on what was logged, and different environments — production, a CLI tool, a test run — can be wired with different logging behavior without changing the function's own code.
How is dependency injection different from just passing plain configuration values, like a port number, as parameters?
Dependency injection specifically refers to supplying behavior — an object with methods the code actually calls, like a service or client — not plain data. Passing a config value as a parameter isn't usually what's meant by 'dependency injection' on its own.
Follow-up: once a database dependency is injected into a function, how do you avoid every caller having to explicitly pass it at every call site?
Wire the real dependency once at the application's composition root at startup — or let a DI container manage it — so it's threaded through automatically from one place, rather than manually re-passed at every layer of the app.
What real-world problem does injecting a fake payment service avoid, compared to testing against a real payment provider?
It avoids actually charging a real card, needing network access to a third party, and test runs becoming slow or flaky because of that external service's availability or rate limits.
How does a module-mocking tool like `jest.mock()` achieve a similar benefit to dependency injection without changing a function's signature?
It intercepts module resolution and swaps a module's real implementation for a mock version wherever it's imported, achieving a similar testing benefit to DI but by replacing the import itself rather than requiring the function to accept the dependency as an explicit parameter.
Trap: a test's fake service has a different method signature than the real service it replaces. What risk does this create?
The test can keep passing while the fake has silently drifted from the real dependency's actual contract, giving false confidence — a fake needs to be kept honest against the real interface it stands in for, e.g. by having both implement a shared, typed interface.
Why is dependency injection often described as a form of loose coupling?
The consuming code depends only on an abstract need — 'something that can send an email' — not on the specific concrete module that fulfills it. The two sides are connected only by whatever gets passed in at the call site, so either can change independently.