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:
NSClassFromString
looks up the type by its module and class name.
CalculatorBrainInterface
doesn’t need to know about CalculatorBrain
at compile time.
- The circular dependency disappears.
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
- 🚀 Faster builds: implementation changes don’t trigger rebuilds of interface consumers.
- ✅ Cleaner boundaries: interfaces remain stable and implementation details stay
isolated.
- 🔄 Flexibility: implementations can be swapped at runtime (e.g., for testing
or feature flags).
Cons
- ⚠️ Runtime safety: if the class name changes or isn’t linked, you’ll hit
a runtime error.
- 🛠 Weaker tooling: refactoring tools won’t catch typos in the string literal.
Although this can be automated, for instance, by using project management
tools such as XcodeGen, Tuist, or Bazel.
- ⏱ Lookup overhead: reflection introduces a tiny runtime cost, though
negligible in most cases.