Principle (restated)
The Dependency Inversion Principle says:
- 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.
In plain words: business code says what it needs through a fixed contract. Email, SMS, database, or vendor code then fits that contract. The contract should use business words and should not copy a vendor's API.
What the Good Code Does
Key components
NotificationChannelis the abstraction. It promisesvoid send(String msg)and does not say how the message travels.EmailServiceandSMSServiceare concrete, low-level classes that implement it.NotificationServiceis the high-level class. Its field type isNotificationChannel, notEmailServiceorSMSService.Mainis the composition point, the one place where objects are created and connected. It picks the implementation and passes it into the service. The realMainbuilds two services: one withEmailServiceand one withSMSService.
Code flow (conceptual)
Maincreates a concrete channel, such asnew EmailService().Mainpasses it to theNotificationServiceconstructor.NotificationServicestores it as aNotificationChannel.- When
notify(msg)runs, the service callsnotificationChannel.send(msg). - Java picks the real
sendmethod at runtime, based on the object that was passed in (runtime dispatch).
The service still reaches email or SMS at runtime. What changed is the source-code dependency: the service now imports the interface and not the detail.
Passing the dependency in through the constructor is dependency injection. The class receives what it needs from outside instead of building it.
How this satisfies DIP
- High-level code depends on an abstraction.
NotificationServicementions onlyNotificationChannel. - Low-level code also depends on that abstraction.
EmailServiceandSMSServiceimplement the interface. - The dependency arrows meet at the contract. The high-level class uses it and the details follow it.
- Implementations are replaceable. WhatsApp, push, or a test fake can be passed in without editing
NotificationService. - Construction sits at the edge.
Main, a factory, or a DI container chooses the concrete object.
The best version of this design keeps NotificationChannel next to the high-level business code. If it sat in a vendor package and took vendor-specific parameters, the business side would still have to speak the vendor's language.
Benefits (detailed)
- Extensibility / Open-Closed: add a channel by implementing
NotificationChannel. The service stays unchanged. - Testability: pass in a fake that records the message, so no real email or SMS goes out.
- Single Responsibility:
NotificationServiceruns the use case. The sender classes own the delivery details. - Maintainability: when an API or library changes, only the concrete class changes.
- Reuse and composition: the same service can run with different channels in different environments.
- Explicit dependencies: the constructor shows what the class needs before it can work.
A tiny fake keeps a test predictable. It stores the message passed to send, and the test checks that value. No credentials or waiting are needed.
Design choices and alternatives
- Constructor injection, used here, makes required dependencies visible and lets fields be
final. - Setter injection suits an optional dependency, but it allows a half-configured object.
- A factory or provider helps when implementations are expensive to create or chosen per request.
- A DI container can create and connect a large set of objects. It is convenient but not required for DIP.
- For several channels, inject
List<NotificationChannel>and loop, or inject a registry when the choice is made at runtime.
DIP and dependency injection are related but different. Injecting a concrete SMSService is DI, yet the high-level class still depends on a detail. Depending on NotificationChannel is the design decision that follows DIP.
Small checklist to recognize DIP compliance
- Does the high-level class depend on an interface shaped around its own need?
- Do concrete classes implement that interface instead of being created by the high-level class?
- Can a new implementation be added without changing the high-level module?
- Can a test substitute a fake without network, database, or clock setup?
- Does the business package avoid imports from the vendor package?
Several "yes" answers are a good sign. An interface alone does not prove DIP. Who owns it, what it is called, and which way the dependencies point still matter.
Example improvements you might consider later
- Make the channel field
finalso the required dependency cannot change after construction. - Rename
notify(String)tonotifyCustomer(String), becauseObject.notify()already has a well-known meaning in thread signalling. - Add a recipient value instead of sending only a message string.
- Use a registry when configuration or request data selects a channel.
- Add logging, retries, and error handling as wrappers so the transport classes stay focused.
- For asynchronous delivery, return a future or publish a notification request to a queue.
These are later improvements. You do not need them to understand the principle.
Conclusion
The refactor inverts the source dependency. NotificationService depends on the NotificationChannel abstraction, and the concrete senders implement it. Runtime calls still reach a real sender, but the business rule no longer knows which delivery technology does the work.
Do not create an interface for every class. The extra layer pays off around details that change, do I/O, or need test fakes. Pure calculations and stable value objects can stay concrete.
Compare with BadCode and read DIP-Notes.md for payment-gateway, clock, DI, and IoC examples.