Dependency Inversion Principle

Intermediate · 10 min read · ▶ live playground · ✦ checkpoint

An order rule says totals must be positive before they are saved. Testing that rule should need one order, but OrderService constructs a SQL store inside itself, so a policy test now drags in database setup.

The Dependency Inversion Principle (DIP) says high-level policy should depend on abstractions, not concrete low-level details. The detail also depends on that abstraction, and the policy side shapes the interface around what it needs.

A high-level policy is a business decision, such as when an order may be accepted. A low-level detail is a mechanism, such as saving rows in SQL. Database machinery should serve the order policy; it should not dictate the shape of that policy.

Before: policy constructs its storage detail

The first design points directly from OrderService to SqlOrderStore. The service chooses the concrete class, creates it, stores it, and calls its API.

class diagram

Before: order policy tied to SQL

Before: order policy tied to SQL
  1. 1OrderService owns the acceptance policy but also chooses its storage class.
  2. 2The service constructs and owns SqlOrderStore, fixing one low-level mechanism in place.
  3. 3Changing storage or isolating a policy test now requires changing the high-level class.

The direct construction hides the dependency from callers but does not remove it:

type Order = {
  id: string;
  total: number;
};
 
class SqlOrderStore {
  private table = "orders";
 
  public insert(order: Order): void {
    console.log(`SQL INSERT ${order.id} INTO ${this.table}`);
  }
}
 
class OrderService {
  // smell: high-level policy constructs and depends on one concrete store.
  private store = new SqlOrderStore();
 
  public place(order: Order): string {
    if (order.total <= 0) {
      throw new Error("Order total must be positive.");
    }
 
    this.store.insert(order);
    return `Accepted ${order.id}`;
  }
}
 
const service = new OrderService();
console.log(service.place({ id: "ORD-41", total: 75 }));

SQL INSERT ORD-41 INTO orders Accepted ORD-41

OrderService has two kinds of knowledge: the rule for accepting an order and the constructor plus method name of a SQL class. Moving to an HTTP service requires editing policy code. A focused test cannot supply a harmless in-memory store because the service has already made the choice.

This is tight coupling, a dependency where one component knows enough concrete detail that changing or replacing the other requires edits. The private field protects access, but encapsulation does not make the coupling flexible.

After: policy defines the abstraction it needs

The order policy needs one storage operation: save an accepted order. OrderStore names exactly that need. OrderService holds the abstraction, while SQL and in-memory details implement it.

class diagram

After: policy and details meet at OrderStore

After: policy and details meet at OrderStore
  1. 1OrderStore is shaped by the policy's need: save an accepted order.
  2. 2OrderService keeps an injected OrderStore and knows no storage mechanism.
  3. 3SqlOrderStore now depends on the policy-owned contract and supplies the SQL detail.
  4. 4A second detail joins without changing OrderService; policy tests can stay in memory.

The source-code dependency has inverted. Before, the policy named SqlOrderStore. After, both the policy and concrete stores point toward OrderStore. The interface belongs conceptually with the policy because its vocabulary—save(order)—describes what the order flow needs, not how a database driver works.

type Order = {
  id: string;
  total: number;
};
 
interface OrderStore {
  save(order: Order): void;
}
 
class OrderService {
  private store: OrderStore;
 
  public constructor(store: OrderStore) {
    this.store = store;
  }
 
  public place(order: Order): string {
    if (order.total <= 0) {
      throw new Error("Order total must be positive.");
    }
 
    this.store.save(order);
    return `Accepted ${order.id}`;
  }
}
 
class SqlOrderStore implements OrderStore {
  private table = "orders";
 
  public save(order: Order): void {
    console.log(`SQL INSERT ${order.id} INTO ${this.table}`);
  }
}
 
class InMemoryOrderStore implements OrderStore {
  private saved: Order[] = [];
 
  public save(order: Order): void {
    this.saved.push(order);
  }
 
  public savedIds(): string[] {
    return this.saved.map((order) => order.id);
  }
}
 
const production = new OrderService(new SqlOrderStore());
console.log(production.place({ id: "ORD-41", total: 75 }));
 
const memoryStore = new InMemoryOrderStore();
const policyUnderTest = new OrderService(memoryStore);
policyUnderTest.place({ id: "ORD-TEST", total: 20 });
console.log(memoryStore.savedIds().join(", "));

SQL INSERT ORD-41 INTO orders Accepted ORD-41 ORD-TEST

OrderService now receives its collaborator through constructor injection, supplying a dependency as a constructor argument. The production setup provides SQL. A test provides an in-memory object and can inspect what the policy saved without starting a database.

Dependency injection and dependency inversion are related but different. Injection is the mechanism for supplying an object. DIP is the design of the dependency direction. A constructor that accepts SqlOrderStore would use injection while leaving the high-level policy coupled to the same concrete detail.

This is also composition from the previous module: OrderService performs its policy by collaborating with a held OrderStore. Composition explains how the runtime object is assembled. DIP explains why the source types point toward a policy-owned abstraction.

When not to over-apply DIP

Do not wrap every concrete class in an interface with the same name and methods. A stable, pure helper with one implementation may be clearer as a direct dependency. Adding StringUppercaser around text.toUpperCase() creates another file and constructor argument without protecting a meaningful boundary.

An abstraction earns its place when the dependency is volatile, side-effecting, expensive to run in tests, supplied by another system, or already has multiple useful implementations. Databases, clocks, payment gateways, and message senders often qualify. A tiny deterministic calculation often does not.

The policy must also remain the owner. Copying an entire vendor SDK into PaymentGateway is not inversion; it is a renamed concrete API. Define the smallest business-facing operations the high-level flow needs, then adapt the detail to them.

Checkpoint

Answer all three to mark this lesson complete

With SOLID as your vocabulary, the pattern catalog ahead is just named solutions to these forces.

+50 XP on completion