Lesson 36 of 54

SOLID Principles in Practice

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:

Typescript
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:

Typescript
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:

Typescript
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:

Typescript
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:

Typescript
// 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)

Typescript
class OrderService {
  constructor(
    private orderRepo: OrderRepository,
    private emailService: EmailService
  ) {}
}

Dependencies are explicit. The class can't be instantiated without them.

Method Injection

Typescript
class OrderService {
  async createOrder(data: OrderData, emailService: EmailService) {
    // emailService passed per-call
  }
}

Useful when the dependency varies per call.

Property Injection (Less Preferred)

Typescript
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

Typescript
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

Typescript
// 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

Text
┌─────────────────────────────────────┐
│           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:

Text
High-Level → depends on → Low-Level
OrderService → depends on → PostgresDatabase

After DIP:

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

Typescript
// 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:

Typescript
// 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):

Typescript
// 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:

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