Dependency Inversion Principle (DIP)
The Dependency Inversion Principle states:
"High-level modules should not depend on low-level modules. Both should depend on abstractions."
And:
"Abstractions should not depend on details. Details should depend on abstractions."
This is about decoupling. Your business logic (high-level) shouldn't know about databases, HTTP, or file systems (low-level). Both should talk through interfaces.
The Problem
Consider this order service:
class OrderService {
private database = new PostgresDatabase();
private emailer = new SendGridEmailer();
private paymentGateway = new StripeGateway();
async createOrder(data: OrderData): Promise<Order> {
const order = new Order(data);
// Direct dependency on Postgres
await this.database.insert('orders', order);
// Direct dependency on SendGrid
await this.emailer.send(order.customerEmail, 'Order Confirmed', '...');
// Direct dependency on Stripe
await this.paymentGateway.charge(order.paymentDetails, order.total);
return order;
}
}
Problems:
- Testing is hard: You need real Postgres, SendGrid, and Stripe connections
- Changing providers is hard: Moving to MySQL means changing
OrderService - Business logic is coupled to infrastructure: The "what" is tangled with the "how"
The Solution: Depend on Abstractions
Define interfaces for your dependencies:
interface OrderRepository {
save(order: Order): Promise<void>;
find(id: string): Promise<Order | null>;
}
interface EmailService {
send(to: string, subject: string, body: string): Promise<void>;
}
interface PaymentGateway {
charge(details: PaymentDetails, amount: number): Promise<PaymentResult>;
}
Implement them with concrete classes:
class PostgresOrderRepository implements OrderRepository {
async save(order: Order) {
await this.connection.query('INSERT INTO orders...');
}
async find(id: string) {
return this.connection.query('SELECT * FROM orders WHERE id = $1', [id]);
}
}
class SendGridEmailService implements EmailService {
async send(to: string, subject: string, body: string) {
await sendgrid.send({ to, subject, body });
}
}
class StripePaymentGateway implements PaymentGateway {
async charge(details: PaymentDetails, amount: number) {
return stripe.charges.create({ ... });
}
}
Now OrderService depends only on abstractions:
class OrderService {
constructor(
private orderRepo: OrderRepository,
private emailService: EmailService,
private paymentGateway: PaymentGateway
) {}
async createOrder(data: OrderData): Promise<Order> {
const order = new Order(data);
await this.paymentGateway.charge(order.paymentDetails, order.total);
await this.orderRepo.save(order);
await this.emailService.send(order.customerEmail, 'Order Confirmed', '...');
return order;
}
}
Dependency Injection
Dependencies are passed in ("injected"), not created inside:
// Composition Root: where dependencies are assembled
const orderRepo = new PostgresOrderRepository(dbConnection);
const emailService = new SendGridEmailService(apiKey);
const paymentGateway = new StripePaymentGateway(stripeKey);
const orderService = new OrderService(orderRepo, emailService, paymentGateway);
Constructor Injection (Preferred)
class OrderService {
constructor(
private orderRepo: OrderRepository,
private emailService: EmailService
) {}
}
Dependencies are explicit. The class can't be instantiated without them.
Method Injection
class OrderService {
async createOrder(data: OrderData, emailService: EmailService) {
// emailService passed per-call
}
}
Useful when the dependency varies per call.
Property Injection (Less Preferred)
class OrderService {
orderRepo?: OrderRepository;
async createOrder(data: OrderData) {
if (!this.orderRepo) throw new Error('OrderRepo not set');
}
}
Dependencies aren't explicit. Risk of null errors.
Benefits
Easy Testing
describe('OrderService', () => {
it('saves order after successful payment', async () => {
const mockRepo: OrderRepository = {
save: jest.fn(),
find: jest.fn(),
};
const mockPayment: PaymentGateway = {
charge: jest.fn().mockResolvedValue({ success: true }),
};
const mockEmail: EmailService = {
send: jest.fn(),
};
const service = new OrderService(mockRepo, mockEmail, mockPayment);
await service.createOrder(testData);
expect(mockRepo.save).toHaveBeenCalled();
});
});
No real database, no real payment processor, no real email.
Easy Provider Swapping
// Switching from SendGrid to Mailgun
class MailgunEmailService implements EmailService {
async send(to: string, subject: string, body: string) {
await mailgun.messages.create(...);
}
}
// Just change the composition root
const emailService = new MailgunEmailService(mailgunKey);
OrderService doesn't change at all.
Clear Architecture
┌─────────────────────────────────────┐
│ Business Logic │
│ (OrderService, UserService, etc.) │
│ Depends on abstractions │
└──────────────────┬──────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Interfaces │
│ (OrderRepository, EmailService) │
│ Pure abstractions │
└──────────────────┬──────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Infrastructure │
│ (PostgresRepo, SendGridEmail) │
│ Implements abstractions │
└─────────────────────────────────────┘
Business logic is isolated from infrastructure details.
The Inversion
Why "inversion"? Traditionally:
High-Level → depends on → Low-Level
OrderService → depends on → PostgresDatabase
After DIP:
High-Level → depends on → Abstraction
↑
Low-Level → implements → Abstraction
OrderService → depends on → OrderRepository
↑
PostgresOrderRepository → implements
Both high-level and low-level now depend on the abstraction. The direction of dependency between high and low level has been inverted.
Common Mistakes
Abstracting Everything
Not every dependency needs an interface. If you'll only ever have one implementation, an interface adds complexity without benefit.
// Overkill for a simple utility
interface StringFormatter {
format(s: string): string;
}
// Just use the function
function formatString(s: string): string { ... }
Leaky Abstractions
An interface that exposes implementation details:
// Bad: exposes SQL
interface UserRepository {
executeQuery(sql: string): Promise<User[]>;
}
// Good: hides implementation
interface UserRepository {
findByEmail(email: string): Promise<User | null>;
findActive(): Promise<User[]>;
}
God Interfaces
An interface that does too much (violates ISP):
// Too much
interface Database {
query(sql: string): Promise<any>;
connect(): void;
disconnect(): void;
createBackup(): void;
// ...
}
// Split by role
interface Queryable {
query(sql: string): Promise<any>;
}
interface Connectable {
connect(): void;
disconnect(): void;
}
Dependency Injection Containers
For complex applications, use a DI container:
// Using tsyringe
@injectable()
class OrderService {
constructor(
@inject('OrderRepository') private orderRepo: OrderRepository,
@inject('EmailService') private emailService: EmailService
) {}
}
// Registration
container.register('OrderRepository', { useClass: PostgresOrderRepository });
container.register('EmailService', { useClass: SendGridEmailService });
// Resolution
const service = container.resolve(OrderService);
The container handles wiring. You just declare dependencies.
Key insight: DIP decouples business logic from infrastructure. High-level modules define the interfaces they need. Low-level modules implement those interfaces. Dependencies are injected, not created internally. This makes code testable, flexible, and clearly layered.