DEV Community

Christian Vasquez
Christian Vasquez

Posted on

How I finally understood the Dependency Inversion Principle

The Intro

Last stop. We made it to the D: the Dependency Inversion Principle.

This one is special because the other four kind of lead here. SRP gave us small classes. OCP taught us to add instead of edit. LSP and ISP gave us honest, tiny interfaces. DIP is where we decide who points at whom.

Buckle up, it's the one that changes how your main.ts looks forever.

The What

The definition has two parts:

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.

Four sentences that use "depend" five times. Cool cool cool.

Plain version: your business logic should not import your database driver. The important stuff (orders, invoices, rules) should sit at the top and know nothing about the plumbing (Postgres, Stripe, the file system). The plumbing should plug into the business logic, not the other way around.

Let's build an order checkout and watch the arrows flip.

The How

Here is OrderService, the "high-level" part of our store. It's the thing that knows what placing an order means:

// src/order-service.ts
import { PostgresOrderRepository } from './postgres-order-repository.js'
import { StripeClient } from './stripe-client.js'

export class OrderService {
  private readonly orders = new PostgresOrderRepository('postgres://localhost/shop')
  private readonly stripe = new StripeClient(process.env.STRIPE_KEY!)

  async placeOrder(customerId: string, total: number) {
    await this.stripe.charge(customerId, total)
    const id = await this.orders.insert({ customerId, total })
    console.log(`๐Ÿงพ order ${id} placed`)
    return id
  }
}
Enter fullscreen mode Exit fullscreen mode

The two "low-level" pieces:

// src/postgres-order-repository.ts
export class PostgresOrderRepository {
  constructor(private readonly url: string) {}

  async insert(order: { customerId: string; total: number }) {
    console.log(`๐Ÿ˜ INSERT INTO orders ... (${this.url})`)
    return 'ord_123'
  }
}
Enter fullscreen mode Exit fullscreen mode
// src/stripe-client.ts
export class StripeClient {
  constructor(private readonly apiKey: string) {}

  async charge(customerId: string, amount: number) {
    console.log(`๐Ÿ’ณ stripe charged ${customerId} $${amount}`)
  }
}
Enter fullscreen mode Exit fullscreen mode

And main:

// src/main.ts
import { OrderService } from './order-service.js'

await new OrderService().placeOrder('cus_42', 99)
Enter fullscreen mode Exit fullscreen mode

Run it, it works, everyone goes home. ๐Ÿ 

Now draw the arrows. Who imports whom?

OrderService  โ”€โ”€โ–ถ  PostgresOrderRepository
OrderService  โ”€โ”€โ–ถ  StripeClient
Enter fullscreen mode Exit fullscreen mode

The most important class in the app depends on the two most replaceable classes in the app. That's upside down. And here's how you feel it:

  • Want to write a test for placeOrder()? Congratulations, your test now needs a running Postgres and a Stripe API key. For a function with two lines of logic.
  • Marketing wants a free-trial checkout with no payment? You're going to put an if inside OrderService to skip Stripe. Then another for the sandbox. Then...
  • The database team migrates to MySQL? Open OrderService, the one file that should never care.
  • Running it locally charges a real card if STRIPE_KEY is a real key. Ask me how I know. ๐Ÿ˜ฌ

The service news its own dependencies, so it is welded to them. Let's cut the welds.

Step 1: the high-level module describes what it needs

OrderService needs two things: somewhere to put orders, and something that can take money. It should describe those in its own words, not in Postgres or Stripe words:

// src/order-service.ts
export interface NewOrder {
  customerId: string
  total: number
}

export interface OrderRepository {
  insert(order: NewOrder): Promise<string>
}

export interface PaymentGateway {
  charge(customerId: string, amount: number): Promise<void>
}

export class OrderService {
  constructor(
    private readonly orders: OrderRepository,
    private readonly payments: PaymentGateway,
  ) {}

  async placeOrder(customerId: string, total: number) {
    await this.payments.charge(customerId, total)
    const id = await this.orders.insert({ customerId, total })
    console.log(`๐Ÿงพ order ${id} placed`)
    return id
  }
}
Enter fullscreen mode Exit fullscreen mode

Two things to notice:

  1. The interfaces live next to the service, because the service owns them. It says "this is what I need". That's the "abstractions belong to the high level" part.
  2. The dependencies come in through the constructor. The service doesn't build them anymore. That's the "dependency injection" part, which is the everyday tool we use to follow DIP.

Step 2: the details plug in

// src/postgres-order-repository.ts
import type { OrderRepository, NewOrder } from './order-service.js'

export class PostgresOrderRepository implements OrderRepository {
  constructor(private readonly url: string) {}

  async insert(order: NewOrder) {
    console.log(`๐Ÿ˜ INSERT INTO orders ... (${this.url})`)
    return 'ord_123'
  }
}
Enter fullscreen mode Exit fullscreen mode
// src/stripe-payment-gateway.ts
import type { PaymentGateway } from './order-service.js'

export class StripePaymentGateway implements PaymentGateway {
  constructor(private readonly apiKey: string) {}

  async charge(customerId: string, amount: number) {
    console.log(`๐Ÿ’ณ stripe charged ${customerId} $${amount}`)
  }
}
Enter fullscreen mode Exit fullscreen mode

Look at the imports now. The Postgres class imports from order-service.ts. The arrow flipped:

PostgresOrderRepository  โ”€โ”€โ–ถ  OrderRepository (owned by OrderService)
StripePaymentGateway     โ”€โ”€โ–ถ  PaymentGateway  (owned by OrderService)
Enter fullscreen mode Exit fullscreen mode

The plumbing points at the business logic. That's the "inversion".

Step 3: main.ts becomes the only place that knows everything

// src/main.ts
import { OrderService } from './order-service.js'
import { PostgresOrderRepository } from './postgres-order-repository.js'
import { StripePaymentGateway } from './stripe-payment-gateway.js'

const service = new OrderService(
  new PostgresOrderRepository('postgres://localhost/shop'),
  new StripePaymentGateway(process.env.STRIPE_KEY!),
)

await service.placeOrder('cus_42', 99)
Enter fullscreen mode Exit fullscreen mode

Same output as before. But now the big one: let's write that test.

Step 4: the test that used to be impossible

// src/order-service.test.ts
import { test, expect } from 'vitest'
import { OrderService, type OrderRepository, type PaymentGateway } from './order-service.js'

class InMemoryOrders implements OrderRepository {
  saved: unknown[] = []
  async insert(order: unknown) {
    this.saved.push(order)
    return 'ord_test'
  }
}

class FakePayments implements PaymentGateway {
  charged: number[] = []
  async charge(_customerId: string, amount: number) {
    this.charged.push(amount)
  }
}

test('placing an order charges and saves it', async () => {
  const orders = new InMemoryOrders()
  const payments = new FakePayments()
  const service = new OrderService(orders, payments)

  const id = await service.placeOrder('cus_42', 99)

  expect(id).toBe('ord_test')
  expect(payments.charged).toEqual([99])
  expect(orders.saved).toHaveLength(1)
})
Enter fullscreen mode Exit fullscreen mode

No Postgres. No Stripe. No mocking library. Two tiny classes that implement the interfaces OrderService asked for, and a test that runs in a millisecond.

And the free-trial checkout from earlier? new OrderService(repo, new NoChargeGateway()). Zero ifs inside the service.

The Why

Let's land this plane:

  • Business logic is the stable part; vendors are the volatile part. Stable code should never import volatile code. DIP is just that sentence turned into a rule.
  • Interfaces belong to the consumer. OrderService wrote PaymentGateway in its own vocabulary. Stripe adapted to it, not the other way around. When you swap Stripe for something else, the interface doesn't move.
  • Construction happens at the edge. main.ts (or your DI container, or your framework's wiring) is the only place that says new PostgresOrderRepository(...). Everything inside is handed what it needs.
  • Testing is a side effect, not a goal. We didn't "design for testability". We pointed the arrows the right way and the tests became easy on their own.

If you read my loosely coupled modules post, this should feel familiar. Same move, now with a name and a letter.

The Goodbye

And that's SOLID, all five letters, five tiny projects, five things we broke on purpose. If I had to compress the series into one line:

Small honest pieces, pointed at abstractions, glued together at the front door.

Thanks for riding along. Now go find a new keyword inside a service class and ask it why it's there. Happy coding ๐Ÿค

Top comments (1)

Collapse
 
blake_codes profile image
Blake Coleman •

The inversion clicked for me when I stopped thinking about "who owns the interface" and started asking "who is allowed to change without asking anyone else". Same idea, but it made the direction of the dependency obvious in a way the diagrams never did.