Dependency Injection in Practice
Dependency injection doesn't require a framework or decorators — here's how to apply it in TypeScript with simple patterns that make code testable and flexible.

Dependency injection has an image problem. Mention it and developers picture Java-style annotation soup, XML configuration files, and framework magic. In TypeScript, DI is just passing arguments to functions. No decorators, no containers, no ceremony — just explicit dependencies that make code testable and composable.
The Problem DI Solves
When a module creates its own dependencies, it becomes impossible to test in isolation or swap implementations.
// ❌ Hard-coded dependency — impossible to test without a real database
import { prisma } from "./db";
export async function getActiveUsers() {
return prisma.user.findMany({
where: { status: "active" },
});
}
// How do you test this without a running database?
// How do you switch to a different ORM?
// How do you mock slow queries in development?// ✅ Dependency is injected — caller decides what to provide
interface UserRepository {
findMany(filter: { where: { status: string } }): Promise<User[]>;
}
export function createUserService(repo: UserRepository) {
return {
async getActiveUsers() {
return repo.findMany({ where: { status: "active" } });
},
};
}
// Production: createUserService(prisma.user)
// Tests: createUserService(mockUserRepo)The function doesn't know or care whether it's talking to Prisma, a mock, or an in-memory array. That's the entire value proposition.
Three Patterns in TypeScript
Constructor injection (classes)
class OrderService {
constructor(
private readonly orderRepo: OrderRepository,
private readonly paymentGateway: PaymentGateway,
private readonly emailService: EmailService,
) {}
async placeOrder(input: PlaceOrderInput): Promise<Order> {
const order = await this.orderRepo.create(input);
await this.paymentGateway.charge(order.total, input.paymentMethod);
await this.emailService.sendConfirmation(order);
return order;
}
}Factory function injection (functional style)
function createOrderService(deps: {
orderRepo: OrderRepository;
paymentGateway: PaymentGateway;
emailService: EmailService;
}) {
return {
async placeOrder(input: PlaceOrderInput): Promise<Order> {
const order = await deps.orderRepo.create(input);
await deps.paymentGateway.charge(order.total, input.paymentMethod);
await deps.emailService.sendConfirmation(order);
return order;
},
};
}Parameter injection (simplest)
async function placeOrder(
input: PlaceOrderInput,
orderRepo: OrderRepository,
paymentGateway: PaymentGateway,
) {
const order = await orderRepo.create(input);
await paymentGateway.charge(order.total, input.paymentMethod);
return order;
}Factory functions are the sweet spot for most TypeScript projects. They give you closures over dependencies without class ceremony, and they compose naturally.
Wiring Dependencies at the Edge
The composition root is where you create real implementations and wire them together. It lives at the application entry point — a server bootstrap, a CLI main function, or a test setup.
// src/composition-root.ts — the ONE place where real deps are created
import { PrismaClient } from "@prisma/client";
import { StripeGateway } from "./infra/stripe";
import { SendGridEmailService } from "./infra/sendgrid";
import { createOrderService } from "./services/order";
import { createUserService } from "./services/user";
const prisma = new PrismaClient();
const paymentGateway = new StripeGateway(process.env.STRIPE_KEY!);
const emailService = new SendGridEmailService(process.env.SENDGRID_KEY!);
export const orderService = createOrderService({
orderRepo: prisma.order,
paymentGateway,
emailService,
});
export const userService = createUserService(prisma.user);Route handlers import from the composition root. Services never import concrete implementations directly.
Testing Becomes Trivial
With DI, every test can substitute any dependency. No mocking libraries, no monkey-patching, no jest.mock() magic.
// ❌ Without DI — need complex mocking setup
jest.mock("./db", () => ({
prisma: { user: { findMany: jest.fn() } },
}));
// ✅ With DI — just pass a fake
import { createUserService } from "./services/user";
test("getActiveUsers filters by status", async () => {
const mockRepo = {
findMany: async (filter: any) => {
expect(filter.where.status).toBe("active");
return [
{ id: "1", name: "Alice", status: "active" },
{ id: "2", name: "Bob", status: "active" },
];
},
};
const service = createUserService(mockRepo);
const users = await service.getActiveUsers();
expect(users).toHaveLength(2);
});The test is fast, deterministic, and reads like a specification. No database, no network, no filesystem.
When You Don't Need a DI Container
DI containers (like tsyringe, inversify, or awilix) add automatic resolution — you register interfaces and implementations, and the container wires them. This is useful when you have 50+ services with deep dependency graphs.
For most web applications, manual wiring in a composition root is clearer and easier to debug:
| Approach | Best for | Drawback |
|---|---|---|
| Manual wiring | Small-to-medium apps, ≤20 services | Verbose when dependency graph is deep |
| DI container | Large apps, plugin systems, deep graphs | Magic resolution, harder to trace |
If your composition root fits in one file and you can read it top-to-bottom, you don't need a container.
Key Takeaways
- DI is just passing arguments — no framework or decorator ceremony required
- Factory functions are the pragmatic sweet spot in TypeScript
- The composition root is the single place where real implementations are wired together
- Testing with DI is trivial — pass fakes directly, no mock libraries needed
- Skip DI containers unless your dependency graph is genuinely large and deep


