Implementing Dependency Injection in TypeScript Projects
AI generated
<T>
type
TypeScript · Dependency Injection · Testing · Architecture
Implementing Dependency Injection in TypeScript Projects
From constructor injection to a DI container

Developers coming from Magento or Symfony already know Dependency Injection from di.xml and the service container. This article shows how to apply the same principle in TypeScript projects, with constructor injection as the default pattern, interfaces as contracts for testable code, and a clear rule for when a DI container like InversifyJS actually earns its keep.

13 min. read Constructor Injection · Interfaces · Testing InversifyJS · tsyringe · Magento comparison

1. What is Dependency Injection and what problem does it solve?

Dependency Injection (DI) is a design pattern in which a class does not create its own dependencies but instead receives them from the outside. Instead of an OrderService calling new StripePaymentGateway() internally, it receives a ready-made instance through its constructor. This principle is called Inversion of Control: control over which concrete implementation is used no longer sits inside the class itself, but with the caller. The result is loosely coupled code that can be swapped, extended, and tested independently, without touching the class itself.

Without DI, grown TypeScript codebases quickly accumulate hidden dependencies: a service instantiates an HTTP client, a database connection, or a logger internally, and suddenly every unit test needs a real network connection or database. DI solves exactly this problem by making dependencies explicit and swappable. For PHP developers used to Magento or Symfony, this is not a new concept at all, it is the same idea that has run through the service container there for years, just without an XML configuration file.

2. Constructor injection as the simplest pattern in TypeScript

Among the different flavors of DI, constructor injection, property injection, and method injection, constructor injection is the most idiomatic and least surprising approach in TypeScript. All dependencies are declared as constructor parameters and stored directly as class properties via the private readonly modifier. TypeScript's parameter properties compiler feature saves the otherwise necessary double declaration of a property and its assignment inside the constructor body.

The decisive advantage over property injection, where dependencies are assigned later via a setter: an instance can never exist in an incomplete state. Once the constructor runs, all dependencies are guaranteed to be present, and the compiler enforces this through type checking. No framework, no decorator, and no container is required, plain TypeScript is entirely sufficient. That is exactly what makes constructor injection the right starting point for practically every project, before a DI container is even considered.

3. Interfaces as injection contracts

For constructor injection to deliver its full value, dependencies should be declared not as concrete classes but as interfaces. An OrderService that gets a PaymentGateway interface injected instead of a concrete StripePaymentGateway class only knows the contract: a charge() method with a specific signature. Which concrete implementation fulfills that contract, whether Stripe, PayPal, or a test double, is completely irrelevant to the OrderService.

That contract is the foundation for testability: in tests, a different implementation of the same interface is simply swapped in, without test code and production code needing to know anything about each other. TypeScript's structural type system makes this particularly pleasant, since a class doesn't need to explicitly declare an interface via implements to be compatible, as long as the shape matches. In practice, using implements explicitly is still recommended, because the compiler then immediately flags it if an implementation drifts from the contract.

4. Practical example: OrderService and PaymentGateway

A concrete example makes the pattern tangible: an OrderService needs to process orders and trigger a payment as part of that. Instead of implementing payment processing itself, the service only defines what it expects from a payment provider, namely the PaymentGateway interface with a charge() method. The concrete StripePaymentGateway class implements this interface and is handed to the OrderService from the outside.

The code below shows exactly this setup. What matters is that OrderService has zero knowledge of Stripe, HTTP requests, or API keys, all of that lives exclusively in the concrete implementation. This cut between contract and implementation later makes it possible, without any change to OrderService, to add a second payment provider like PayPal or to swap the provider based on environment, country, or customer segment.


// Contract that any payment gateway implementation must fulfill
interface PaymentGateway {
  charge(amountCents: number, currency: string): Promise<boolean>;
}

// OrderService depends on the PaymentGateway abstraction, not a concrete class
class OrderService {
  constructor(private readonly paymentGateway: PaymentGateway) {}

  async placeOrder(orderId: string, amountCents: number): Promise<void> {
    const success = await this.paymentGateway.charge(amountCents, 'EUR');
    if (!success) {
      throw new Error(`Payment failed for order ${orderId}`);
    }
    console.log(`Order ${orderId} placed successfully`);
  }
}

// Concrete implementation used in production
class StripePaymentGateway implements PaymentGateway {
  async charge(amountCents: number, currency: string): Promise<boolean> {
    // Real Stripe API call would happen here
    return true;
  }
}

5. Testability: mocking and test doubles

The real payoff of Dependency Injection shows up in testing. Because OrderService depends exclusively on the PaymentGateway interface, a unit test can plug in a MockPaymentGateway class that implements the same interface but makes no real network calls at all. The test stays fast, deterministic, and independent of external services like a Stripe sandbox account or an internet connection.

Test doubles like MockPaymentGateway can additionally be built to record calls, simulate failure cases, or return fixed values, as in the example below with the shouldSucceed flag. That makes it possible to test both the success path and failure paths like a declined payment deliberately, without the real payment infrastructure ever having to produce those states. Libraries like Jest or Vitest offer additional shortcuts with jest.mock() or vi.mock(), but the underlying principle stays the same: replaceability through a shared contract.


// Test double implementing the same contract as the real gateway
class MockPaymentGateway implements PaymentGateway {
  public calls: Array<{ amount: number; currency: string }> = [];
  private readonly shouldSucceed: boolean;

  constructor(shouldSucceed = true) {
    this.shouldSucceed = shouldSucceed;
  }

  async charge(amountCents: number, currency: string): Promise<boolean> {
    this.calls.push({ amount: amountCents, currency });
    return this.shouldSucceed;
  }
}

// Unit test: no network call, no real Stripe account needed
test('placeOrder throws when payment fails', async () => {
  const failingGateway = new MockPaymentGateway(false);
  const orderService = new OrderService(failingGateway);

  await expect(orderService.placeOrder('order-1', 4999))
    .rejects.toThrow('Payment failed for order order-1');

  expect(failingGateway.calls).toHaveLength(1);
});

6. Manual wiring: poor man's DI and the composition root

Not every project needs a DI container. For small to medium applications, manual wiring, colloquially known as "poor man's DI", is entirely sufficient: at a single place in the code, the so-called composition root, all concrete classes are instantiated and wired together through constructors. This spot is typically located right at the edge of the application, for example directly in the entry point index.ts or in a dedicated composition-root.ts file.

The advantage of this approach: there is no hidden magic, no reflection mechanism, and no runtime library assembling the object graph. Any developer can trace the entire construction of the application by reading a single file. For projects with a manageable number of services, often under twenty to thirty classes, this is not just sufficient, it is often even more maintainable than a container, because TypeScript catches wiring mistakes at compile time instead of only at runtime inside a container.


// composition-root.ts: the single place where concrete classes meet
function createApp() {
  const paymentGateway: PaymentGateway = new StripePaymentGateway();
  const inventoryService: InventoryService = new WarehouseInventoryService();
  const orderService = new OrderService(paymentGateway, inventoryService);
  const orderController = new OrderController(orderService);

  return { orderController };
}

// index.ts: the entry point wires nothing itself, it just calls the root
const { orderController } = createApp();
orderController.listen(3000);

7. When does a DI container like InversifyJS or tsyringe pay off?

Past a certain project size, manual wiring becomes unwieldy: when an object graph consists of hundreds of services, many of them with different lifetimes such as singleton or request-scoped, and dependencies repeat across the application over and over, a DI container like InversifyJS or tsyringe takes over this wiring automatically. Decorators such as @injectable() and @inject() mark classes and their dependencies, and the container resolves the entire graph at runtime.

The price for this: decorators require reflect-metadata and matching TypeScript compiler flags, wiring mistakes often only surface at runtime instead of at compile time, and new team members additionally have to learn the container framework. A container pays off where the complexity of the object graph exceeds the complexity of the framework, typically in large backend services, but not in small CLI tools, build scripts, or manageable frontend widgets, where constructor injection plus a composition root is entirely sufficient.


import { injectable, inject, Container } from 'inversify';

const TYPES = {
  PaymentGateway: Symbol.for('PaymentGateway'),
  OrderService: Symbol.for('OrderService'),
};

@injectable()
class StripePaymentGateway implements PaymentGateway {
  async charge(amountCents: number, currency: string): Promise<boolean> {
    return true;
  }
}

@injectable()
class OrderService {
  constructor(
    @inject(TYPES.PaymentGateway) private readonly paymentGateway: PaymentGateway
  ) {}

  async placeOrder(orderId: string, amountCents: number): Promise<void> {
    await this.paymentGateway.charge(amountCents, 'EUR');
  }
}

const container = new Container();
container.bind<PaymentGateway>(TYPES.PaymentGateway).to(StripePaymentGateway).inSingletonScope();
container.bind<OrderService>(TYPES.OrderService).to(OrderService);

const orderService = container.get<OrderService>(TYPES.OrderService);

8. The bridge to PHP: Magento's ObjectManager and Symfony's service container

For developers coming from Magento or Symfony, none of this is a new concept. Magento's di.xml defines preferences, which concrete class is used for an interface, and the ObjectManager resolves this binding automatically as soon as an interface is type hinted in a constructor. Virtual types and plugins extend this system with variants of the same class carrying different configuration and with behavior changes applied after the fact, without modifying the original class. container.bind() in InversifyJS plays exactly the same role.

Symfony goes a step further with autowiring: the service container automatically recognizes, from the type hints in a constructor, which service to inject, with no explicit configuration at all, as long as the mapping is unambiguous. container.resolve() in tsyringe works on the same principle. So anyone who already understands di.xml preferences or Symfony's autowiring doesn't need to build a new mental model for TypeScript DI, just carry over the names and syntax: an interface stays an interface, constructor injection stays constructor injection, only the configuration language changes from XML and YAML to TypeScript decorators.


// TypeScript container binding: swap the interface implementation
container.bind<PaymentGateway>(TYPES.PaymentGateway).to(PaypalPaymentGateway);

// The equivalent Magento concept lives in di.xml as a preference:
//
// <preference for="App\Api\PaymentGatewayInterface"
//             type="App\Model\PaypalPaymentGateway" />
//
// Magento's ObjectManager resolves the interface to the concrete class
// automatically wherever it is type hinted in a constructor, just like
// the TypeScript container resolves TYPES.PaymentGateway above.
// Symfony's service container does the same via autowiring: a type
// hinted constructor argument is resolved from the container without
// any manual "new" call, comparable to container.resolve() in tsyringe.

9. Manual wiring vs. DI container compared side by side

The choice between manual wiring and a DI container rarely comes down to personal taste, it depends on the size and requirements of the project. The table below summarizes the key decision criteria.

Criterion Manual Wiring DI Container
Small app, few services Recommended Unnecessary overhead
Large dependency graph Quickly becomes unmanageable Recommended
Need for decorators/auto-wiring Not available Core feature
Build/runtime overhead None Requires reflect-metadata, decorators
Learning curve for the team Minimal, plain TypeScript Additional framework knowledge required

In practice, most TypeScript projects start with manual wiring through a composition root and only move to a container once the object graph actually becomes unmanageable, not preemptively at the start of the project. This pragmatic approach mirrors exactly the experience many Magento and Symfony projects have as well: configuration grows with the complexity of the application, not the other way around.

Mironsoft

TypeScript architecture and Dependency Injection for scalable Magento and headless projects

Ready to implement Dependency Injection properly?

We help your team build testable TypeScript architectures, from constructor injection through interfaces to the decision for or against a DI container, including integration with existing Magento and Hyvä projects.

DI architecture review

Assess existing TypeScript code for coupling and testability, cut clean interfaces and a composition root

Container rollout

Introduce InversifyJS or tsyringe where the object graph justifies it, including team onboarding

Test setup

Build unit test infrastructure with mocks and test doubles for existing services

10. Summary

Dependency Injection in TypeScript projects follows the same underlying principle that PHP developers already know from Magento's di.xml and Symfony's service container: dependencies are not created by a class itself, but handed to it from the outside through the constructor. Interfaces act as contracts that make implementations swappable and, above all, make code testable, without unit tests needing real network connections, databases, or third-party APIs.

For most projects, constructor injection combined with a manual composition root is entirely sufficient, with no extra framework at all. A DI container like InversifyJS or tsyringe only pays off once the object graph grows large enough that automatic resolution via decorators genuinely reduces maintenance effort. Anyone who makes that decision deliberately and per project, instead of reflexively reaching for a container, avoids unnecessary complexity while staying fully able to add one later if the need grows.

Dependency Injection in TypeScript - The Essentials at a Glance

Constructor Injection

Declare dependencies as parameter properties in the constructor, no framework required.

Interfaces as Contracts

Inject a PaymentGateway interface instead of a concrete class, implementations stay freely swappable.

Composition Root

One central place, usually index.ts, manually wires all concrete classes together.

DI Container When Needed

Reach for InversifyJS or tsyringe only for large object graphs with many lifetimes.

11. FAQ: Dependency Injection in TypeScript

1What is the difference between Dependency Injection and a DI container?
Dependency Injection is the design pattern itself. A DI container is an optional tool that automates the wiring through decorators and reflection once the object graph is large enough.
2Why is constructor injection the preferred DI method in TypeScript?
It lets the compiler guarantee an instance never exists without its dependencies, requires no framework, and uses TypeScript's parameter properties.
3Do I always need interfaces for Dependency Injection?
Not strictly, but without an interface you lose the ability to swap implementations for tests and alternative providers, the central benefit of DI.
4How do I test code that uses Dependency Injection?
With a test double class implementing the same interface, for example MockPaymentGateway instead of StripePaymentGateway, injected through the same constructor.
5What is a composition root?
The one central place in the code, usually the entry point, where all concrete classes are instantiated and wired together into a complete object graph.
6When does InversifyJS or tsyringe pay off?
Once a project reaches a certain number of services with different lifetimes and recurring dependencies, when manual wiring becomes unwieldy.
7How does TypeScript DI differ from Magento's di.xml?
di.xml defines preferences the ObjectManager resolves automatically from a type hint. A TypeScript container performs the same task via container.bind() instead of XML.
8How does TypeScript DI differ from Symfony's service container?
Symfony's autowiring recognizes type hints automatically. tsyringe works on the same principle with container.resolve(), just with decorators instead of YAML configuration.
9Do I need reflect-metadata for Dependency Injection in TypeScript?
Only with a decorator-based container like InversifyJS or tsyringe. Plain constructor injection with a composition root needs neither reflect-metadata nor special compiler flags.
10Is Dependency Injection worthwhile for small TypeScript projects too?
Yes, but without a container. Constructor injection with interfaces and a simple composition root already improves testability and structure in small projects.