PushBackLog

Dependency Inversion Principle (DIP)

Advisory enforcement Complete by PushBackLog team
Topic: quality Topic: architecture Methodology: SOLID Skillset: any Technology: generic Stage: execution Stage: review

Dependency Inversion Principle (DIP)

Status: Complete
Category: SOLID Principles
Default enforcement: Advisory
Author: PushBackLog team


Tags

  • Topic: quality, architecture
  • Methodology: SOLID
  • Skillset: any
  • Technology: generic
  • Stage: execution, review

Summary

High-level modules should not depend on low-level modules — both should depend on abstractions. Abstractions should not depend on details; details should depend on abstractions. Concrete implementations are injected, not hard-coded.


Rationale

The dependency problem without inversion

In a naive system, high-level policy modules import low-level mechanism modules directly:

OrderService → MySQLOrderRepository
OrderService → StripeClient
OrderService → SmtpEmailSender

OrderService — the most business-valuable code — is shackled to specific databases, payment providers, and email implementations. Changing any one of those forces changes in OrderService. Testing OrderService in isolation requires a real database, a real payment gateway, or mocking at the file-system level. The most important code in the system is the hardest to test and the most entangled to change.

Inversion of control

DIP is the principle underlying dependency injection and inversion of control. Rather than high-level modules pulling in their dependencies, they declare what they need via an abstraction, and a higher-level assembler (a DI container, a factory, or just a constructor parameter) pushes in the concrete implementation.

The dependency arrow is inverted: both OrderService and MySQLOrderRepository now depend on an IOrderRepository interface. The interface is owned by, or co-located with, the policy layer — not the infrastructure layer.

Testability as a first principle

DIP is one of the most powerful testability enablers. When OrderService depends on IPaymentGateway, tests can inject a FakePaymentGateway or a StubPaymentGateway that returns controlled responses. The entire business logic can be exercised in milliseconds, with no network, no database, and no flakiness.


Guidance

The dependency injection pattern

The most common way to apply DIP is constructor injection: declare your dependencies as interface parameters in the constructor, and let the caller (or DI container) provide the implementation.

// Without DIP
class OrderService {
  private db = new MySQLOrderRepository(); // hard-coded detail
  private mailer = new SmtpEmailSender();  // hard-coded detail
}

// With DIP
class OrderService {
  constructor(
    private repository: IOrderRepository,  // abstraction
    private mailer: IEmailSender           // abstraction
  ) {}
}

Interface ownership

A subtlety: the interface should be owned by the consumer (the high-level policy), not the producer (the low-level mechanism). If IOrderRepository lives in the infrastructure package alongside MySQLOrderRepository, OrderService still depends transitively on infrastructure — the arrow isn’t truly inverted. Place interfaces in the domain or application layer.

DI containers vs manual injection

ApproachWhen to use
Manual construction (main function, factory)Small systems, explicit wiring easier to understand
DI container (Spring, NestJS, .NET Core IoC)Medium–large systems with many dependencies; reduces boilerplate
Partial manual + factoryTests use manual injection; production uses container

DI containers are powerful but add a layer of indirection. Ensure the team understands what the container is doing.


Examples

User notification service

// Abstraction (lives in application layer)
interface INotificationSender {
  send(userId: string, message: string): Promise<void>;
}

// High-level policy — no knowledge of how notifications are sent
class UserEventService {
  constructor(private sender: INotificationSender) {}

  async onUserRegistered(userId: string) {
    await this.sender.send(userId, 'Welcome to PushBackLog!');
  }
}

// Low-level implementations — details depend on the abstraction
class EmailSender implements INotificationSender { ... }
class SmsSender implements INotificationSender { ... }
class FakeSender implements INotificationSender {
  sent: { userId: string; message: string }[] = [];
  async send(userId: string, message: string) {
    this.sent.push({ userId, message });
  }
}

// In tests
const fakeSender = new FakeSender();
const service = new UserEventService(fakeSender);
await service.onUserRegistered('user-123');
assert(fakeSender.sent.length === 1); // No SMTP server needed

Anti-patterns

1. new inside business logic

new Database(), new EmailClient(), or new HttpClient() inside a domain class hard-codes the dependency. Extract to a constructor parameter.

2. Service locator pattern

const db = ServiceLocator.get<IDatabase>('database');

Service locator is sometimes presented as DIP-compliant because it uses an interface, but it hides dependencies and makes them invisible at the constructor boundary. It’s harder to test and harder to reason about.

3. Injecting containers into classes

constructor(private container: Container) {
  this.db = container.get(IDatabase);
}

This re-introduces the service locator pattern via DI. Inject the resolved dependency itself, not the container.

4. Abstractions that track details

An IOrderRepository interface with a getRawSql() method, or a IPaymentGateway with getStripeCustomerId(), leaks implementation details into the abstraction, defeating the purpose of the interface.



Part of the PushBackLog Best Practices Library. Suggest improvements →