This dependency injection pattern uses native Swift language features to provide a safe, concise, deterministic, and intentional approach to dependency injection.
The primary Swift language feature that drives CPDI is called "protocol composition". This feature allows you to create a type alias from any combination of protocols. For example:
protocol Car {
func drive()
}
protocol Airplane {
func fly()
}
typealias Carplane = Car & Airplane
func start(vehicle: Carplane) {
vehicle.drive()
vehicle.fly()
}The above example shows how you can combine two protocols into a single type alias and then use that to describe a tangible requirement. Additionally, that type alias can be used as a requirement for another type alias, and so on. For example:
protocol Motorcycle {
func wheelie()
}
typealias Carplanecycle = Carplane & Motorcycle
func start(vehicle: Carplanecycle) {
vehicle.drive()
vehicle.wheelie()
vehicle.fly()
}The above example uses the Carplane type alias from the previous example and combines it effortlessly with the Motorcycle protocol.
Using this approach, we can provide every dependency to every part of a modularized app using a single parameter.
If we create protocols that describe something we depend on, we can compose those protocols together into a type alias in each file to describe that file's dependencies. For example:
protocol LoggingDependency {
var logger: Logging { get }
}
protocol TrackingDependency {
var tracker: Tracking { get }
}If the above dependency protocols are defined somewhere accessible to our file's code, we can use those dependency protocols like so:
typealias ThunkDependencies = LoggingDependency
& TrackingDependency
func thunk(dependencies: ThunkDependencies) {
dependencies.logger.logDebug("Thunk was called!")
dependencies.tracker.trackEvent(Events.thunk)
}Note: We recommend that you namespace your dependencies as shown above to avoid property and function name collisions in your dependencies. Instead of
dependencies.trackAddToCart(), it should bedependencies.tracker.trackAddToCart().
Note: One of the major benefits of CPDI is that it cannot crash at runtime because all dependencies are satisfied explicitly and cannot be accessed outside of their intended runtime scopes. This inherent safety puts the burden of proof on the engineer (in the form of type safety) instead of putting the burden of proof on the end-user (in the form of crashes). It is for this and several other reasons that CPDI is preferred over the many other dependency injection approaches and libraries.
In this simple example, we have a SwiftUI view that needs do to some tracking. We'll use CPDI to fulfill its requirements.
struct PDPView: View {
var body: some View {
VStack {
Text("Product Detail Page")
Button("Add to cart") {
// TODO: Track add to cart event
}
}
}
}To apply the CPDI pattern to PDPView, we add a Dependencies type to the top of the same file where the dependencies are being used. We are also careful to name the dependencies type using the exact name of the type whose requirements it satisfies: PDPViewDependencies. This location and naming of the dependencies type alias are critical for discoverability and maintainability. Following this pattern will help prevent any downstream confusion about the scope of a given type alias.
typealias PDPViewDependencies = TrackingDependency
struct PDPView<Dependencies: PDPViewDependencies>: View {
let dependencies: Dependencies
var body: some View {
VStack {
Text("Product Detail Page")
Button("Add to cart") {
dependencies.tracker.trackAddToCart()
}
}
}
}Note: We also use a generic alias instead of using the
PDPViewDependenciestype alias directly within the class. We do this because it lets us add generic dependencies (later on) without any additional code changes.
For readability, consistency, and convention, the dependencies parameter should always appear first in any parameter list. For example:
// BAD
PDPView(sku: "ABC", disposition: ..., context: ..., dependencies: ...)
// GOOD
PDPView(dependencies: ..., sku: "ABC", disposition: ..., context: ...)Dependencies type aliases should never be reused or shared with other types, except when forwarding them as sub-dependencies. Forwarding a Dependencies type alias should only happen in the following sub-dependencies situations:
- Direct Child Dependencies
- Superclass Dependencies
- Helper or Static Function Dependencies
If your code file instantiates a struct or class that has its own dependencies type, you must forward that child's Dependencies type alias along with your file's Dependencies type alias.
Consider a SwiftUI view named PDPView that includes a sub-view called ProductImageView. Each view requires specific dependencies:
ProductImageViewdepends on various services and utilities specific to its functionality, grouped under a type aliasProductImageViewDependencies.PDPViewrequires its own set of services, such asTrackingDependency, for tracking user interactions, grouped under a type aliasPDPViewDependencies.
To accommodate all requirements, PDPViewDependencies must integrate ProductImageViewDependencies into its own dependency structure. For example:
typealias PDPViewDependencies = TrackingDependency
& ProductImageViewDependencies // Required by ProductImageView
struct PDPView<Dependencies: PDPViewDependencies>: View {
let dependencies: Dependencies
var body: some View {
VStack {
Text("Product Detail Page")
ProductImageView(dependencies: dependencies) // Borrows PDPViewDependencies
Button("Add to cart") {
dependencies.tracker.trackAddToCart()
}
}
}
}This principle applies in all code files that work with injected dependencies, not just for SwiftUI views.
Similar to child dependencies, if you inherit a class that has its own dependencies requirement, you must forward that superclass' Dependencies type alias along with your file's Dependencies type alias. In the following example, a UIKit view controller depends on a base view controller, which has its own dependencies requirement.
typealias PDPViewControllerDependencies = TrackingDependency
& ShopBaseViewControllerDependencies // Required by ShopBaseViewController
struct PDPViewController<Dependencies: PDPViewControllerDependencies>: ShopBaseViewController {
let dependencies: Dependencies
init(dependencies: Dependencies) {
self.dependencies = dependencies
super.init(dependencies: dependencies) // Borrows PDPViewControllerDependencies
}
}If you you have a dependency that should only be accessible within a certain struct/class, file, or module, or if you need a dependency to be instantiated and destroyed along with a certain component, view, or other memory scope, you will have to use a technique called "Dependencies Splicing".
In this approach, we use a concrete struct or class to splice the narrowly scoped dependencies into the broader-scoped dependencies in a way that maintains the desired memory scope and accessibility.
Continuing with our previous example, if we want to add a PDPDataRepositoryDependency to the PDPView and that dependency is only internally available (within the same module), and you want that repository to be instantiated and destroyed along with the PDPView, then we would add that dependency to the PDPViewDependencies, as you would expect.
typealias PDPViewDependencies = TrackingDependency
& PDPDataRepositoryDependencyThen, we create a second dependencies type alias that splits out all of the broader-scope dependencies. This will be used to help construct the spliced dependencies concrete type.
typealias PDPViewExternalDependencies = TrackingDependencyNow, we create the SplicedPDPViewDependencies type that allows us to create and inject the PDPDataRepository into a single Dependencies type, like so:
struct SplicedPDPViewDependencies<ExternalDependencies: PDPViewExternalDependencies>: PDPViewDependencies {
let externalDependencies: ExternalDependencies // Obtained from outside callers
let pdpDataRepository: PDPDataRepository // Created locally
var tracking: Tracking { // Forwarded to children
externalDependencies.tracking
}
init(externalDependencies: ExternalDependencies, pdpDataRepository: PDPDataRepository) {
self.externalDependencies = externalDependencies
self.pdpDataRepository = pdpDataRepository
}
}The spliced dependencies type above combines the broad and narrow scope dependencies. This can be a struct or a class, as needed. SplicedPDPViewDependencies will now act as the underlying dependencies type for PDPView. We accomplish this by changing PDPView's generic dependency declaration like so:
struct PDPView<ExternalDependencies: PDPExternalDependencies>: View {
let dependencies: SplicedPDPViewDependencies<ExternalDependencies>
var body: some View {
VStack {
Text(dependencies.pdpDataRepository.productName)
Button("Add to cart") {
dependencies.tracking.trackAddToCart()
}
}
}
}How and where the SplicedPDPViewDependencies type is initialized depends on several factors, but for SwiftUI, it usually must be done by whoever is instantiating the SwiftUI view in question. The previous example may be declared like so:
PDPView(
dependencies: SplicedPDPViewDependencies(
externalDependencies: ...,
pdpDataRepository: PDPDataRepository(dependencies: ...)
)
)Where the caller's Dependencies type alias conforms to our external PDPViewExternalDependencies type alias that we just created.
To prevent from having to make all of your dependencies type aliases public, you can splice your dependencies at the top-most object/struct or call site of your feature. Regardless, it's usually a good idea to create a publicly accessible wrapper around your internal functionality to create a single entry point into your feature. Here is such an example:
public typealias PDPFeatureDependencies = PDPViewExternalDependencies
& PDPDataRepositoryDependenciesFirst, we created a new PDPFeatureDependencies type alias in a new file where our feature's API will live. We declare this type alias as publicly accessible.
Note: You may need to declare your "External" Dependencies type aliases as public as well, as required, but no other type aliases should be made public. If you find yourself making all of your types public to fix compiler warnings, you should reevaluate how you are splicing your dependencies.
Next, we create the public API using PDPFeatureDependencies:
public struct PDPFeature<Dependencies: PDPFeatureDependencies> {
let dependencies: Dependencies
public init(dependencies: Dependencies) {
self.dependencies = dependencies
}
public func pdpView(for sku: String) -> some View {
PDPView(
dependencies: SplicedPDPViewDependencies(
externalDependencies: dependencies,
pdpDataRepository: PDPDataRepository(dependencies: dependencies, sku: sku)
)
)
}
// Other PDP Feature functions
}This provides a succinct API for accessing all of our internal code, and a simple Dependencies type alias for external callers to grok and use.
Finally, callers can invoke your feature at any time using the following code:
PDPFeature(dependencies: dependencies).view(for: "ABC")Where the caller's Dependencies type alias conforms to our external PDPFeatureDependencies type alias that we just created.
The following example shows the ideal setup for forwarding any kind of dependencies for a type called Foo.
typealias FooDependencies = FooBaseDependencies // Superclass dependencies
& LoggingDependency // Used directly in this file
& BarDependencies // Direct child's dependencies
& Baz_trackDependencies // Helper/function call w/ dependencies
class Foo<Dependencies: FooDependencies>: FooBase {
let dependencies: Dependencies
let bar: Bar
init(dependencies: Dependencies) {
self.dependencies = dependencies // set own dependencies, if required
self.bar = Bar(dependencies: dependencies) // pass child-dependencies
super.init(dependencies: dependencies) // pass superclass dependencies
Baz.track(dependencies: dependencies) // pass helper function dependencies
dependencies.logger.log("foo Loaded!") // use dependencies directly
}
}The following uses of CPDI will land you into trouble. Avoid using these where possible, but if you use them, do it right.
You may be tempted to create a new protocol that composes other dependencies into it. Resist this urge. Always use type aliases when composing and forwarding protocols.
protocol PDPViewDependencies: TrackingDependency & ProductImageViewDependencies {
// ...
}The consequences of using the above approach are that when you compose this protocol with other type aliases that forward the same sub-dependencies, you will get unresolvable compiler errors akin to, "duplicate conformance detected".
You should avoid passing dependencies directly into functions. Instead, you should pass the dependencies via the initializer of the thing that vends the helper function. However, if you must, the following pattern should be followed when passing dependencies into helper functions.
Similar to child dependencies and superclass dependencies, if your file calls a helper function that has its own dependencies requirement, you must forward that function's Dependencies type alias along with your file's Dependencies type alias. In the following example, a SwiftUI view modifier requires a dependencies type alias that must be satisfied by the PDPViewDependencies type alias. The view modifier in this example auto-expands the product view when tapped.
typealias PDPViewDependencies = TrackingDependency
& View_autoExpandDependencies // Required by `.autoExpand(...)`, declared in `View+AutoExpand.swift`
struct PDPView<Dependencies: PDPViewDependencies>: View {
let dependencies: Dependencies
var body: some View {
VStack {
Text("Product Detail Page")
Button("Add to cart") {
dependencies.tracker.trackAddToCart()
}
}
.autoExpand(dependencies: dependencies) // Borrows PDPViewDependencies
}
}When building protocols that have extended behavior, using an associatedtype for Dependencies is generally discouraged because it can lead to difficult situations where confusing, circular, or unresolvable dependency loops can occur. It is also a conceptual POP violation to mix implementation constraints into an abstract description of functionality. Instead, stick to providing convenience directly through dependencies and not through protocol conformance of self. Protocol Oriented Programming favors extensions that provide reflexive or laterally-available concrete behavior. If your protocol extension needs a dependency, rethink your pattern.
Otherwise, if you must use this pattern, adhere to the following guidelines:
If your type implements a protocol with an associated Dependencies type, you must also forward that base class or protocol file's FooDependencies type alias along with your file's dependencies type alias, like so:
typealias PDPViewDependencies = TrackingDependency
& ProductImageViewDependencies
& PDPComponentDependencies // Required by PDPComponent
struct PDPView<Dependencies: PDPViewDependencies>: View, PDPComponent /* <- Implicitly uses PDPViewDependencies */ {
let dependencies: Dependencies
var body: some View {
VStack {
Text("Product Detail Page")
ProductImageView(dependencies: dependencies)
Button("Add to cart") {
dependencies.tracker.trackAddToCart()
}
}
.onAppear {
log("appeared!")
}
}
}The above example implements and uses a protocol called PDPComponent by forwarding its PDPComponentDependencies type alias. PDPComponent is defined like so:
typealias PDPComponentDependencies = LoggingDependency
protocol PDPComponent {
associatedtype Dependencies: PDPComponentDependencies
let dependencies: Dependencies
}
extension PDPComponent {
func log(message: String) {
dependencies.logger.log(message: "PDPComponent: " + message)
}
}