Skip to content

Dependency Inversion Principle (DIP)

SOLID Series — This article is Part 5 of 5. See also: SRP · OCP · LSP · ISP

Introduction

The Dependency Inversion Principle, the fifth of Robert C. Martin's SOLID principles, states two things: 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. DIP reverses the traditional dependency direction so that business logic is insulated from infrastructure concerns, making systems far more flexible, testable, and maintainable.

Core Concept

In a naive layered architecture, the business logic layer directly instantiates database connections, file writers, and email clients. When the database changes from MySQL to PostgreSQL, or the log target changes from a file to a cloud service, the business logic class must be modified. DIP eliminates this coupling by inserting an abstraction layer — an interface — between the high-level policy and the low-level mechanism. Both layers then depend on the interface, and the direction of source-code dependency is inverted relative to the direction of data flow.

The key phrase is "inversion": in the traditional model, high-level modules control low-level modules. With DIP, high-level modules define the interface that low-level modules must satisfy. The dependency arrow points toward the high-level abstraction.

Anti-Pattern — OrderService with Concrete Dependencies

The most common DIP violation is a service class that news its own infrastructure dependencies.

java
// ANTI-PATTERN: OrderService directly depends on concrete low-level classes
public class OrderService {

    // Hard-coded concrete dependency — cannot be swapped without modifying OrderService
    private MySQLDatabase database = new MySQLDatabase(
            "jdbc:mysql://localhost:3306/orders", "root", "password");

    // Another concrete dependency — impossible to replace with CloudLogger in production
    private FileLogger logger = new FileLogger("/var/log/orders.log");

    public Order placeOrder(Cart cart, Customer customer) {
        logger.log("Placing order for customer: " + customer.getId());

        Order order = new Order(cart, customer);
        order.setStatus(OrderStatus.PENDING);
        order.setCreatedAt(LocalDateTime.now());

        try {
            database.save("orders", order); // tightly coupled to MySQLDatabase API
            database.save("order_items", cart.getItems()); // schema details leak into service
            logger.log("Order " + order.getId() + " saved successfully");
        } catch (SQLException e) {
            logger.logError("Failed to save order: " + e.getMessage());
            throw new OrderPersistenceException("Order could not be saved", e);
        }

        return order;
    }

    public Optional<Order> findOrder(Long orderId) {
        try {
            return database.findById("orders", orderId, Order.class);
        } catch (SQLException e) {
            logger.logError("Error fetching order " + orderId + ": " + e.getMessage());
            return Optional.empty();
        }
    }
}

Problems with this design:

  • OrderService cannot be unit-tested without a running MySQL instance.
  • Switching from MySQL to PostgreSQL requires modifying OrderService.
  • Switching to a cloud logging service requires modifying OrderService.
  • The test environment must mirror production infrastructure exactly.
  • MySQLDatabase is directly referenced — the import alone creates a compile-time dependency.

Correct Implementation — Abstractions and Constructor Injection

Define interfaces that represent what the high-level module needs. Low-level modules implement those interfaces.

java
// CORRECT: Database abstraction — belongs to the domain/application layer
public interface Database {
    <T> T save(T entity);
    <T> Optional<T> findById(Long id, Class<T> type);
    <T> List<T> findAll(Class<T> type);
    void delete(Long id, Class<?> type);
}

// CORRECT: Logger abstraction — belongs to the application layer
public interface Logger {
    void log(String message);
    void logError(String message);
    void logError(String message, Throwable cause);
}
java
// CORRECT: MySQL implementation — lives in the infrastructure layer
public class MySQLDatabase implements Database {

    private final String jdbcUrl;
    private final String username;
    private final String password;

    public MySQLDatabase(String jdbcUrl, String username, String password) {
        this.jdbcUrl = jdbcUrl;
        this.username = username;
        this.password = password;
    }

    @Override
    public <T> T save(T entity) {
        // MySQL-specific JDBC or Hibernate implementation
        System.out.println("[MySQL] Saving entity: " + entity.getClass().getSimpleName());
        return entity; // simplified
    }

    @Override
    public <T> Optional<T> findById(Long id, Class<T> type) {
        System.out.println("[MySQL] Finding " + type.getSimpleName() + " with id: " + id);
        return Optional.empty(); // simplified
    }

    @Override
    public <T> List<T> findAll(Class<T> type) {
        System.out.println("[MySQL] Fetching all " + type.getSimpleName());
        return List.of();
    }

    @Override
    public void delete(Long id, Class<?> type) {
        System.out.println("[MySQL] Deleting " + type.getSimpleName() + " with id: " + id);
    }
}

// CORRECT: PostgreSQL implementation — can replace MySQL without touching OrderService
public class PostgreSQLDatabase implements Database {

    private final DataSource dataSource;

    public PostgreSQLDatabase(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    @Override
    public <T> T save(T entity) {
        System.out.println("[PostgreSQL] Saving entity: " + entity.getClass().getSimpleName());
        return entity;
    }

    @Override
    public <T> Optional<T> findById(Long id, Class<T> type) {
        System.out.println("[PostgreSQL] Finding " + type.getSimpleName() + " with id: " + id);
        return Optional.empty();
    }

    @Override
    public <T> List<T> findAll(Class<T> type) {
        return List.of();
    }

    @Override
    public void delete(Long id, Class<?> type) {
        System.out.println("[PostgreSQL] Deleting " + type.getSimpleName() + " with id: " + id);
    }
}
java
// CORRECT: File logger — implements the abstraction
public class FileLogger implements Logger {

    private final String logFilePath;

    public FileLogger(String logFilePath) {
        this.logFilePath = logFilePath;
    }

    @Override
    public void log(String message) {
        System.out.println("[FILE LOG] " + LocalDateTime.now() + " INFO: " + message);
        // Real implementation: write to logFilePath via BufferedWriter
    }

    @Override
    public void logError(String message) {
        System.err.println("[FILE LOG] " + LocalDateTime.now() + " ERROR: " + message);
    }

    @Override
    public void logError(String message, Throwable cause) {
        System.err.println("[FILE LOG] " + LocalDateTime.now() + " ERROR: " + message + " | " + cause.getMessage());
    }
}

// CORRECT: Cloud logger — swap in without touching any business logic
public class CloudLogger implements Logger {

    private final CloudLoggingClient client;
    private final String serviceName;

    public CloudLogger(CloudLoggingClient client, String serviceName) {
        this.client = client;
        this.serviceName = serviceName;
    }

    @Override
    public void log(String message) {
        client.send(LogEntry.info(serviceName, message));
    }

    @Override
    public void logError(String message) {
        client.send(LogEntry.error(serviceName, message));
    }

    @Override
    public void logError(String message, Throwable cause) {
        client.send(LogEntry.error(serviceName, message, cause));
    }
}
java
// CORRECT: OrderService depends only on abstractions, received via constructor injection
public class OrderService {

    private final Database database;
    private final Logger logger;

    // Constructor injection — dependencies are explicit and substitutable
    public OrderService(Database database, Logger logger) {
        this.database = database;
        this.logger = logger;
    }

    public Order placeOrder(Cart cart, Customer customer) {
        logger.log("Placing order for customer: " + customer.getId());

        Order order = new Order(cart, customer);
        order.setStatus(OrderStatus.PENDING);
        order.setCreatedAt(LocalDateTime.now());

        Order saved = database.save(order);
        logger.log("Order " + saved.getId() + " placed successfully");
        return saved;
    }

    public Optional<Order> findOrder(Long orderId) {
        logger.log("Looking up order: " + orderId);
        return database.findById(orderId, Order.class);
    }

    public void cancelOrder(Long orderId) {
        logger.log("Cancelling order: " + orderId);
        database.delete(orderId, Order.class);
        logger.log("Order " + orderId + " cancelled");
    }
}

Spring Boot — Dependency Injection in Practice

Spring's IoC container implements DIP at the framework level. You declare your abstractions, annotate your implementations, and let Spring wire everything together.

java
// Spring Boot: interface lives in the service layer
public interface OrderRepository {
    Order save(Order order);
    Optional<Order> findById(Long id);
    List<Order> findAll();
    void deleteById(Long id);
}

// Infrastructure: JPA implementation — Spring manages instantiation
@Repository
public class JpaOrderRepository implements OrderRepository {

    @PersistenceContext
    private EntityManager entityManager;

    @Override
    @Transactional
    public Order save(Order order) {
        entityManager.persist(order);
        return order;
    }

    @Override
    public Optional<Order> findById(Long id) {
        return Optional.ofNullable(entityManager.find(Order.class, id));
    }

    @Override
    public List<Order> findAll() {
        return entityManager.createQuery("SELECT o FROM Order o", Order.class).getResultList();
    }

    @Override
    @Transactional
    public void deleteById(Long id) {
        Optional.ofNullable(entityManager.find(Order.class, id)).ifPresent(entityManager::remove);
    }
}

// Application service — depends on interface, not JpaOrderRepository
@Service
public class OrderManagementService {

    private final OrderRepository orderRepository;
    private final Logger logger;

    // Spring injects JpaOrderRepository here automatically
    public OrderManagementService(OrderRepository orderRepository, Logger logger) {
        this.orderRepository = orderRepository;
        this.logger = logger;
    }

    public Order createOrder(OrderRequest request) {
        logger.log("Creating order for: " + request.getCustomerId());
        Order order = Order.from(request);
        return orderRepository.save(order);
    }

    public List<Order> getAllOrders() {
        return orderRepository.findAll();
    }
}

// Spring configuration — wires Logger implementation
@Configuration
public class InfrastructureConfig {

    @Bean
    public Logger logger(@Value("${log.file.path:/var/log/app.log}") String logPath) {
        return new FileLogger(logPath);
        // Switch to: return new CloudLogger(cloudClient, "order-service");
        // without changing OrderManagementService at all
    }
}

Diagrams

Abstraction Layer Architecture

Spring IoC Container

Module Dependency Graph — Before and After DIP

DIP Compliance Flowchart

Class Diagram — Complete DIP Architecture

Unit Test — Testing With Mock Dependencies

The ultimate payoff of DIP is testability. Because OrderService depends on interfaces, tests can inject mock implementations without any real database or file system.

java
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    private Database database;

    @Mock
    private Logger logger;

    @InjectMocks
    private OrderService orderService;

    @Test
    void placeOrder_savesOrderAndLogsSuccess() {
        // Arrange
        Cart cart = new Cart(List.of(new CartItem("Widget", 2, 9.99)));
        Customer customer = new Customer(42L, "Alice");
        Order expectedOrder = new Order(cart, customer);
        expectedOrder.setId(100L);

        when(database.save(any(Order.class))).thenReturn(expectedOrder);

        // Act
        Order result = orderService.placeOrder(cart, customer);

        // Assert
        assertThat(result.getId()).isEqualTo(100L);
        verify(database).save(any(Order.class));
        verify(logger).log(contains("customer: 42"));
        verify(logger).log(contains("Order 100"));
        // No MySQL server, no file system — pure logic test
    }

    @Test
    void findOrder_delegatesToDatabase() {
        Long orderId = 55L;
        Order order = new Order();
        order.setId(orderId);
        when(database.findById(orderId, Order.class)).thenReturn(Optional.of(order));

        Optional<Order> result = orderService.findOrder(orderId);

        assertThat(result).isPresent();
        assertThat(result.get().getId()).isEqualTo(orderId);
        verify(logger).log(contains("55"));
    }

    @Test
    void cancelOrder_deletesFromDatabaseAndLogs() {
        Long orderId = 77L;
        doNothing().when(database).delete(orderId, Order.class);

        orderService.cancelOrder(orderId);

        verify(database).delete(orderId, Order.class);
        verify(logger, times(2)).log(anyString());
    }
}

These tests run in milliseconds with no external dependencies. Switching the production implementation from MySQLDatabase to PostgreSQLDatabase requires zero test changes.

Best Practices

  1. Constructor injection over field injection — Constructor injection makes dependencies explicit, required, and visible. Field injection with @Autowired hides dependencies and makes the class harder to instantiate in tests. Always prefer @Autowired on the constructor or, better yet, omit it entirely and let Spring detect the single constructor.
  2. Depend on interfaces, not classes — Import only the interface type in your high-level module. If your import statements include concrete infrastructure classes (MySQLDatabase, FileLogger), that is a DIP violation in the source code.
  3. Let the IoC container manage lifecycle — Spring, Guice, and CDI are DIP implementations at the framework level. Trust the container to wire dependencies; do not new infrastructure objects inside services.
  4. Place interfaces in the application layer — The Database and Logger interfaces belong to your domain/application layer, not the infrastructure layer. This ensures the dependency arrows point inward (toward the domain), which is the architectural inversion DIP describes.
  5. One interface per capability — Combining DIP with ISP means each injected interface should represent only the capability the class actually needs. An OrderService that only reads orders should receive ReadableOrderRepository, not the full OrderRepository.
  6. Test at the seam — The constructor of a DIP-compliant service is the "seam" where implementations are swapped. Always write tests that inject mock implementations via the constructor to verify the service's logic in isolation.
  7. Avoid service locators — The Service Locator pattern (ServiceLocator.get(Database.class)) is a DIP anti-pattern in disguise. It hides dependencies inside method bodies, making them invisible to callers and impossible to inject in tests.

Other SOLID Principles:

Further Reading:

  • Inversion of Control (IoC) containers: Spring Framework, Google Guice, Jakarta CDI.
  • Clean Architecture by Robert C. Martin: the Dependency Rule and how it maps to DIP.
  • Hexagonal Architecture (Ports and Adapters): a structural pattern that enforces DIP at the module level.
  • REST API Design: applying DIP to HTTP client abstractions in API integrations.