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 wat

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
  }
}

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'
  }
}
// src/stripe-client.ts
export class StripeClient {
  constructor(private readonly apiKey: string) {}

  async charge(customerId: string, amount: number) {
    console.log(`???? stripe charged ${customerId} $${amount}`)
  }
}

And main:

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

await new OrderService().placeOrder('cus_42', 99)

Run it, it works, everyone goes home. ????

Now draw the arrows. Who imports whom?

OrderService  ──▶  PostgresOrderRepository
OrderService  ──▶  StripeClient

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
  }
}

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'
  }
}
// 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}`)
  }
}

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)

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)

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)
})

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 ????

Share