Skip to content

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.

4 min read
A dependency graph showing services connected through injection rather than direct imports

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.

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

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

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

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

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

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

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX