Let Dependency Injection lead you to a better design

17 Dec 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 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.

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