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 UTEAccount : NSObject
@property (nonatomic, strong) UTEUser *user;
@end
@implementation UTEAccount
#pragma mark - Init
- (instancetype)init
{
self = [super init];
if(self)
{
self.user = [UTEUser localUser]; //a class method to retrieve the locally signed-in user
}
return self;
}
#pragma mark - Save
- (void)saveToCache
{
[[UTECache sharedInstance] saveAccount(self)];
[[UTELogger sharedInstance] accountSaved(self)];
}
@end
UTEAccount
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 UTEAccount
owns the responsibility of creating its own dependencies. With Dependency Injection
, we will flip this ownership model and instead have UTEAccount
merely receive its dependencies. The result of this ownership flip will be a more straightforward version of UTEAccount
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 UTEAccount
:
@implementation UTEAccount
// Omitted other code
- (instancetype)init
{
self = [super init];
if(self)
{
self.user = [UTEUser localUser]; //a class method to retrieve the locally signed-in user
}
return self;
}
@end
In the above code snippet, we can see that UTEAccount
is dependent upon the UTEUser
class. There's nothing wrong with having UTEAccount
dependent on UTEUser
. One type depending on another happens all the time, but let's delve deeper. UTEAccount
isn't only concerned with using a UTEUser
instance, UTEAccount
is also responsible for retrieving the local UTEUser
instance itself. This doesn't feel right as it violates the design principle: separation of concerns. If in the future we wanted to tweak UTEAccount
to work with another UTEUser
instance, we would need to modify UTEAccount
itself. Let's tweak UTEAccount
to remove the responsibility of retrieving its UTEUser
instance and so make future tweaks easier by using dependency injection to pass that UTEUser
instance in as a parameter during initialisation:
@implementation UTEAccount
- (instancetype)initWithUser:(UTEUser *)user
{
self = [super init];
if(self)
{
self.user = user;
}
return self;
}
@end
With this code change, UTEAccount
no longer has to care about which UTEUser
instance or how that instance is created, UTEAccount
only cares that it always has a UTEUser
instance to work with. By using Initialiser Injection
, we have reduced the responsibilities of UTEAccount
.
Property Injection
As mentioned, Property Injection
is where a dependency is passed in as a property's value. Inside the saveToCache
method of UTEAccount
, we currently use UTELogger
to log that a save operation has been completed. UTELogger
feels like an optional dependency, so let's use Property Injection
to be explicit about that:
@interface UTEAccount : NSObject
// Omitted other code
@property (nonatomic, weak) UTELogger *logger;
@end
@implementation UTEAccount
// Omitted other code
#pragma mark - Save
- (void)saveToCache
{
// Omitted other code
[logger accountSaved(self)];
}
@end
After an instance of UTEAccount
is created, this logger
property is set. UTEAccount
is no longer concerned with the creation of a UTELogger
instance; UTEAccount
is only concerned with what UTELogger
can do. By using Property Injection
, we have reduced the responsibilities of UTEAccount
.
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 UTECache
. Let's instead use Method Injection
to pass the cache in:
Take the following example of a method:
@implementation UTEAccount
// Omitted other code
#pragma mark - Save
- (void)saveToCache(UTECache *)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 UTECache
object to store the account in the cache. This increased flexibility allows us to swap out a UTECache
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", [UTEBrandManager 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 UTEBrandManager
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.