What dependency injection means
Dependency injection (DI) means a piece of code receives the objects it needs from outside instead of creating them itself. Typical dependencies are a database client, an HTTP client or a clock. The most common form is constructor injection: a class lists its dependencies as constructor parameters, and whoever creates it passes them in.
Without it, a class that calls new PostgresClient() in its constructor is tied to that database. You can't test its logic without a running database, and switching storage means editing the class.
With constructor injection, the class asks for something that matches an interface. The choice of implementation moves to the code that assembles the application. Setter and property injection also exist, but constructor injection makes dependencies explicit and the object usable as soon as it exists.
Constructor injection in TypeScript
In the example below, ReminderService needs invoice data and the current time. It declares both as interfaces and receives them in its constructor. It doesn't know whether invoices come from PostgreSQL, an API or an in-memory array.
The one place that creates the real implementations is called the composition root. It is usually the application's entry point or a module that wires services together, and keeping construction there makes the dependency graph easy to find.
type Invoice = { id: string; dueDate: Date; paidAt: Date | null };
interface InvoiceRepository {
findOverdue(asOf: Date): Promise<Invoice[]>;
}
interface Clock {
now(): Date;
}
class ReminderService {
constructor(
private readonly invoices: InvoiceRepository,
private readonly clock: Clock,
) {}
async overdueCount(): Promise<number> {
const overdue = await this.invoices.findOverdue(this.clock.now());
return overdue.length;
}
}
// Composition root: the one place that picks real implementations.
// PostgresInvoiceRepository and pool are defined elsewhere.
const service = new ReminderService(
new PostgresInvoiceRepository(pool),
{ now: () => new Date() },
);
// In a test: small hand-written fakes, no database.
const testService = new ReminderService(
{ findOverdue: async () => [{ id: 'inv_1', dueDate: new Date('2026-01-01'), paidAt: null }] },
{ now: () => new Date('2026-02-01T00:00:00Z') },
);Inversion of control vs DI containers
Inversion of control (IoC) is the broader idea: your code hands control of some decision to something outside it. A framework that calls your route handler applies IoC to control flow. Dependency injection applies it to one decision, which is who creates a dependency.
A DI container is a library that automates the wiring. You register which implementation goes with each interface or token, and the container builds objects and their dependencies for you. Examples include the injectors in Angular and NestJS, Spring in Java and Microsoft.Extensions.DependencyInjection in .NET.
A container is optional. Wiring by hand in a composition root, sometimes called pure DI, is plain code the compiler checks. Containers pay off in large object graphs with scoped lifetimes, such as one database transaction per request. They also add decorators, registration rules and errors that appear only at runtime when a binding is missing.
A constructor that takes eight dependencies is a warning sign either way. The class probably has too many responsibilities, and splitting it helps more than a container would.
How dependency injection helps testing
Injected dependencies let a test replace slow or unpredictable parts with simple fakes. An in-memory repository returns fixed records and a fake clock returns a fixed date. A test for overdue invoices then gives the same result every day and runs without a database.
Prefer small hand-written fakes over mocks that assert every call. Tests that check which methods were called, in which order, break on every refactor even when the behavior is unchanged.
Fakes have a limit: they prove your logic works against the fake. You still need integration tests that run the real adapter against a real database, because that is where query errors, missing constraints and migration problems show up.
When a plain function argument is enough
You don't need classes or a container to get the benefits. A function that takes its dependencies as parameters is dependency injection in its simplest form. A default value keeps production call sites short while tests pass something else, as in the example below.
Don't inject what never needs replacing. Pure helpers, such as a date formatter, and stable standard library functions can be imported directly. Inject what touches the outside world or varies between environments: storage, network calls, time, randomness and configuration.
type Invoice = { id: string; dueDate: Date; paidAt: Date | null };
export function isOverdue(invoice: Invoice, now: Date = new Date()): boolean {
return invoice.paidAt === null && invoice.dueDate < now;
}
// Production: isOverdue(invoice)
// Test: isOverdue(invoice, new Date('2026-02-01T00:00:00Z'))