Let Dependency Injection Lead You to a Better Design
Dependency Injection (DI) is a powerful design technique that turns hidden, hard-to-control dependencies into explicit components that can be passed into a class or method. By making dependencies injectable, our code gains several advantages:
- Testability - Unit tests become easier to write because we can provide mock or stub versions of dependencies instead of relying on techniques like method swizzling.
- Flexibility - Swapping implementations (e.g. a disk cache for some network calls vs an ephemeral cache for other network calls) requires no code changes, just different injected dependencies.
- Maintainability - Explicit dependencies make code easier to understand, as we can see exactly what a class or method relies on from its interface.
- Decoupling - By breaking direct ties to specific implementations, our system becomes less brittle. Decoupled code reduces the risk of cascading changes when refactoring and makes introducing new features safer.
In short, Dependency Injection encourages a more modular, testable, adaptable design that can evolve with our application over time.
I always think of automated testing as a safety net; the larger I can make that safety net, the more comfortable I am with what I've written.
Dependency Injection
is a great way to expand that safety net.
Dependency Injection Forms
Dependency Injection
takes three forms:
- Initialiser Injection - Use init'er parameters to require dependencies before an instance of the type can be initialised.
- Property Injection - Use properties to set optional dependencies after instance initialisation.
- Method Injection - Use method parameters to require dependencies to be provided before the operation of the method can be performed.
Together, we are going to refactor a class to utilise the three forms of Dependency Injection
, resulting in a more decoupled design that is easier to test as it is explicit about its dependencies. Let's look at that class:
@interface WBAccount : NSObject
@property (nonatomic, strong) WBUser *user;
@end
@implementation WBAccount
#pragma mark - Init
- (instancetype)init
{
self = [super init];
if(self)
{
self.user = [WBUser localUser]; //a class method to retrieve the locally signed-in user
}
return self;
}
#pragma mark - Save
- (void)saveToCache
{
[[WBCache sharedInstance] saveAccount(self)];
[[WBLogger sharedInstance] accountSaved(self)];
}
@end
WBAccount
is simple: it holds a local user and provides a way to save that user to cache.
However, that simplicity is sitting uneasily - it feels like WBAccount
owns the responsibility of creating its own dependencies. With Dependency Injection
, we will flip this ownership model and instead have WBAccount
merely receive its dependencies. The result of this ownership flip will be a more straightforward version of WBAccount
without the unease.
Now let's start its transformation.
Initialiser Injection
As mentioned, Initialiser Injection
is where a dependency is passed in as a parameter during the initialisation of an object. Let's look at the initialiser of WBAccount
:
@implementation WBAccount
// Omitted other code
- (instancetype)init
{
self = [super init];
if(self)
{
self.user = [WBUser localUser]; //a class method to retrieve the locally signed-in user
}
return self;
}
@end
In the above code snippet, we can see that WBAccount
is dependent upon the WBUser
class. There's nothing wrong with having WBAccount
dependent on WBUser
. One type depending on another happens all the time, but let's delve deeper. WBAccount
isn't only concerned with using a WBUser
instance, WBAccount
is also responsible for retrieving the local WBUser
instance itself. This doesn't feel right as it violates the design principle: separation of concerns. If in the future we wanted to tweak WBAccount
to work with another WBUser
instance, we would need to modify WBAccount
itself. Let's tweak WBAccount
to remove the responsibility of retrieving its WBUser
instance and so make future tweaks easier by using dependency injection to pass that WBUser
instance in as a parameter during initialisation:
@implementation WBAccount
- (instancetype)initWithUser:(WBUser *)user
{
self = [super init];
if(self)
{
self.user = user;
}
return self;
}
@end
With this code change, WBAccount
no longer has to care about which WBUser
instance or how that instance is created, WBAccount
only cares that it always has a WBUser
instance to work with. By using Initialiser Injection
, we have reduced the responsibilities of WBAccount
.
Property Injection
As mentioned, Property Injection
is where a dependency is passed in as a property's value. Inside the saveToCache
method of WBAccount
, we currently use WBLogger
to log that a save operation has been completed. WBLogger
feels like an optional dependency, so let's use Property Injection
to be explicit about that:
@interface WBAccount : NSObject
// Omitted other code
@property (nonatomic, weak) WBLogger *logger;
@end
@implementation WBAccount
// Omitted other code
#pragma mark - Save
- (void)saveToCache
{
// Omitted other code
[logger accountSaved(self)];
}
@end
After an instance of WBAccount
is created, this logger
property is set. WBAccount
is no longer concerned with the creation of a WBLogger
instance; WBAccount
is only concerned with what WBLogger
can do. By using Property Injection
, we have reduced the responsibilities of WBAccount
.
Method Injection
As mentioned before, Method injection
is where a dependency is passed to a method so that the method can utilise it to fulfil its operation. Inside the saveToCache
method, we currently retrieve the WBCache
. Let's instead use Method Injection
to pass the cache in:
Take the following example of a method:
@implementation WBAccount
// Omitted other code
#pragma mark - Save
- (void)saveToCache(WBCache *)cache
{
[cache saveAccount(self)];
// Omitted other code
}
@end
In the above code snippet, we can see that the saveToCache
method is now dependent on receiving a WBCache
object to store the account in the cache. This increased flexibility allows us to swap out a WBCache
instance with another instance in the production target or a test-double in the test target. By using Method Injection
, we have reduced the responsibilities of saveToCache
.
Special note: Dealing with Singletons
The singleton pattern creates a single, shared instance of a class that's accessible globally, making it ideal for managing centralised state or configuration. The direct use of singletons is a primary candidate for the Dependency Injection forms described above. The direct use of a singleton is a code smell that Dependency Injection
can help to remove.
Take the following example of a method:
- (NSString *)welcomeMessage
{
return [NSString stringWithFormat:@"Welcome to the %@ service", [WBBrandManager sharedInstance].serviceName];
}
In the above code snippet, like in all the others, we see an implicit dependency which can be broken down by:
- (NSString *)welcomeMessageWithBrand:(NSString *)brand
{
return [NSString stringWithFormat:@"Welcome to the %@ service", brand];
}
Here, we have used Method Injection
to not only remove the singleton from the method body but also to remove any dependency on the WBBrandManager
type; instead, we are now using the standard Foundation
framework class NSString
, which has resulted in a more straightforward and flexible implementation. This change will also lead to simpler unit tests. In fact, the more unit tests that you write, the more powerful Dependency Injection
as a technique becomes.
Won't this lead to more complexity?
Now, the above example is small and the changes straightforward, and you might be wondering:
"What happens when a class starts to accumulate four, five, or even six dependencies? Won't this just lead to more complexity not less?"
At first glance, it may seem that dependency injection adds unnecessary complexity. In reality, this is usually a signal that the class or method is doing too much and is violating the Single Responsibility Principle. The solution isn't to abandon dependency injection but to reconsider the design and break the code into smaller, more focused units. In other words, dependency injection doesn't create complexity - it reveals it.
By making dependencies explicit, we make the true shape of our system visible. Hidden dependencies create traps for future developers, where changes in one place unexpectedly break behaviour elsewhere. Explicit dependencies, on the other hand, surface design problems early and encourage the development of modular, testable, and maintainable code.