Code & Craft by Kasra

Faster Builds by Resolving Protocol Implementations at Runtime

As iOS projects grow, modularization is key to keeping builds fast and dependencies clean. Modularization helps large iOS projects scale by keeping code organized, validating dependencies, for instance, logic modules shouldn’t depend on UI modules, separated test suites, and speeding up incremental builds. When modules are isolated, small changes only rebuild what’s necessary, leading to faster iteration and shorter feedback loops.

Problem

Although modularization solves a lot of issues, it requires a scalable dependency injection approach.

Let’s say we’re building a Calculator app where the CalculatorUI contains the UI views and view models, and some of the view models depend on the business logic (mathematic operations) that live in the CalculatorBrainInterface.

This separation means if something in the CalculatorUI gets updated, Xcode will only build that module and everything that depends on it (only the app target in this example).

However, when the implementation of some method in CalculatorBrainInterface changes, all the modules need to be rebuilt. At this point, some might create a new module called CalculatorBrainImplementation, which only contains a public interface:

public protocol CalculatorBrainInterface {
  static var `default`: any CalculatorBrainInterface { get }

  func add(_ lhs: Double, _ rhs: Double) -> Double
  func subtract(_ lhs: Double, _ rhs: Double) -> Double
  func multiply(_ lhs: Double, _ rhs: Double) -> Double
  func divide(_ lhs: Double, _ rhs: Double) -> Double?
}

Next, CalculatorBrainImplementation could implement the methods; however, then the accessor to the default implementation needs to live inside CalculatorBrainInterface, which means the dependency should be inverted, i.e., CalculatorBrainInterface should depend on CalculatorBrainImplementation in order to see the protocol. This means if the implementation changes, even though the interface hasn’t changed, all the modules need to be rebuilt.

Solution

Ideally, we’d like the dependencies to be as depicted below, where the default implementation depends on the interface; however, the accessor to the default implementation should live inside the interface module so that as long as the interface hasn’t changed, only the module with the default implementation gets rebuilt.

The problem is that unless CalculatorBrainInterface can see CalculatorBrainImplementation, it won’t be able to access the default implementation. However, adding CalculatorBrainInterface as a dependency to CalculatorBrainImplementation causes a Circular Dependency since now CalculatorBrainInterface depends on CalculatorBrainImplementation to see the protocol and CalculatorBrainImplementation needs to import CalculatorBrainInterface as a dependency to initialize and use the concrete type in its public accessor.

The solution is to resolve the default implementation at runtime, without importing CalculatorBrainInterface:

// Inside the CalculatorBrainInterface module

public let defaultCalculatorBrain: any CalculatorBrainInterface = {
  let implementationType = NSClassFromString("CalculatorBrainImplementation.DefaultCalculatorBrain") as! CalculatorBrainInterface.Type

  return implementationType.default
}()

public protocol CalculatorBrainInterface {
  static var `default`: any CalculatorBrainInterface { get }

  func add(_ lhs: Double, _ rhs: Double) -> Double
  func subtract(_ lhs: Double, _ rhs: Double) -> Double
  func multiply(_ lhs: Double, _ rhs: Double) -> Double
  func divide(_ lhs: Double, _ rhs: Double) -> Double?
}

and inside our CalculatorBrainImplementation, we can provide a concrete implementation:

// Inside the CalculatorBrainImplementation module
import CalculatorBrainInterface

final class DefaultCalculatorBrain: CalculatorBrainInterface {
  static var default: any CalculatorBrainInterface {
    DefaultCalculatorBrain()
  }

  func add(_ lhs: Double, _ rhs: Double) -> Double { /* ... */ }
  func subtract(_ lhs: Double, _ rhs: Double) -> Double { /* ... */ }
  func multiply(_ lhs: Double, _ rhs: Double) -> Double { /* ... */ }
  func divide(_ lhs: Double, _ rhs: Double) -> Double? { /* ... */ }
}

What’s happening here:

In other words, the process for getting the concrete type that conforms to the protocol is hidden.

While there’s various ways of doing dependency injection, many projects use a container-based approach, such as Factory, where a central component is responsible for registering the abstractions and their concrete implementations. In some contexts, this is known as the Service Locator pattern:

A design pattern used in software development to encapsulate the processes involved in obtaining a service with a strong abstraction layer.

This pattern has been adopted by many companies. For instance, the engineers at Lyft created a lightweight service locator which was explained in details by Scott Berrevoets:

Simply a central accessor, takes the abstraction (in this case, the protocol) as an argument and provides a service instance, while the actual implementation, thanks to NSClassFromString(_:) is discovered at runtime.

Although this part is out of the scope of this article, the logic for obtaining the concrete type using NSClassFromString(_:) could be encapsulated by the container. Then, with some extra steps, could automate obtaining the string representation of the default implementation type’s name and the module that contains it to eliminate the need for hardcoding it.

Trade-offs

This approach comes with both benefits and costs.

Pros

Cons