Free lesson · Low Level Design Fundamentals
What Dependency Inversion Actually Means
What Dependency Inversion Actually Means
The Dependency Inversion Principle (DIP) is the last of the SOLID principles and arguably the most architecturally significant. Robert Martin gave it two formulations, both of which capture an aspect of the same idea:
- 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.
The word "inversion" matters. In a naive layered architecture, high-level code (business logic) depends on low-level code (databases, HTTP clients, file I/O). The high level uses the low level, so it naturally depends on it. DIP says: invert this. Make the high level depend on abstractions; let the low level implement those abstractions. The arrows of dependency reverse.
The principle is what makes most modern application architectures possible. Hexagonal architecture, Clean Architecture, Onion Architecture - all rely on DIP as their core mechanism. Testing-without-databases, swappable infrastructure, plugin systems - all are DIP applications. Mastering DIP isn't optional for senior engineering work; it's foundational to how systems get designed.
In interviews, DIP is the principle that most often appears in system design and architecture discussions. "How would you make this testable?" "How would you swap this implementation?" "How would you isolate the business logic from infrastructure?" - each question's strong answer involves DIP.
What the Naive Architecture Looks Like
To understand inversion, look at what's being inverted. The naive layered architecture flows top-down:
graph TD
A[OrderService<br/>business logic] --> B[OrderRepository<br/>data access]
B --> C[JdbcDatabase<br/>infrastructure]
A --> D[EmailNotifier<br/>concrete class]
D --> E[SmtpClient<br/>infrastructure]
A diagram showing a naive layered architecture. OrderService at the top is the business logic, depending downward on OrderRepository and EmailNotifier. OrderRepository depends on JdbcDatabase, an infrastructure component. EmailNotifier depends on SmtpClient, also infrastructure. All dependencies flow from high-level business logic down toward low-level infrastructure.
Each layer depends on the layer below it. Business logic depends on data access. Data access depends on database infrastructure. Business logic also depends on notification. Notification depends on email infrastructure.
This architecture has several problems:
- The business logic can't run without the infrastructure. Testing
OrderServicerequires a database, an SMTP server, or working mocks of them. - Changing infrastructure ripples upward. Switching from JDBC to MongoDB requires changes through the layers.
- Business logic depends on concrete things. It knows about
JdbcDatabaseandSmtpClient(transitively or directly), which means it's coupled to specific technologies. - The dependency graph follows the use relationship. Higher level uses lower level, so higher level depends on lower level. The direction is "natural" but limiting.
What DIP Does
DIP says: between the high level and the low level, put an abstraction. Both depend on the abstraction. The low level implements it.
graph TD
A[OrderService<br/>business logic] --> B[OrderRepository<br/>interface]
A --> C[Notifier<br/>interface]
D[JdbcOrderRepository<br/>infrastructure] --> B
E[EmailNotifier<br/>infrastructure] --> C
A diagram showing DIP-inverted architecture. OrderService depends on OrderRepository and Notifier interfaces. JdbcOrderRepository and EmailNotifier are concrete infrastructure classes that implement those interfaces. The dependency arrows from infrastructure point upward to the interfaces, not downward from business logic to infrastructure.
Look at the arrows. The high-level OrderService still uses the repository and notifier - but it depends on interfaces, not on concrete classes. The concrete classes (JdbcOrderRepository, EmailNotifier) implement those interfaces. The dependency between business logic and infrastructure is now mediated by the interfaces.
What's been inverted: the direction of compile-time dependency. In the naive architecture, business logic depended on infrastructure. In the DIP architecture, infrastructure depends on (implements) interfaces that the business logic defines. The arrow flips.
The business logic no longer knows about JdbcOrderRepository or EmailNotifier. It knows only about the interfaces. At runtime, of course, the concrete classes are wired in (dependency injection or manual wiring). But at compile time and at the level of source code dependencies, the business logic is isolated from infrastructure.
The "Inversion" Is About Where Abstractions Are Owned
A subtle but important point: in DIP-respecting code, the abstractions belong to the high-level module, not to the low-level one.
// In the business logic module
package com.example.orders.domain;
public interface OrderRepository {
Optional<Order> findById(OrderId id);
void save(Order order);
}
// In the infrastructure module
package com.example.orders.infrastructure;
import com.example.orders.domain.OrderRepository;
public class JdbcOrderRepository implements OrderRepository {
// ...
}
The OrderRepository interface lives in the domain module. The implementation lives in the infrastructure module. The infrastructure module depends on the domain module (it imports the interface). The domain module doesn't depend on infrastructure at all.
This is the deeper inversion: not just that high-level depends on abstraction, but that the high-level defines the abstraction. The abstraction belongs to the high-level module because the high-level module decides what it needs. The low-level module is one of possibly many implementations.
This pattern is essential for hexagonal/Clean architecture. The domain defines ports (interfaces for what it needs). The infrastructure provides adapters (implementations). The flow of definition is from high to low; the flow of dependency is from low to high.
A Concrete Before-and-After
To make this concrete, look at the same OrderService written both ways.
Without DIP - Direct Dependency
public class OrderService {
private final JdbcOrderRepository repository;
private final SmtpEmailSender emailSender;
public OrderService(JdbcOrderRepository repository, SmtpEmailSender emailSender) {
this.repository = repository;
this.emailSender = emailSender;
}
public void placeOrder(Order order) {
repository.save(order);
emailSender.send(
order.customer().email(),
"Order confirmed",
buildEmailBody(order)
);
}
}
OrderService imports and depends on JdbcOrderRepository and SmtpEmailSender. To test it, you need real implementations or mocks of these specific classes. To swap to MongoDB or to a different email provider, you have to change OrderService's constructor and possibly its implementation.
With DIP - Inverted Dependency
// In the domain module
public interface OrderRepository {
void save(Order order);
}
public interface OrderNotifier {
void notifyOrderConfirmed(Order order);
}
public class OrderService {
private final OrderRepository repository;
private final OrderNotifier notifier;
public OrderService(OrderRepository repository, OrderNotifier notifier) {
this.repository = repository;
this.notifier = notifier;
}
public void placeOrder(Order order) {
repository.save(order);
notifier.notifyOrderConfirmed(order);
}
}
// In the infrastructure module
public class JdbcOrderRepository implements OrderRepository {
@Override
public void save(Order order) { /* SQL implementation */ }
}
public class EmailOrderNotifier implements OrderNotifier {
@Override
public void notifyOrderConfirmed(Order order) {
// build email and send via SMTP
}
}
OrderService depends on OrderRepository and OrderNotifier interfaces, both defined in the domain. The concrete classes implement these interfaces and live in infrastructure.
Now:
OrderServicecan be tested with in-memory implementations ofOrderRepositoryandOrderNotifier.- Switching from JDBC to MongoDB is a new class implementing
OrderRepository. The domain doesn't change. - The domain module compiles without any infrastructure dependencies. It could be in a separate JAR that infrastructure depends on.
This is DIP in its essential form.
The Test for DIP Compliance
A practical test: can the high-level module compile without the low-level module?
If yes, DIP is being respected. The high level doesn't depend on the low level at compile time.
If no, DIP is being violated. Some dependency from high to low is in the import graph.
For applications, this test usually requires actual module separation - putting domain code in one Maven/Gradle module and infrastructure in another. Many codebases never enforce the separation at the build level; they hope developers will follow conventions. With explicit modules, the build catches violations immediately:
ERROR: domain module cannot reference 'org.springframework.jdbc.core.JdbcTemplate'.
It's defined in the infrastructure module.
Without explicit modules, the discipline is manual. Code reviews catch some violations; some slip through. Over time, the domain accumulates imports it shouldn't have, and the DIP property is gradually eroded.
Why "Both Should Depend on Abstractions"
The principle says high-level modules and low-level modules should both depend on abstractions. The high-level dependency is the one most often discussed (high level depending on infrastructure-level abstractions). But the low-level dependency matters too.
A concrete JdbcOrderRepository doesn't depend on infrastructure abstractions; it depends on JDBC directly. But the use of JdbcOrderRepository is via the abstraction. The class itself can know about JDBC; the rest of the system knows it only through OrderRepository.
This produces the layered structure:
- Domain layer. Defines abstractions, has no infrastructure dependencies.
- Application layer. Uses domain abstractions to orchestrate workflows. Often defines additional abstractions for its specific use cases.
- Infrastructure layer. Provides concrete implementations of abstractions. Depends on the domain to know what to implement.
Each layer depends only "inward" - toward the more abstract layers. The infrastructure depends on the domain (to implement domain interfaces), not the other way around. The flow of compile-time dependency goes from concrete to abstract.
What About "Abstractions Should Not Depend on Details"?
The second part of the principle is about how the abstractions are designed:
Abstractions should not depend on details. Details should depend on abstractions.
When you write an interface, its methods, parameters, and return types should be in terms of domain concepts - not in terms of specific technology choices.
// Bad - abstraction depends on details
public interface OrderRepository {
void saveToOracle(Order order); // Oracle-specific
void saveToFile(Order order, Path path); // File-specific
ResultSet findByCustomer(String customerId); // JDBC-specific
String findOrderAsJson(String id); // JSON-specific
}
// Good - abstraction is in domain terms
public interface OrderRepository {
void save(Order order);
List<Order> findByCustomer(String customerId);
Optional<Order> findById(String id);
}
The good version's method names, parameter types, and return types are all domain concepts (Order, customer ID, optional). The bad version leaks JDBC, Oracle, JSON, file system - implementation details that should be hidden.
When the abstraction leaks details, switching implementations becomes hard. Replacing JDBC with MongoDB doesn't just mean a new implementation class - it means changing the interface to remove the JDBC-specific concepts. And changing the interface ripples to every client.
The discipline: when defining abstractions, ask "is this method's signature in terms of what the operation does, or how it's implemented?" The signature should be about what.
How DIP Enables Testing
The most immediate practical benefit of DIP: testing without infrastructure.
// Without DIP - OrderService can't be tested without a real database
@Test
void testPlaceOrder() {
JdbcOrderRepository realRepo = new JdbcOrderRepository(realDataSource);
SmtpEmailSender realEmail = new SmtpEmailSender("smtp.example.com");
OrderService service = new OrderService(realRepo, realEmail);
service.placeOrder(testOrder);
// Now we need to query the real database to verify
}
// With DIP - OrderService can be tested with in-memory implementations
@Test
void testPlaceOrder() {
InMemoryOrderRepository fakeRepo = new InMemoryOrderRepository();
RecordingOrderNotifier fakeNotifier = new RecordingOrderNotifier();
OrderService service = new OrderService(fakeRepo, fakeNotifier);
service.placeOrder(testOrder);
assertThat(fakeRepo.savedOrders()).contains(testOrder);
assertThat(fakeNotifier.sentNotifications()).contains(testOrder);
}
The DIP version doesn't need a database, an SMTP server, or working mocks of either. In-memory test doubles satisfy the abstractions and let the business logic be exercised in isolation.
This testability is one of the strongest practical arguments for DIP. Tests that don't require infrastructure are faster (no I/O), more reliable (no flaky network), more focused (one concern per test), and easier to reason about.
How DIP Enables Substitution
The second practical benefit: substitution.
// Different implementations for different environments
public class OrderServiceConfiguration {
public OrderService productionOrderService() {
return new OrderService(
new JdbcOrderRepository(productionDataSource()),
new EmailOrderNotifier(productionSmtpClient())
);
}
public OrderService developmentOrderService() {
return new OrderService(
new InMemoryOrderRepository(),
new ConsoleOrderNotifier()
);
}
public OrderService testOrderService() {
return new OrderService(
new InMemoryOrderRepository(),
new RecordingOrderNotifier()
);
}
}
The same OrderService works in production, development, and tests with different infrastructure. The business logic is environment-independent; the wiring chooses the appropriate concrete implementations.
The same pattern enables migrations: switching from JDBC to MongoDB is a new implementation of OrderRepository, plus a wiring change. The business logic doesn't know the difference.
How DIP Enables Plugin Architectures
Taking substitution further, DIP enables systems where the implementation can be loaded at runtime:
public class OrderServiceFactory {
public OrderService create(String repositoryType) {
OrderRepository repository = switch (repositoryType) {
case "jdbc" -> new JdbcOrderRepository(/* ... */);
case "mongo" -> new MongoOrderRepository(/* ... */);
case "memory" -> new InMemoryOrderRepository();
default -> throw new IllegalArgumentException(repositoryType);
};
return new OrderService(repository, /* ... */);
}
}
A factory selects the implementation based on configuration. The same OrderService works with any of them. Going further, the implementations could be plugins loaded via ServiceLoader (Topic 9.3) - discovered at runtime without the factory knowing about them at compile time.
This is the foundation of plugin systems, hexagonal architecture's port-and-adapter pattern, and Clean Architecture's outer layers.
DIP and Frameworks
Modern frameworks like Spring and Guice are essentially DIP-implementation machines. They:
- Manage the construction of concrete implementations.
- Wire abstractions to implementations based on configuration or annotations.
- Inject the appropriate implementations into the classes that depend on abstractions.
@Service
public class OrderService {
private final OrderRepository repository;
private final OrderNotifier notifier;
public OrderService(OrderRepository repository, OrderNotifier notifier) {
this.repository = repository;
this.notifier = notifier;
}
}
@Repository
public class JdbcOrderRepository implements OrderRepository { /* ... */ }
@Component
public class EmailOrderNotifier implements OrderNotifier { /* ... */ }
Spring sees the @Service, @Repository, @Component annotations and constructs the wiring. OrderService is constructed with JdbcOrderRepository and EmailOrderNotifier injected, because those are the available implementations of the required interfaces.
The framework handles the wiring; DIP gives the design pattern that makes the wiring possible. Without abstractions to wire, the framework couldn't do anything useful.
Topic 12.4 covers IoC containers in detail.
DIP at Different Scales
DIP applies at every architectural scale:
Class level. A class depends on interfaces of its collaborators, not on the concrete classes.
Module level. A module depends on interfaces in other modules, not on the concrete implementations.
System level. A service depends on contracts (APIs, schemas) of other services, not on their internal implementations.
Cross-organization. Open standards (HTTP, SQL, S3 API) are abstractions that multiple implementations can fulfill. Code depending on the standard, not on a specific provider, is DIP at the largest scale.
The principle scales because the underlying insight scales: dependencies should be on what's stable (abstractions), not on what's volatile (implementations). Implementations change; abstractions ideally don't.
Why This Matters in Interviews
Interview: DIP is the most architecturally significant SOLID principle and appears throughout senior interviews:
Direct probes:
- "What is the Dependency Inversion Principle?" - Testing whether you can state both forms.
- "What gets inverted?" - Testing whether you understand the direction-flip.
- "How does this enable testing?" - Testing the practical application.
Implicit evaluation: When proposing architecture during a system design, the interviewer watches whether you separate domain from infrastructure, whether you propose abstractions for external dependencies, and whether the dependency direction goes from concrete to abstract.
Common follow-up probes:
- "How would you test this?" - Testing whether you've designed for testability.
- "What if we wanted to swap the database?" - Testing whether the design isolates that change.
- "Where does this interface belong?" - Testing whether you understand abstraction ownership.
A strong articulation: "The OrderService depends on OrderRepository and OrderNotifier interfaces, both defined in the domain layer. The concrete implementations live in infrastructure. To test OrderService, I inject in-memory implementations. To swap the database, I write a new OrderRepository implementation - the domain doesn't change. The principle is that high-level business logic shouldn't depend on low-level infrastructure; both should depend on abstractions, and the abstractions live with the high-level code."
Common Mistakes
Warning: Common DIP failures:
Abstractions in the wrong place. Defining
OrderRepositoryin the infrastructure module instead of the domain. Now domain depends on infrastructure for the abstraction itself; the inversion isn't really there.Leaky abstractions. Interface methods that expose implementation details (return types, parameter types tied to specific technologies). The abstraction isn't really abstract.
Concrete dependencies despite interfaces. A class with an
OrderRepositoryfield but also a directJdbcDataSourcefield. The interface didn't help because there's a parallel concrete dependency.Heavy framework coupling. A "DIP" architecture that's so tied to a specific framework (Spring's annotations, Guice's modules) that you can't use it without the framework. The principle is being applied to specific code but the architecture as a whole still has heavy compile-time dependencies.
Treating DIP as "always use interfaces." Not every concrete class needs an interface. DIP says depend on abstractions; some "concrete" classes are themselves abstract enough not to need further abstraction.
Try It
Exercise: For each scenario, identify whether DIP is being violated and propose a corrective design:
An
OrderProcessorthat imports and usesHibernateOrderDao,JavaMailSender, andLog4jLoggerdirectly.A
PaymentServicewith a field of typeStripeClient. The class can't be tested without a real Stripe API key.A
Cache<K, V>interface with methodvoid putWithTtl(K key, V value, long ttlMilliseconds). The interface includes Redis-specific concepts.A web controller that creates a
new DatabaseConnection(jdbcUrl)inside its request handler.A domain class that imports
org.springframework.beans.factory.annotation.Autowired.For each:
- Identify the DIP violation (direction of dependency, leaky abstraction, etc.).
- Propose the corrective: what interface should exist? Where should it be defined? What concrete classes implement it?
- Trace the new dependency direction.
Then look at a class you've worked on. List its imports. Which ones are domain abstractions vs infrastructure concretions? Is the class respecting DIP? If not, what would the refactor look like?