Dependency Injection in Swift Without the Ceremony
Dependency Injection is one of those ideas that often sounds more complicated than it is.
At its core, dependency injection means this: a type should receive the things it needs instead of creating them secretly inside itself.
That is it.
The ceremony comes later, when we start talking about containers, property wrappers, registries, global resolvers, runtime graphs, and other machinery. Those tools can be useful in some contexts, but they are not the idea. The idea is much smaller and much more practical.
If a products feature needs to load products, the use case should receive the repository it needs, and the ViewModel should receive the use case output.
struct GetProductsUseCase {
let repository: ProductsRepository
let output: ProductsOutput
func execute() async {
do {
output.didLoadProducts(try await repository.products())
} catch {
output.didFailLoadingProducts()
}
}
}
This is dependency injection. No framework required.
#The Problem With Creating Dependencies Internally
The opposite approach is to create the dependency from inside the type that uses it.
final class ProductsViewModel: ObservableObject {
private let repository = URLSessionProductsRepository()
func onAppear() async {
// load products, handle errors, map state
}
}
This looks convenient because the initializer is shorter. But the ViewModel is now deciding more than it should.
It decides that products come from the network. It decides which repository implementation is used. It starts to own business execution and presentation mapping at the same time. It also becomes harder to test, because a unit test cannot replace the repository without changing production code.
The problem is not that the code is long. The problem is that the dependency direction is wrong. A feature-level object is making application-level decisions.
In The Composition Root in SwiftUI Apps, I describe the place where those application-level decisions should live. Dependency injection is the technique that lets those decisions stay there.
#Constructor Injection First
In Swift, initializer injection should be the default.
final class LoginViewModel: ObservableObject {
private let validateEmail: EmailValidator
init(validateEmail: EmailValidator) {
self.validateEmail = validateEmail
}
}
Initializer injection has a few nice properties:
- Required dependencies are visible.
- Invalid objects are hard to create.
- Tests can provide test doubles.
- The type does not know where dependencies came from.
That last point matters. A ViewModel should not care whether a use case was created by the app, a preview, a test, or a factory. In a unidirectional design, the ViewModel can depend on presentation collaborators, but the use case is usually triggered by an adapter, a view intent closure, or the composition layer and then reports outputs back to the ViewModel.
#Protocols Are Optional
A common mistake is thinking dependency injection always requires protocols.
It does not.
This is still dependency injection:
struct ProductsView: View {
let viewModel: ProductsViewModel
var body: some View {
// render state
}
}
So is this:
final class ProductsViewModel: ObservableObject {
private let priceFormatter: PriceFormatter
init(priceFormatter: PriceFormatter) {
self.priceFormatter = priceFormatter
}
}
You introduce a protocol when you need substitution. Testing is one reason. Multiple implementations are another. A stable boundary between modules can be another.
But if there is only one implementation and no useful substitution point, a protocol may be noise. It is one of the easiest ways to accidentally make code look architectural without making it easier to change.
#Factories Keep Initializers Honest
As dependencies grow, initializer injection can make creation code noisy.
That noise is useful information, but it does not mean every caller should see all of it.
struct ProductsFeatureFactory {
let repository: ProductsRepository
let analytics: AnalyticsTracking
func makeProductsView() -> ProductsView {
let viewModel = ProductsViewModel()
let output = AnalyticsProductsOutputDecorator(
decoratee: viewModel,
analytics: analytics
)
let useCase = GetProductsUseCase(
repository: repository,
output: output
)
ProductsView(
viewModel: viewModel,
onAppear: {
await useCase.execute()
}
)
}
}
The feature still uses constructor injection, but callers do not have to rebuild the whole graph manually. The ViewModel receives outputs, the use case receives its repository, and the view receives an intent closure. The factory becomes the local construction boundary for the feature.
This is where factories and composition roots work nicely together. The composition root chooses concrete implementations. Feature factories assemble feature objects from those choices.
#What About SwiftUI Environment?
SwiftUI's environment is useful, but it is not a replacement for dependency injection.
The environment is a good fit for values that behave like environment:
- color scheme
- locale
- dismiss actions
- navigation actions in some designs
- app-wide context
- broadly shared services with low feature specificity
It is a weaker fit for business dependencies that are essential to understanding a feature.
struct ProductsView: View {
@Environment(\.productsRepository) private var repository
}
This can make previews and experiments convenient. But if every important dependency arrives invisibly through the environment, the feature's dependency graph disappears from the initializer and becomes harder to reason about.
My rule of thumb is simple: if a dependency is part of the feature's behavior, prefer the initializer. If it is part of the surrounding app context, environment can be a good fit.
#Singletons Are Hidden Injection
A singleton is often dependency injection with the injection removed.
final class CheckoutViewModel {
func showPaymentStarted() {
Analytics.shared.track("checkout_started")
}
}
The ViewModel depends on analytics, but the dependency is hidden. Tests now need to manage global state. The feature cannot easily run with different analytics behavior. The call site says nothing about the dependency.
A small change makes the dependency visible:
final class CheckoutAnalyticsDecorator: CheckoutOutput {
private let decoratee: CheckoutOutput
private let analytics: AnalyticsTracking
init(decoratee: CheckoutOutput, analytics: AnalyticsTracking) {
self.decoratee = decoratee
self.analytics = analytics
}
func didStartPayment() {
analytics.track("checkout_started")
decoratee.didStartPayment()
}
}
Now the app can still use a shared analytics instance if that is appropriate. The difference is that the decision is made at the edge of the app, not inside the feature.
#Dependency Injection Containers
DI containers can be useful in large apps, especially when object graphs are broad and repeated. But a container should not be the first tool you reach for.
Before adding a container, ask:
- Are factories becoming repetitive in a way that hurts clarity?
- Does the team understand the container's lifecycle rules?
- Will failures happen at compile time or runtime?
- Can a new engineer discover where a dependency is registered?
If the answer is unclear, plain Swift is usually better.
A small composition root, a few factories, and initializer injection can take you very far.
#What You Should Remember
Dependency injection is not about frameworks. It is about honesty.
A type should be honest about what it needs. The application should be honest about which concrete implementations it chooses. Tests and previews should be able to replace those choices without rewriting the feature.
Start with initializers. Add protocols when substitution is useful. Add factories when construction becomes noisy. Reach for containers only when the simpler tools are no longer enough.
Read next
Factories, Builders, and Composition Roots in Swift
Understand factories, builders, and composition roots in Swift, and learn where each construction pattern belongs.