Skip to content
ioob.dev
Go back

OOP Design Principles Part 3 — DIP

· 6 min read
OOP & SOLID Series (3/5)
  1. OOP Design Principles Part 1 — SRP and OCP
  2. OOP Design Principles Part 2 — LSP and ISP
  3. OOP Design Principles Part 3 — DIP
  4. OOP Design Principles Part 4 — TDA, Law of Demeter, CQS
  5. OOP Design Principles Part 5 — Composition over Inheritance
Table of contents

Table of contents

DIP — High-Level Modules Should Not Be Dragged Around by Low-Level Ones

DIP (Dependency Inversion Principle) was proposed by Robert C. Martin and consists of two rules.

  1. High-level modules should not depend on low-level modules. Both should depend on abstractions.
  2. Abstractions should not depend on details. Details should depend on abstractions.

Terms like “high-level” and “low-level” might sound abstract. Let’s be concrete.

When a high-level module directly depends on a low-level module, changes to the low-level affect the high-level. The core business logic gets dragged around by the DB type or the mail library. DIP says to invert this direction of dependency.

What It Means to Invert Dependencies

Before Inversion — The Natural Direction

The most natural structure when first writing code looks like this.

public class OrderService {

    private final MySqlOrderRepository repository = new MySqlOrderRepository();

    public void createOrder(Order order) {
        // Business logic
        order.validate();
        order.calculateTotal();

        // Save
        repository.save(order);
    }
}

public class MySqlOrderRepository {

    public void save(Order order) {
        // INSERT into MySQL
        String sql = "INSERT INTO orders ...";
        // JDBC connection, execution
    }
}

Drawing the dependency direction, the high-level (OrderService) directly references the low-level (MySqlOrderRepository).

flowchart TB
    subgraph before["Before dependency inversion"]
        direction TB
        OS["OrderService<br/>High-level — Business logic"] -->|"Direct dependency"| MySQL["MySqlOrderRepository<br/>Low-level — MySQL implementation"]
    end
    style MySQL fill:#ff6b6b,color:#fff

The problems with this structure are clear. To switch MySQL to PostgreSQL, you must modify OrderService. Testing requires an actual MySQL instance. Business logic is coupled to infrastructure technology.

After Inversion — Depending on Abstractions

Introduce an interface. Then make the high-level module depend on this interface.

public interface OrderRepository {
    void save(Order order);
    Order findById(Long id);
}

The high-level module depends only on the interface.

public class OrderService {

    private final OrderRepository repository;

    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }

    public void createOrder(Order order) {
        order.validate();
        order.calculateTotal();
        repository.save(order);
    }
}

The low-level module implements the interface.

public class MySqlOrderRepository implements OrderRepository {

    @Override
    public void save(Order order) {
        // INSERT into MySQL
    }

    @Override
    public Order findById(Long id) {
        // SELECT from MySQL
        return null;
    }
}

Drawing the dependency direction again reveals the arrows have changed.

flowchart TB
    subgraph after["After dependency inversion"]
        direction TB
        OS2["OrderService<br/>High-level — Business logic"] -->|"Depends on abstraction"| ORI["OrderRepository<br/>Interface — Abstraction"]
        MySQLImpl["MySqlOrderRepository<br/>Low-level — MySQL implementation"] -->|"Implements abstraction"| ORI
    end
    style ORI fill:#339af0,color:#fff
    style OS2 fill:#51cf66,color:#fff
    style MySQLImpl fill:#ffd43b,color:#000

OrderService depends on the OrderRepository interface. MySqlOrderRepository also depends on the OrderRepository interface (in the form of implementation). Both depend on the abstraction. Because the low-level module’s dependency direction has flipped toward the high-level module, it’s called “Inversion.”

The Core of Inversion — Who Owns the Interface?

There’s an important point here. Where should the OrderRepository interface reside?

Intuitively, you might think “Why not put it next to the implementation?” — placing it in the same package as the MySQL-related code. But in DIP, the owner of the interface is the side that uses it, i.e., the high-level module.

com.example.order/
├── OrderService.java           ← High-level module
├── OrderRepository.java        ← Interface (located in high-level package)
└── ...

com.example.order.infrastructure/
├── MySqlOrderRepository.java   ← Low-level module (implements the interface)
└── ...

When the interface is in the high-level package, the low-level package depends on the high-level package. Dependencies are inverted at the package level too. This is the complete application of DIP.

flowchart LR
    subgraph domain["com.example.order — High-level"]
        OS3["OrderService"]
        ORI2["OrderRepository<br/>Interface"]
        OS3 --> ORI2
    end
    subgraph infra["com.example.order.infrastructure — Low-level"]
        MySQLImpl2["MySqlOrderRepository"]
    end
    MySQLImpl2 -->|"implements"| ORI2

DI and DIP — Similar Names, Different Concepts

If you’ve used Spring, DI (Dependency Injection) probably comes to mind. DI and DIP are frequently confused due to their similar names, but they are different concepts.

DI is a commonly used tool for implementing DIP, but you can use DI without DIP, and you can apply DIP without DI.

DIP Without DI

Here’s an example of applying DIP using the factory pattern. No framework needed.

public class OrderRepositoryFactory {

    public static OrderRepository create() {
        String dbType = System.getProperty("db.type");
        if ("mysql".equals(dbType)) {
            return new MySqlOrderRepository();
        }
        return new PostgresOrderRepository();
    }
}

// Usage
OrderRepository repository = OrderRepositoryFactory.create();
OrderService service = new OrderService(repository);

OrderService still depends only on the OrderRepository interface. DIP is maintained. There’s just no DI container.

DI Without DIP

Conversely, you can use DI while still violating DIP.

public class OrderService {

    private final MySqlOrderRepository repository;

    // Constructor injection — DI is applied
    public OrderService(MySqlOrderRepository repository) {
        this.repository = repository;
    }
}

MySqlOrderRepository is injected from outside, so DI is applied. But since the dependency is on a concrete class rather than an interface, DIP is violated. It’s not the form of injection but the direction of dependency that matters for DIP.

DIP + DI in Spring

In Spring Boot, DIP and DI naturally combine.

Declare the interface.

interface OrderRepository {
    fun save(order: Order)
    fun findById(id: Long): Order?
}

Annotate the implementation with @Repository.

@Repository
class JpaOrderRepository(
    private val jpaRepository: OrderJpaRepository
) : OrderRepository {

    override fun save(order: Order) {
        jpaRepository.save(order.toEntity())
    }

    override fun findById(id: Long): Order? {
        return jpaRepository.findByIdOrNull(id)?.toDomain()
    }
}

The service depends on the interface. Spring finds and injects the implementation.

@Service
class OrderService(
    private val orderRepository: OrderRepository  // Depends on interface
) {

    fun createOrder(order: Order) {
        order.validate()
        order.calculateTotal()
        orderRepository.save(order)
    }
}

DIP (depending on the interface) and DI (Spring injecting the implementation) work together. Spring’s IoC container (Inversion of Control) is infrastructure that makes DIP convenient to apply.

DIP in Layered Architecture

In a traditional 3-layer architecture (Presentation -> Business -> Data Access), the dependency direction flows from top to bottom.

flowchart TB
    subgraph traditional["Traditional layered structure — Dependencies flow downward"]
        Pres["Presentation Layer<br/>Controller"]
        Biz["Business Layer<br/>Service"]
        Data["Data Access Layer<br/>Repository"]
        Pres --> Biz
        Biz --> Data
    end

In this structure, the business layer depends on the data access layer. Changes to DB technology can affect business logic. Applying DIP means the business layer owns the interfaces, and the data access layer implements them.

flowchart TB
    subgraph dip_arch["DIP Applied — Dependencies point inward"]
        Pres2["Presentation Layer"]
        Biz2["Business Layer<br/>Service + Repository Interfaces"]
        Data2["Data Access Layer<br/>Repository Implementations"]
        Pres2 --> Biz2
        Data2 -->|"implements"| Biz2
    end
    style Biz2 fill:#339af0,color:#fff

The data access layer’s arrow direction has changed. It now depends toward the business layer. The business layer no longer knows or needs to know which DB is being used. Even switching MySQL to MongoDB doesn’t change a single line in the business layer.

This principle, taken further, leads to hexagonal architecture (Ports & Adapters) and clean architecture. In these architectures, DIP is a core principle. Business logic sits at the center, and external technologies (DB, API, UI) connect like plugins, adapting to the business logic.

DIP in Practice — How Far Should You Go?

Once you understand DIP’s theory, the question arises: “Should I create interfaces everywhere?” The answer is no. Creating interfaces has costs too. Files multiply, code to trace increases, and simple structures can become complex.

Good places to apply DIP include:

  1. Boundaries with external systems — DB, message queues, external APIs, file systems. Infrastructure technology is likely to change, and you often need to replace them with mocks in tests
  2. Core business logic — Domain logic should not depend on frameworks or libraries
  3. Policies likely to change — Discount policies, notification methods, and other parts that change based on business needs

On the other hand, direct dependencies are fine in these cases:

Summarizing DIP Application Criteria

// Interface needed — boundary with external systems
interface NotificationSender {
    fun send(userId: Long, message: String)
}

class EmailNotificationSender : NotificationSender { /* ... */ }
class SlackNotificationSender : NotificationSender { /* ... */ }

// Interface unnecessary — internal utility with no chance of change
class DateFormatter {
    fun format(date: LocalDateTime): String =
        date.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))
}

NotificationSender could change from email to Slack to KakaoTalk. There’s plenty of reason to abstract it behind an interface. DateFormatter just formats dates. If there’s no chance of a second implementation, it’s fine to depend on it directly.

Key Takeaways

ConceptDescription
DIPBoth high-level and low-level modules should depend on abstractions
Dependency InversionFlipping the direction so low-level depends toward high-level
Interface OwnershipThe interface is owned by the user side (high-level)
DI vs DIPDI is an injection technique, DIP is a design principle. Independent but powerful together
Application CriteriaExternal system boundaries, core domain, policies likely to change

We’ve covered all five SOLID principles across three parts. SRP limits reasons for change to one, OCP creates extensible structures, LSP ensures polymorphism works correctly, ISP segregates interfaces by client needs, and DIP inverts the direction of dependencies toward abstractions.

These principles shine most when applied together rather than individually. SRP separates responsibilities, OCP creates extension points, LSP maintains polymorphism correctness, ISP tidies interfaces, and DIP establishes dependency direction. Ultimately, they all aim at the same goal — code that is resilient to change.

In the next part, we’ll look at principles beyond SOLID that govern communication between objects. TDA (Tell, Don’t Ask), the Law of Demeter, and CQS (Command-Query Separation) are the main characters.


-> Part 4: TDA, Law of Demeter, CQS


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
OOP Design Principles Part 2 — LSP and ISP
Next Post
OOP Design Principles Part 4 — TDA, Law of Demeter, CQS