Dependency Injection Patterns in TypeScript Applications
Dependency injection in TypeScript with constructor injection, factory functions, DI containers and module composition — testable, without framework lock-in.

The Problem DI Solves
When a module directly imports and instantiates its dependencies, it becomes impossible to test in isolation, swap implementations, or reuse in different contexts. Dependency injection inverts that control: the caller provides dependencies instead of the module creating them.
Constructor Injection
The simplest and most explicit pattern. Dependencies are parameters, and the type system enforces the contract.
// ❌ Hard-coded dependency — impossible to test without a real database
class OrderService {
private db = new PostgresDatabase();
async getOrder(id: string): Promise<Order> {
return this.db.query("SELECT * FROM orders WHERE id = $1", [id]);
}
}
// ✅ Constructor injection — dependency provided from outside
interface Database {
query<T>(sql: string, params: unknown[]): Promise<T>;
}
class OrderService {
constructor(private db: Database) {}
async getOrder(id: string): Promise<Order> {
return this.db.query("SELECT * FROM orders WHERE id = $1", [id]);
}
}
// Production
const service = new OrderService(new PostgresDatabase(connectionString));
// Test — inject a mock
const mockDb: Database = {
query: async () => ({ id: "1", total: 100, status: "completed" }) as any,
};
const testService = new OrderService(mockDb);Factory Functions Over Classes
Not everything needs to be a class. Factory functions achieve the same decoupling with less ceremony.
// ❌ Class with constructor injection for simple logic
class PriceCalculator {
constructor(
private taxService: TaxService,
private discountService: DiscountService
) {}
async calculate(items: CartItem[]): Promise<number> {
const subtotal = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
const discount = await this.discountService.getDiscount(items);
const tax = await this.taxService.getTax(subtotal - discount);
return subtotal - discount + tax;
}
}
// ✅ Factory function — same testability, less boilerplate
interface PricingDeps {
getTax: (amount: number) => Promise<number>;
getDiscount: (items: CartItem[]) => Promise<number>;
}
function createPriceCalculator(deps: PricingDeps) {
return {
async calculate(items: CartItem[]): Promise<number> {
const subtotal = items.reduce(
(sum, i) => sum + i.price * i.quantity, 0
);
const discount = await deps.getDiscount(items);
const tax = await deps.getTax(subtotal - discount);
return subtotal - discount + tax;
},
};
}
// Usage
const calculator = createPriceCalculator({
getTax: taxService.getTax.bind(taxService),
getDiscount: discountService.getDiscount.bind(discountService),
});Composition Root
Wire all dependencies in a single place at application startup. This is the only location that knows about concrete implementations.
// composition-root.ts — the only file that imports concrete implementations
import { PostgresDatabase } from "./infra/postgres";
import { StripePaymentGateway } from "./infra/stripe";
import { SendGridEmailService } from "./infra/sendgrid";
import { OrderService } from "./services/order-service";
import { PaymentService } from "./services/payment-service";
import { NotificationService } from "./services/notification-service";
export function createAppContext(config: AppConfig) {
// Infrastructure
const db = new PostgresDatabase(config.databaseUrl);
const payments = new StripePaymentGateway(config.stripeKey);
const email = new SendGridEmailService(config.sendgridKey);
// Services — wired with their dependencies
const orderService = new OrderService(db);
const paymentService = new PaymentService(payments, db);
const notificationService = new NotificationService(email);
return {
orderService,
paymentService,
notificationService,
// Expose shutdown for graceful cleanup
async shutdown() {
await db.close();
},
};
}
// server.ts
const app = createAppContext(loadConfig());
// Route handlers receive services from the composition root
router.post("/orders", async (req, res) => {
const order = await app.orderService.create(req.body);
await app.notificationService.sendOrderConfirmation(order);
res.json(order);
});Lightweight DI Container
When the dependency graph grows complex, a simple container avoids manual wiring without a heavy framework.
type Factory<T> = (container: Container) => T;
class Container {
private factories = new Map<string, Factory<unknown>>();
private singletons = new Map<string, unknown>();
register<T>(name: string, factory: Factory<T>): void {
this.factories.set(name, factory);
}
singleton<T>(name: string, factory: Factory<T>): void {
this.factories.set(name, (c) => {
if (!this.singletons.has(name)) {
this.singletons.set(name, factory(c));
}
return this.singletons.get(name)!;
});
}
resolve<T>(name: string): T {
const factory = this.factories.get(name);
if (!factory) throw new Error(`No registration for "${name}"`);
return factory(this) as T;
}
}
// Registration
const container = new Container();
container.singleton("database", () =>
new PostgresDatabase(process.env.DATABASE_URL!)
);
container.singleton("orderService", (c) =>
new OrderService(c.resolve("database"))
);
container.singleton("paymentService", (c) =>
new PaymentService(
c.resolve("paymentGateway"),
c.resolve("database")
)
);
// Resolution
const orderService = container.resolve<OrderService>("orderService");Testing with Dependency Injection
DI makes testing straightforward. Replace real dependencies with test doubles that return predictable results.
import { describe, it, expect } from "vitest";
describe("OrderService", () => {
function createTestOrderService(
overrides: Partial<OrderServiceDeps> = {}
) {
const defaults: OrderServiceDeps = {
db: {
query: async () => [],
execute: async () => ({ rowCount: 1 }),
},
eventBus: {
publish: async () => {},
},
idGenerator: () => "test-id-123",
};
return new OrderService({ ...defaults, ...overrides });
}
it("creates an order and publishes event", async () => {
const publishedEvents: unknown[] = [];
const service = createTestOrderService({
eventBus: {
publish: async (event) => {
publishedEvents.push(event);
},
},
});
const order = await service.create({
userId: "user-1",
items: [{ productId: "p1", quantity: 2, price: 10 }],
});
expect(order.id).toBe("test-id-123");
expect(publishedEvents).toHaveLength(1);
expect(publishedEvents[0]).toMatchObject({
type: "order.created",
orderId: "test-id-123",
});
});
it("throws when no items provided", async () => {
const service = createTestOrderService();
await expect(
service.create({ userId: "user-1", items: [] })
).rejects.toThrow("Order must have at least one item");
});
});When Not to Use DI
Not every function needs injectable dependencies. Pure functions, utilities, and leaf modules with no side effects are fine as direct imports. DI adds value at system boundaries—database access, external APIs, message queues—where you need testability and swappability.
// No DI needed — pure function with no side effects
function calculateTotal(items: CartItem[]): number {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
// DI valuable — external dependency with side effects
function createPaymentProcessor(deps: { gateway: PaymentGateway }) {
return {
async charge(amount: number, token: string): Promise<PaymentResult> {
return deps.gateway.charge(amount, token);
},
};
}Key Takeaways
Dependency injection is about making dependencies explicit and provided from outside rather than created internally. Constructor injection is sufficient for most cases—start there before reaching for containers or decorators. Factory functions offer the same decoupling as classes with less boilerplate.
Wire all concrete implementations in a single composition root so the rest of your codebase depends only on interfaces. Reserve DI for boundaries with side effects: databases, APIs, message queues, and file systems. Pure functions and deterministic utilities do not need injection—adding it there is over-engineering. The test for whether DI is warranted is simple: if you cannot write a fast, isolated test for a module, its dependencies need to be injectable.


