electrical-engineering-principles
How to Use Dependency Inversion to Support the Open/closed Principle
Table of Contents
Introduction to the Open/Closed Principle
The Open/Closed Principle (OCP) is one of the five SOLID principles of object-oriented design. It states that software entities—classes, modules, functions—should be open for extension but closed for modification. In practice, this means you can add new behavior to a system without altering existing, tested code. The principle reduces the risk of introducing bugs when evolving a codebase and encourages a robust, modular architecture.
Without OCP, every new feature often forces changes deep inside existing components, creating a ripple effect of modifications. This leads to fragile systems where a small update can break unrelated functionality. By designing for extension rather than modification, teams can safely add features, replace implementations, and scale their applications with confidence.
The Role of Dependency Inversion in Supporting OCP
Dependency Inversion is the D in SOLID and directly enables the Open/Closed Principle. While OCP sets the what—open for extension, closed for modification—Dependency Inversion provides the how. It flips the traditional dependency flow: high-level policy modules should not depend on low-level detail modules. Instead, both should depend on abstractions (interfaces or abstract classes). Abstractions should not depend on details; details should depend on abstractions.
This reversal decouples the core business logic from concrete implementations. When you need to add a new behavior, you introduce a new concrete class that implements the existing abstraction, leaving the high-level modules untouched. The system is extended, not modified. Without Dependency Inversion, high-level classes directly instantiate low-level classes, making it impossible to introduce new variants without changing the high-level code.
How Abstractions Shield Change
Consider a payment processing module. The high-level order service should not care whether the payment is made via credit card, PayPal, or a bank transfer. If the order service depends directly on a CreditCardProcessor, adding a PayPal option forces you to modify the order service. Instead, by defining a PaymentProcessor interface, the order service depends on the abstraction. New payment methods implement the interface, and the order service remains unchanged. This is OCP in action, enabled by Dependency Inversion.
Core Mechanisms of Dependency Inversion
To implement Dependency Inversion effectively, you need three core practices: abstraction definition, dependency injection, and inversion of control containers.
1. Defining Abstractions
Identify the boundaries in your system where variation is likely. Create interfaces or abstract classes that represent contracts for behavior. These abstractions should be defined by the high-level module—they belong to the policy layer, not the implementation layer. For example, a reporting module defines an ReportExporter interface; the concrete PDF, CSV, and JSON exporters depend on that interface.
2. Dependency Injection (DI)
Dependency injection is the most common technique for supplying concrete implementations to high-level modules. The high-level class receives its dependencies through constructor parameters, setter methods, or method parameters, rather than creating them internally. Constructor injection is preferred because it makes dependencies explicit and enforces that the object is fully initialized.
public class OrderService {
private final PaymentProcessor processor;
public OrderService(PaymentProcessor processor) {
this.processor = processor;
}
public void processOrder(Order order) {
processor.charge(order.getAmount());
}
}
Now the OrderService is closed for modification—you never change it to add a new payment method. It is open for extension because you can pass any implementation of PaymentProcessor.
3. Inversion of Control Containers
In larger applications, managing dependencies manually becomes tedious. IoC containers (such as Spring in Java, Laravel’s service container in PHP, or Microsoft’s DI container in .NET) automate the wiring. You configure bindings between interfaces and implementations, and the container resolves the dependency chain at runtime. This further supports OCP by centralizing configuration: swapping implementations requires only a configuration change, not code modification.
Practical Example: Notification System
Let’s revisit the notification example from the original article and expand it into a real-world scenario. Imagine a SaaS application that needs to notify users about account activity via email, SMS, or push notifications. The high-level notification service must be able to handle any current and future channel without modification.
Step 1: Define an abstraction
public interface NotificationChannel {
boolean send(String recipient, String subject, String body);
}
Step 2: Create concrete implementations
public class EmailChannel implements NotificationChannel {
private final EmailService emailService;
public EmailChannel(EmailService emailService) {
this.emailService = emailService;
}
@Override
public boolean send(String recipient, String subject, String body) {
// implementation
}
}
public class SmsChannel implements NotificationChannel {
private final SmsProvider smsProvider;
public SmsChannel(SmsProvider smsProvider) {
this.smsProvider = smsProvider;
}
@Override
public boolean send(String recipient, String subject, String body) {
// implementation
}
}
Step 3: High-level service depends on abstraction
public class NotificationService {
private final List<NotificationChannel> channels;
public NotificationService(List<NotificationChannel> channels) {
this.channels = channels;
}
public void notifyAllChannels(String recipient, String subject, String body) {
for (NotificationChannel channel : channels) {
channel.send(recipient, subject, body);
}
}
}
Adding a Webhook channel or a Slack bot later requires only a new class implementing NotificationChannel and registering it in the IoC container. The NotificationService stays untouched. This is OCP achieved via Dependency Inversion.
In Action with Directus
Directus, a headless CMS built on Laravel, extensively uses dependency injection and interface-based designs. For example, the file storage system defines a StorageAdapter interface. You can extend Directus by writing a new adapter for Amazon S3, Google Cloud Storage, or a local disk, and the core file management code never needs modification. You are extending the system rather than altering its internals. Learn more about Directus extensions and how they leverage dependency inversion.
Benefits Beyond OCP
While Dependency Inversion primarily supports the Open/Closed Principle, it brings additional advantages that improve overall software quality.
- Enhanced testability: Dependencies can be mocked or stubbed in unit tests. High-level components can be tested in isolation without instantiating real databases, network services, or file systems. This leads to faster, more reliable tests.
- Reduced coupling: Changes in low-level implementations do not cascade into the high-level policy code. Teams can work on different implementations in parallel without merge conflicts.
- Improved maintainability: Because abstractions are well-defined, the codebase becomes more modular. New developers can understand the system by examining interfaces, then dive into concrete details as needed.
- Parallel development: Once the interface is agreed upon, a team can develop the high-level module while another team builds the concrete implementations, provided they respect the contract.
Common Pitfalls and How to Avoid Them
Applying Dependency Inversion incorrectly can lead to over-engineering or a false sense of decoupling. Here are frequent mistakes and their remedies.
1. Over-Abstraction
Creating an interface for every single class, no matter how unlikely it is to vary, adds unnecessary complexity. Only abstract where you anticipate change or polymorphism. A rule of thumb: if you have only one concrete implementation and no planned alternative, consider starting without an interface. You can extract one later when a second implementation appears. Refactoring tools make this safe.
2. Inconsistent Abstractions
Interfaces should be cohesive and focused. A UserRepository interface should not include methods for sending emails. When an interface becomes a “god object,” high-level modules depend on functionality they never use, violating the Interface Segregation Principle (the I in SOLID). Keep abstractions small and role-specific.
3. Leaking Implementation Details into Abstractions
An abstraction that exposes implementation-specific parameters (like config keys or environment variables) ties the high-level code to low-level concerns. For example, if your PaymentProcessor interface requires a merchantId parameter that only matters for credit cards, then any new payment method (like a digital wallet) must also accept that parameter, causing coupling. Instead, use parameter objects or allow the implementation to obtain its own configuration.
4. Ignoring the Environment
Dependency injection must be set up correctly in the composition root (the entry point of the application). If you use service location (anti-pattern) or forget to register a dependency, you get runtime errors. Use automated tests and linters to verify that all dependencies can be resolved. Containers often throw clear exceptions when bindings are missing.
Dependency Inversion vs. Dependency Injection
These terms are often confused. Dependency Inversion is a design principle that dictates the direction of dependencies. Dependency Injection is a technique to supply dependencies to a class. You can implement Dependency Inversion without Dependency Injection (by using factory patterns or service locators, albeit less cleanly). However, DI is the most practical and widely adopted method to realize the principle. In other words, DI is a means to achieve DIP and thus OCP.
Do not equate the two. A system might use DI but still violate DIP if the high-level module depends on a concrete class that is injected. For example, injecting EmailSender directly violates DIP because the abstraction is missing. The injected type should be an interface or abstract class. Always inject abstractions, not concretions.
Testing in an Inverted-Dependency World
One of the biggest wins of Dependency Inversion is effortless unit testing. Because high-level modules depend only on interfaces, you can provide mock implementations in your tests. The NotificationService can be tested with a FakeChannel that records calls:
public class FakeChannel implements NotificationChannel {
public List<String> sentMessages = new ArrayList<>();
@Override
public boolean send(String recipient, String subject, String body) {
sentMessages.add(recipient + ": " + subject);
return true;
}
}
In your test:
@Test
public void testNotifyAllChannels() {
FakeChannel channel1 = new FakeChannel();
FakeChannel channel2 = new FakeChannel();
NotificationService service = new NotificationService(List.of(channel1, channel2));
service.notifyAllChannels("[email protected]", "Hello", "World");
assertThat(channel1.sentMessages).hasSize(1);
assertThat(channel2.sentMessages).hasSize(1);
}
No network calls, no configuration, just pure isolated logic. This makes tests fast, deterministic, and easy to maintain.
Real-World Application: Data Access Layers
A typical enterprise application uses OCP and Dependency Inversion at its data access layer. High-level business services depend on a repository interface (CustomerRepository), not on a specific ORM or database driver. When the team decides to migrate from MySQL to PostgreSQL, they write a new implementation of CustomerRepository and swap the binding in the IoC container. The business logic remains unchanged. This is a textbook example of extension without modification.
For a deeper dive into repository patterns and dependency inversion, see Martin Fowler’s article on Repository pattern and the official SOLID principles guide on Wikipedia.
Conclusion
Dependency Inversion is not merely a theoretical nicety—it is a powerful tool that directly supports the Open/Closed Principle. By ensuring that high-level modules depend on abstractions and that those abstractions are owned by the high-level policy, you create systems that can grow without incurring constant modification costs. Combined with proper dependency injection and IoC containers, teams can build flexible, testable, and maintainable software architectures.
The key takeaway: when you sense that a change in one area forces changes in many others, look for natural boundaries. Define interfaces at those boundaries, invert the dependencies, and let the high-level code be a stable core around which concrete implementations plug in. This is how you honor the Open/Closed Principle in practice.
For additional reading, explore the Laravel documentation on service containers and how they facilitate dependency inversion, or check out Directus’s own configuration system for examples of extensible design.