Let Dependency Injection Lead You to a Better Design

18 May 2012 5 min read

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:

  1. 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.
  2. 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.
  3. Maintainability - Explicit dependencies make code easier to understand, as we can see exactly what a class or method relies on from its interface.
  4. 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.

Photo of a man walking down a path

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:

  1. Initialiser Injection - Use init'er parameters to require dependencies before an instance of the type can be initialised.
  2. Property Injection - Use properties to set optional dependencies after instance initialisation.
  3. 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.

What do you think? Let me know by getting in touch on Mastodon or Bluesky.