Skip to content

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.

3 min read
TypeScript code showing dependency injection through constructor parameters

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.

tstypescript
// ❌ 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?
tstypescript
// ✅ 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)

tstypescript
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)

tstypescript
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)

tstypescript
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.

tstypescript
// 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.

tstypescript
// ❌ 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:

ApproachBest forDrawback
Manual wiringSmall-to-medium apps, ≤20 servicesVerbose when dependency graph is deep
DI containerLarge apps, plugin systems, deep graphsMagic 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

  1. DI is just passing arguments — no framework or decorator ceremony required
  2. Factory functions are the pragmatic sweet spot in TypeScript
  3. The composition root is the single place where real implementations are wired together
  4. Testing with DI is trivial — pass fakes directly, no mock libraries needed
  5. Skip DI containers unless your dependency graph is genuinely large and deep
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX