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 CalculatorBrain.

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 CalculatorBrain changes, all the modules need to be rebuilt. At this point, some might create a new module called AnyCalculatorBrain, 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, CalculatorBrain could implement the methods; however, then the accessor to the default implementation needs to live inside CalculatorBrain, which means the dependency should be inverted, i.e., CalculatorBrain should depend on AnyCalculatorBrain 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 AnyCalculatorBrain can see CalculatorBrain, it won’t be able to access the default implementation. However, adding CalculatorBrain as a dependency to AnyCalculatorBrain causes a Circular Dependency since now CalculatorBrain depends on AnyCalculatorBrain to see the protocol and AnyCalculatorBrain needs to import CalculatorBrain 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 CalculatorBrain:

public let calculatorBrain: any CalculatorBrainInterface = {
  let type = NSClassFromString("CalculatorBrain.DefaultCalculatorBrain") as! CalculatorBrainInterface.Type
  return type.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?
}

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