Dependency Injection + Dependency Inversion: More Robust and Testable Code
Modern applications evolve quickly: features are added, platforms multiply, and teams scale. In this environment, tightly coupled code becomes a bottleneck for change and a source of fragile tests. Two key ideas help us fight this complexity:
- Dependency Inversion Principle (DIP) from SOLID: high-level modules should not depend on low-level modules. Both should depend on abstractions.
- Dependency Injection (DI): a technique and set of tools to provide those dependencies from the outside instead of constructing them internally.
In this post, we’ll see why DIP matters, how DI enforces it, and how using a DI framework makes your code more robust and testable—with Kotlin-flavored examples that apply equally to other languages.
Dependency Inversion in a Nutshell (the “D” in SOLID)
DIP encourages designing around abstractions (interfaces) rather than concrete implementations. The goal is to decouple business logic (high-level) from infrastructure details (low-level):
- High-level modules depend on interfaces they define or control
- Low-level modules implement those interfaces
- Composition happens at the boundaries (app startup, DI container, factories)
This reduces ripple effects of change and makes substitution and testing straightforward.
|
|
What Dependency Injection Adds
DI is the practice of providing concrete implementations to components from the outside. Instead of constructing dependencies with new
(or constructors) inside a class, we accept them via constructor parameters (or setters), and a composition root (often a DI framework) wires the graph. Benefits:
- Decoupling: classes depend on interfaces, not on how to create them
- Testability: dependencies can be replaced with fakes/mocks easily
- Single Responsibility: classes focus on behavior, not object creation
- Replaceability: swap implementations without touching consumers
|
|
No CheckoutService
knowledge about Stripe, PayPal, or HTTP clients—only the abstraction.
Before vs After: A Concrete Example
Without DIP/DI (tightly coupled):
|
|
Problems:
- Hard to unit test (requires network or heavy mocking tools)
- Changing provider (Stripe -> PayPal) touches this class
- Violates SRP by mixing construction and business rules
With DIP + DI:
|
|
Composition (using any DI style):
|
|
Test becomes trivial:
|
|
Using a DI Framework: Why It Helps
As your application grows, manual wiring becomes error-prone. DI frameworks act as a composition engine:
- Centralize object graphs and lifecycles (singleton, scoped, transient)
- Enforce explicit dependencies (constructor injection)
- Provide compile-time or runtime validation
Common options by ecosystem:
- Kotlin/Android: Hilt/Dagger (compile-time, annotations), Koin (DSL), Kodein
- JVM/backend: Spring Framework/Spring Boot (annotations), Guice
- Multiplatform: Koin works for KMP; some projects use manual composition or Service Locator for shared code
Example with Koin (simple DSL):
|
|
Example with Hilt (Android):
|
|
Robustness and Testability in Practice
- Smaller blast radius: infrastructure changes don’t cascade through business logic
- Deterministic tests: inject fakes; no hidden singletons or global state
- Clear boundaries: interfaces model the seams of your system
- Easier maintenance: constructor parameters make dependencies visible and reviewable
Checklists:
- Do my high-level modules depend only on abstractions they own?
- Can I swap implementations without editing consumers?
- Are constructors the only way dependencies enter my types?
- Can I unit test without network, disk, or threads?
Common Pitfalls and How to Avoid Them
- Service Locator antipattern: avoid pulling dependencies from a global registry inside classes; prefer constructor injection
- God modules: split DI modules by feature/boundary to avoid giant graphs
- Over-abstraction: don’t create interfaces without alternate implementations or test value
- Hidden state: avoid static singletons; prefer DI-managed lifecycles
Conclusion
Applying the Dependency Inversion Principle and adopting Dependency Injection yields code that is easier to maintain, extend, and test. Start small: define interfaces for your boundaries, inject dependencies via constructors, and gradually introduce a DI framework as your graph grows. The payoff is significant—cleaner architecture, faster tests, and safer change.