Siddhant Deval
Siddhant Deval
system design20 min read

SOLID Principles: Decoupling, Inversion & Modern IoC (I, D)

High-level domain policy must never depend on low-level implementation details. This article covers Interface Segregation to eliminate fat contracts, the Dependency Inversion Principle enforced at module boundaries, and a clear-eyed comparison of manual DI, the Service Locator anti-pattern, and modern IoC containers (Inversify, NestJS) under TypeScript 5.0+ Stage 3 Decorators.

SOLID Principles: Decoupling, Inversion & Modern IoC (I, D)

In TypeScript 5+, OOP is an architectural contract, not an inheritance tree: enforce domain invariants at compile time, encapsulate mutation strictly within aggregates, and invert dependencies so high-level business policy never couples to execution details. The phrase "invert dependencies" is precisely what this article mechanizes. Dependency Inversion is not a suggestion to "use interfaces" — it is a structural rule about which layer owns the interface and which layer implements it.

Combined with Interface Segregation, DIP produces the cleanest test-boundary architecture possible: every collaborator in the domain core is a narrow interface, injected at the composition root, replaceable with a test double without any framework. That architecture is what distinguishes senior-level code from mid-level code in LLD interviews.


1. The Anti-Pattern Graveyard: The Fat Monolithic Interface

Here is the interface every CRUD-first team writes when they first extract a service layer:

TYPESCRIPT
// ❌ Anti-Pattern: Fat IOrderService — every client depends on every method

interface IOrderService {
  createOrder(customerId: string, items: OrderItem[]): Promise<Order>;
  updateOrder(orderId: string, items: OrderItem[]): Promise<Order>;
  cancelOrder(orderId: string, reason: string): Promise<void>;
  refundOrder(orderId: string): Promise<RefundResult>;
  deleteOrder(orderId: string): Promise<void>;
  findOrderById(orderId: string): Promise<Order | null>;
  findOrdersByCustomer(customerId: string): Promise<Order[]>;
  exportOrderReport(from: Date, to: Date): Promise<OrderReport>;
}

// The AuditExporter only needs to read transaction dates
// But it must depend on the ENTIRE IOrderService contract
class AuditExporter {
  constructor(private readonly orderService: IOrderService) {}

  async exportComplianceReport(from: Date, to: Date): Promise<string[]> {
    const report = await this.orderService.exportOrderReport(from, to);
    return report.entries.map(e => `${e.orderId}: ${e.total}`);
  }
  // AuditExporter has no business with createOrder, deleteOrder, refundOrder —
  // but it is forced to depend on all of them through the fat interface.
}

The audit exporter now has hidden coupling to 7 methods it never calls. Any change to those method signatures forces a recompile of AuditExporter. Any mock for AuditExporter's tests must stub all 8 methods. And if someone renames exportOrderReport, the interface change propagates through every class that implements IOrderService — even those that never export reports.


2. Interface Segregation Principle (ISP)

2.1 The Mechanical Statement

ISP: No client should be forced to depend on methods it does not use.

The solution is role interfaces: define a separate interface for each distinct capability, sized to the minimal surface that any single consumer needs.

TYPESCRIPT
// ✅ Role interfaces — each sized to one consumer's actual needs

interface IOrderReader {
  findOrderById(orderId: string): Promise<Order | null>;
  findOrdersByCustomer(customerId: string): Promise<Order[]>;
}

interface IOrderWriter {
  createOrder(customerId: string, items: OrderItem[]): Promise<Order>;
  updateOrder(orderId: string, items: OrderItem[]): Promise<Order>;
}

interface IOrderCanceller {
  cancelOrder(orderId: string, reason: string): Promise<void>;
}

interface IOrderRefunder {
  refundOrder(orderId: string): Promise<RefundResult>;
}

interface IOrderReporter {
  exportOrderReport(from: Date, to: Date): Promise<OrderReport>;
}

// Now AuditExporter depends only on what it uses
class AuditExporter {
  constructor(private readonly reporter: IOrderReporter) {}

  async exportComplianceReport(from: Date, to: Date): Promise<string[]> {
    const report = await this.reporter.exportOrderReport(from, to);
    return report.entries.map(e => `${e.orderId}: ${e.total}`);
  }
}

// The concrete class implements all role interfaces — that is its responsibility
class OrderService implements IOrderReader, IOrderWriter, IOrderCanceller, IOrderRefunder, IOrderReporter {
  async findOrderById(orderId: string): Promise<Order | null> { return null; }
  async findOrdersByCustomer(customerId: string): Promise<Order[]> { return []; }
  async createOrder(customerId: string, items: OrderItem[]): Promise<Order> { return {} as Order; }
  async updateOrder(orderId: string, items: OrderItem[]): Promise<Order> { return {} as Order; }
  async cancelOrder(orderId: string, reason: string): Promise<void> {}
  async refundOrder(orderId: string): Promise<RefundResult> { return {} as RefundResult; }
  async exportOrderReport(from: Date, to: Date): Promise<OrderReport> { return {} as OrderReport; }
}

Now a test for AuditExporter only needs to mock IOrderReporter — a single method. The entire OrderService is irrelevant to the test.


3. Dependency Inversion Principle (DIP)

3.1 The Two Rules of DIP

DIP has two parts:

  1. High-level modules must not import from low-level modules. Both must depend on abstractions.
  2. Abstractions must not depend on details. Details (implementations) must depend on abstractions.

The critical word is own: the abstraction (interface) must be owned by the module that uses it — the domain — not by the module that implements it — the infrastructure.

TYPESCRIPT
// ❌ Violation: Domain imports from infrastructure

// domain/PaymentService.ts
import { StripeClient } from '../infrastructure/stripe/StripeClient'; // ← Direct import

class PaymentService {
  private readonly stripe = new StripeClient(process.env.STRIPE_KEY!);

  async chargeOrder(orderId: string, amount: number): Promise<string> {
    return this.stripe.createCharge({ amount, currency: 'usd' }).then(c => c.id);
  }
}
// If StripeClient changes its API, PaymentService must change.
// PaymentService cannot be unit tested without a live Stripe connection.
TYPESCRIPT
// ✅ DIP: Domain defines the abstraction; infrastructure implements it

// domain/ports/IPaymentGateway.ts — OWNED BY the domain layer
interface IPaymentGateway {
  charge(amount: number, currency: string): Promise<PaymentResult>;
  refund(transactionId: string): Promise<void>;
}

// domain/PaymentService.ts — depends ONLY on the interface it owns
class PaymentService {
  constructor(private readonly gateway: IPaymentGateway) {}

  async chargeOrder(orderId: string, amount: number): Promise<string> {
    const result = await this.gateway.charge(amount, 'USD');
    if (result.status === 'FAILED') throw new Error(result.reason);
    return result.transactionId;
  }
}

// infrastructure/stripe/StripeGateway.ts — implements the domain interface
import { StripeClient } from './StripeClient';

class StripeGateway implements IPaymentGateway {
  private readonly client = new StripeClient(process.env.STRIPE_KEY!);

  async charge(amount: number, currency: string): Promise<PaymentResult> {
    const charge = await this.client.createCharge({ amount, currency });
    return { status: 'OK', transactionId: charge.id };
  }

  async refund(transactionId: string): Promise<void> {
    await this.client.createRefund({ charge: transactionId });
  }
}

The dependency arrows now point inward: StripeGateway depends on IPaymentGateway, not the reverse. The domain layer has zero knowledge of Stripe.


4. The Composition Root: Wiring Without a Framework

The composition root is the single location in an application where all dependencies are instantiated and wired together. In a Node.js API, this is typically the application entry point.

TYPESCRIPT
// src/main.ts — the composition root

import { PaymentService } from './domain/PaymentService';
import { OrderService } from './domain/OrderService';
import { StripeGateway } from './infrastructure/stripe/StripeGateway';
import { PostgresOrderRepository } from './infrastructure/db/PostgresOrderRepository';
import { PaymentController } from './api/PaymentController';

// All construction and wiring happens here — ONCE, at startup
const gateway = new StripeGateway(process.env.STRIPE_KEY!);
const orderRepo = new PostgresOrderRepository(process.env.DATABASE_URL!);
const paymentService = new PaymentService(gateway);
const orderService = new OrderService(orderRepo, paymentService);
const paymentController = new PaymentController(orderService);

// Express routing — only receives already-wired controllers
app.post('/orders/:id/pay', (req, res) => paymentController.pay(req, res));

No class outside main.ts calls new on a dependency. All construction is centralized. Swapping StripeGateway for AdyenGateway requires changing one line in one file.

4.1 The Service Locator Anti-Pattern

The Service Locator is the most common misidentification of Dependency Injection. It looks similar but has the opposite effect on testability:

TYPESCRIPT
// ❌ Service Locator — hidden dependencies, invisible from the outside

const container = new Map<string, unknown>();
container.set('paymentGateway', new StripeGateway());
container.set('orderRepo', new PostgresOrderRepository());

class PaymentService {
  async chargeOrder(orderId: string, amount: number): Promise<string> {
    // Fetching a dependency at call time — hidden from constructor signature
    const gateway = container.get('paymentGateway') as IPaymentGateway;
    return (await gateway.charge(amount, 'USD')).transactionId;
  }
}

// Test cannot inject a mock — the dependency is fetched from a global container
// The test must manipulate the global container to replace the gateway
// This makes test setup order-dependent and globally coupled

With constructor injection (DIP), the test is:

TYPESCRIPT
// ✅ Constructor DI — explicit, replaceable, no global state
const mockGateway: IPaymentGateway = {
  charge: async () => ({ status: 'OK', transactionId: 'test_txn' }),
  refund: async () => {},
};
const service = new PaymentService(mockGateway);
const txnId = await service.chargeOrder('ord_001', 100);
Two-column diagram. LEFT column labeled 'Dependency Inversion Violation' in red: shows three layered boxes. Top box 'PaymentService (Domain)' has a red arrow pointing DOWN to middle box 'StripeGateway (Infrastructure)'. The arrow is labeled 'import — wrong direction'. A red callout: 'Domain couples to infrastructure details. Cannot test without a live Stripe connection'. RIGHT column labeled 'Dependency Inversion Applied' in cyan: shows three layered boxes. Top box 'PaymentService (Domain)' defines 'IPaymentGateway' interface inside it. Middle box 'IPaymentGateway (Interface)' with 'OWNED BY DOMAIN' label in cyan. Bottom box 'StripeGateway (Infrastructure)' has a cyan arrow pointing UP to the interface labeled 'implements — correct direction'. A small box labeled 'Composition Root' connects StripeGateway to IPaymentGateway. Cyan callout: 'Domain never imports infrastructure. Both depend on the abstraction.'
Two-column diagram. LEFT column labeled 'Dependency Inversion Violation' in red: shows three layered boxes. Top box 'PaymentService (Domain)' has a red arrow…

5. Modern Decorators (TS 5.0+) vs. Legacy Metadata

The history of decorators in TypeScript has two distinct eras:

5.1 Legacy Experimental Decorators (experimentalDecorators)

Before TypeScript 5.0, the only available decorator implementation was the legacy proposal, enabled via "experimentalDecorators": true in tsconfig.json. This required reflect-metadata for IoC containers like InversifyJS:

TYPESCRIPT
// tsconfig: "experimentalDecorators": true, "emitDecoratorMetadata": true
import 'reflect-metadata';
import { injectable, inject, Container } from 'inversify';

@injectable()
class PaymentService {
  constructor(
    @inject('IPaymentGateway') private readonly gateway: IPaymentGateway,
  ) {}
}

const container = new Container();
container.bind<IPaymentGateway>('IPaymentGateway').to(StripeGateway);
container.bind<PaymentService>(PaymentService).toSelf();

const service = container.get<PaymentService>(PaymentService);

This works, but reflect-metadata uses legacy Reflect APIs and relies on TypeScript emitting constructor parameter metadata — a coupling between the IoC container and the compiler's emission behavior.

5.2 ECMAScript Stage 3 Decorators (TS 5.0+)

TypeScript 5.0 implemented the finalized TC39 Stage 3 Decorator proposal. These are not backward compatible with legacy experimental decorators. They use a different decorator factory signature and do not rely on reflect-metadata.

TYPESCRIPT
// tsconfig: "experimentalDecorators": false (default)
// No reflect-metadata needed

// Stage 3 decorator — a function receiving the decorated value and context
function log(target: Function, context: ClassMethodDecoratorContext) {
  const methodName = String(context.name);
  return function (this: unknown, ...args: unknown[]) {
    console.log(`[LOG] ${methodName} called with:`, args);
    const result = (target as Function).apply(this, args);
    console.log(`[LOG] ${methodName} returned:`, result);
    return result;
  };
}

class PaymentService {
  @log
  async chargeOrder(orderId: string, amount: number): Promise<string> {
    return `txn_${amount}`;
  }
}

5.3 Decorator Metadata in TS 5.2+

TS 5.2 adds Symbol.metadata for lightweight custom registry without reflect-metadata:

TYPESCRIPT
// Using Symbol.metadata for a lightweight service registry
const INJECTABLE = Symbol('injectable');

function injectable(target: Function, context: ClassDecoratorContext) {
  context.metadata[INJECTABLE] = true;
}

@injectable
class StripeGateway implements IPaymentGateway {
  async charge(amount: number, currency: string): Promise<PaymentResult> {
    return { status: 'OK', transactionId: `stripe_${Date.now()}` };
  }
  async refund(transactionId: string): Promise<void> {}
}

// Read at composition root:
const isInjectable = (StripeGateway as any)[Symbol.metadata]?.[INJECTABLE]; // true
Crucial Requirement

IoC Container Landscape:

  • InversifyJS 6+ / TSyringe: Still use legacy experimentalDecorators + reflect-metadata. Mature but carry the reflection overhead.
  • NestJS: Uses legacy decorators internally. The framework manages metadata; you don't need to install reflect-metadata manually.
  • Manual DI (recommended for interviews): Pure constructor injection at the composition root. Zero dependencies, zero framework, 100% testable. Always demonstrate this first in an LLD interview.

6. Dependency Binding Lifecycles

IoC containers support three binding lifecycles. Choosing the wrong one creates memory leaks or state contamination:

TYPESCRIPT
// ✅ Lifecycle reference — shown here in pseudo-code for any IoC container

// SINGLETON — one instance for the entire process lifetime
// Use for: stateless services, connection pools, configuration
container.bind(PaymentService).toSelf().inSingletonScope();

// TRANSIENT — new instance per resolution
// Use for: stateful request handlers, aggregate factories
container.bind(OrderFactory).toSelf().inTransientScope();

// REQUEST — one instance per HTTP request (scoped lifetime)
// Use for: per-request context (user identity, trace ID)
container.bind(RequestContext).toSelf().inRequestScope();
Performance / Safety Warning

Injecting a transient dependency into a singleton creates a captive dependency — the transient object is retained for the singleton's lifetime, effectively becoming a singleton itself. Always match or extend the lifetime of injected dependencies: a singleton can only safely hold other singletons.


Summary

Principle Violation Fix
ISP Fat interface forces clients to depend on unused methods Split into role interfaces sized to each consumer
DIP (rule 1) Domain imports from infrastructure directly Domain defines the interface; infrastructure implements it
DIP (rule 2) Interface lives in the infrastructure layer Move interface ownership to the domain layer
Composition Root Construction scattered across many files Single wiring point at application entry
Service Locator container.get(key) inside domain classes Constructor injection — dependencies explicit in signature
Stage 3 Decorators Legacy experimentalDecorators + reflect-metadata TS 5.0+ Stage 3 decorators + Symbol.metadata (TS 5.2+)

What's Next

In Part 8, we pivot from architecture principles to the GoF creational patterns, translated for TypeScript 5+. Factory Method, Abstract Factory, ESM Singletons, and a type-safe Builder with phantom types that literally prevents .build() from being called until all required fields are set. Part 8: Creational Design Patterns: Factories, Builders & Const Construction.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#TypeScript#SOLID#ISP#DIP#IoC#Dependency Injection
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.