There is a picture we see often when reviewing large iOS codebases: a DI library is installed, AppContainer.swift runs to a few hundred lines, and the team says "we use dependency injection". Then you open a view model and its init takes one parameter: the container itself. To learn what that class needs, you have to read its body from top to bottom.
The promise of dependency injection was simple. A type states what it needs in its signature, and someone outside supplies it. A container is one way to implement that promise, and used in the wrong place it quietly takes the promise back. This article covers the iOS side; for the same mistakes in the Hilt and Koin world, see the Android article.
Our running example is a hypothetical travel booking app: a trip list, trip detail, a cancellation flow, payments.
The service locator in a DI costume
The code below is written with Swinject, and it compiles and runs. Nothing is wrong with the library. The problem is where we call it from.
import Swinject
@MainActor
enum AppAssembly {
static let container: Container = {
let c = Container()
c.register(APIClient.self) { _ in URLSessionAPIClient() }
.inObjectScope(.container)
c.register(BookingRepository.self) { r in
RemoteBookingRepository(api: r.resolve(APIClient.self)!)
}
.inObjectScope(.container)
c.register(Analytics.self) { _ in FirebaseAnalyticsAdapter() }
.inObjectScope(.container)
// ... 180 more lines
return c
}()
}
@MainActor
final class TripDetailViewModel {
private let container: Container
private let tripID: Trip.ID
init(tripID: Trip.ID, container: Container) {
self.tripID = tripID
self.container = container
}
func cancelTrip() async throws {
let repository = container.resolve(BookingRepository.self)!
try await repository.cancel(tripID)
container.resolve(Analytics.self)!.track(.tripCancelled(tripID))
if container.resolve(FeatureFlags.self)!.isEnabled(.refundBanner) {
container.resolve(RefundNotifier.self)!.scheduleBanner(for: tripID)
}
}
}
TripDetailViewModel depends on four things and its signature mentions none of them. What Mark Seemann wrote about the service locator years ago applies word for word: the class hides its dependencies and moves the failure from compile time to run time. Taking the container as a parameter does not improve matters. You handed the class access to everything, along with the right to keep quiet about what it uses.
The second costume looks smarter. The team gets tired of resolve calls and writes a property wrapper:
@propertyWrapper
struct Injected<Service> {
let wrappedValue: Service
@MainActor
init() {
wrappedValue = AppAssembly.container.resolve(Service.self)!
}
}
@MainActor
final class TripDetailViewModel {
@Injected private var repository: BookingRepository
@Injected private var analytics: Analytics
@Injected private var flags: FeatureFlags
@Injected private var refunds: RefundNotifier
private let tripID: Trip.ID
init(tripID: Trip.ID) { self.tripID = tripID }
}
This version reads better than the first, because the dependencies are at least listed at the top of the class. But init(tripID:) is still lying. Whoever constructs this class in a test cannot learn from the signature that four registrations must exist in a global container; they run the test and wait for it to crash. Tests share global state, so running them in parallel becomes risky. And to swap a dependency you cannot hand the object anything, you have to overwrite a global registration.
Everything DI promised to remove is back: hidden coupling, global state, order-dependent setup. The only difference is that a library now does it for you.
If a type's dependency list cannot be read from its
initsignature, the project has a container and no injection.
The forgotten registration crashes on the screen QA never opened
In Swinject, resolve returns an Optional. Even the README examples unwrap it with !, because the caller has nothing sensible to do with nil. The practical consequence: the compiler knows nothing about the link between a registration and its use.
The familiar scenario goes like this. Someone adds a new dependency to RefundNotifier and forgets to register it, or puts the registration in an assembly that sits behind a feature flag. The app launches, the main flows work, QA passes the regression list. The refund banner only appears on trips that are cancelled and awaiting a refund, and that screen is not on the list. The crash report arrives after release, and the stack trace points at Injected.init().
The usual defence, a test that resolves every registration one by one, helps but has two gaps. It tends to miss registrations with arguments and conditional assemblies, and it makes you maintain by hand something the compiler would do for free.
For comparison, make the same mistake with constructor injection:
let notifier = RefundNotifier(scheduler: notificationScheduler)
// ^ if this is not defined, the project does not build
let model = TripDetailModel(
tripID: id,
repository: repository,
analytics: analytics,
refunds: notifier
)
Here the forgotten dependency is a build error. Nobody needs to open the screen; the mistake shows up in the pull request. Every recommendation in this article rests on that difference: turning the dependency graph into ordinary Swift code the compiler can read.
The god container: the file everyone edits
The service locator's natural companion is a single assembly file. The symptoms are easy to recognise.
Every feature edits the same file. AppContainer.swift changes in every pull request, is a regular in merge conflicts, and has to know each feature's internal types, so the app target imports everything. If you want to delete a feature you pick its registrations out of this file, and the compiler will not tell you which of them are resolved elsewhere.
The container travels as a parameter. The coordinator receives it, hands it to the view model, which hands it to the next screen's view model. Any object holding the container can reach everything in the app, and your module boundaries exist on paper only.
Everything is a singleton by default. Swinject's default scope is .graph, meaning each resolve call builds a fresh object graph. Teams run into the resulting "why are there two caches" surprise once, and from then on add .inObjectScope(.container) to every registration by reflex. The result is a handful of global variables under another name; we covered what that costs in the singletons article. The most expensive case is user-bound state: a BookingRepository living in .container scope keeps the previous user's trips in memory after logout.
Nobody knows what the scopes mean. The project defines app, session, flow and screen scopes, and finding out when each one resets means reading the library's source. A new developer picks the one that looks safest, which is usually the longest-lived.
A scope is really a simple question: who owns this object, and when does the owner die? All four scopes we use can be expressed with ordinary object ownership:
| Scope | Owner | Dies when | Typical contents |
|---|---|---|---|
| App | AppGraph (held by the app delegate or the App struct) |
The process ends | HTTP client, analytics, configuration |
| Session | SessionGraph (held by AppGraph) |
Logout | User, authorised API client, user-specific caches |
| Flow | The coordinator or the flow's root view | The flow closes | Multi-step booking draft |
| Screen | The view controller or a SwiftUI view's @State |
The screen closes | View model |
There is no library concept anywhere in that table. An object's lifetime is decided by the let that holds it.
A protocol for everything: one-implementation interfaces
A by-product of container culture is this pair:
protocol TripPriceFormatterProtocol {
func format(_ price: Money) -> String
}
final class TripPriceFormatterImpl: TripPriceFormatterProtocol {
func format(_ price: Money) -> String { /* ... */ }
}
The protocol exists only "for DI", on the assumption that a container wants to register against an interface type. There is no second implementation, and tests use the real class too, because mocking a pure function is pointless. The cost is real, though. Every signature change is made twice, "jump to definition" lands on the protocol, and each reader checks whether another conformance exists.
A protocol earns its place in one of three situations:
- The real implementation leaves the process or is not deterministic: network, disk, clock, randomness, notification permissions. You will write a second implementation for tests anyway.
- The type sits on a module boundary and you do not want the consuming module to see the implementation. This is the interface module case described below.
- You need to invert the direction of a dependency: a type in a lower layer needs behaviour from a higher one.
Otherwise, inject the concrete type. TripPriceFormatter goes into the init as a struct, and if a second implementation ever turns up you extract the protocol that day; Xcode's refactoring tools do it in minutes. Constructor injection does not require protocols. It only changes who creates the object.
SwiftUI: the environment can be an implicit global too
SwiftUI ships a service locator inside the framework. With @EnvironmentObject, a view asks for an object it hopes someone further up the tree has provided:
struct TripDetailScreen: View {
@EnvironmentObject private var bookings: BookingStore
@EnvironmentObject private var analytics: AnalyticsStore
let tripID: Trip.ID
var body: some View { /* ... */ }
}
TripDetailScreen(tripID:) compiles. If the object is not in the environment, the app stops at run time the first time the view touches that property. Apple's documentation phrases this as "be sure to set a corresponding model object on an ancestor view". The usual places it goes wrong are well known: a UIHostingController root created from inside UIKit navigation, a preview that forgot to set up the environment, or a second screen reusing the view from a different tree. It is the forgotten container registration again, this time in the view hierarchy.
In the Observation era (iOS 17 and later) the API changed and its nature did not. For @Environment(BookingStore.self) the documentation is explicit: if the object is not in the environment, SwiftUI throws an exception. Declare the type as BookingStore? and you get nil, which turns the crash into a silently empty screen. Keys defined with @Entry require a default value, so nothing crashes, but a screen running in production on the default NoopAnalytics without anyone noticing is not a pleasant surprise either.
We draw the line like this. The environment is for values that concern the whole view tree and belong to the UI: theme, locale, the signed-in user's display name, a dismiss action. Repositories, API clients and use cases are not the view's business. They go into the view model's init, and the view model is passed to the view as a parameter.
import Observation
import SwiftUI
@MainActor
@Observable
final class TripDetailModel {
private(set) var trip: Trip?
private(set) var isCancelling = false
@ObservationIgnored private let tripID: Trip.ID
@ObservationIgnored private let repository: any BookingRepository
@ObservationIgnored private let analytics: any Analytics
init(tripID: Trip.ID, repository: any BookingRepository, analytics: any Analytics) {
self.tripID = tripID
self.repository = repository
self.analytics = analytics
}
func load() async throws {
trip = try await repository.trip(id: tripID)
}
func cancel() async throws {
isCancelling = true
defer { isCancelling = false }
try await repository.cancel(tripID)
analytics.track(.tripCancelled(tripID))
}
}
struct TripDetailScreen: View {
@State private var model: TripDetailModel
init(model: TripDetailModel) {
_model = State(initialValue: model)
}
var body: some View {
Group {
if let trip = model.trip {
TripSummaryView(trip: trip, isCancelling: model.isCancelling)
} else {
ProgressView()
}
}
.task { try? await model.load() }
}
}
One caveat: State evaluates its initial value every time the view is initialised and keeps only the first one. That is why Apple tells you to avoid side effects and expensive work in the initial value. A view model's init should only assign; the network call starts in .task. The example above follows that rule.
The setup we recommend: composition root, factories, explicit scopes
The rule fits in one sentence: objects receive their dependencies through init, and the code that builds the graph lives in one place, at the application's entry point. Seemann calls this the composition root. Everything else is detail.
App and session scope
@MainActor
final class AppGraph {
let configuration: AppConfiguration
let analytics: any Analytics
let authenticator: Authenticator
private let httpClient: any APIClient
private(set) var session: SessionGraph?
init(configuration: AppConfiguration) {
self.configuration = configuration
let httpClient = URLSessionAPIClient(baseURL: configuration.apiBaseURL)
self.httpClient = httpClient
self.analytics = FirebaseAnalyticsAdapter()
self.authenticator = Authenticator(api: httpClient)
}
@discardableResult
func startSession(user: User, token: AccessToken) -> SessionGraph {
let graph = SessionGraph(
user: user,
api: AuthorizedAPIClient(wrapping: httpClient, token: token),
analytics: analytics
)
session = graph
return graph
}
func endSession() {
session = nil // everything that belongs to the user is released here
}
}
@MainActor
final class SessionGraph {
let user: User
private let api: any APIClient
private let analytics: any Analytics
// One instance for the whole session: this object owns it.
private lazy var bookings: any BookingRepository = RemoteBookingRepository(api: api)
init(user: User, api: any APIClient, analytics: any Analytics) {
self.user = user
self.api = api
self.analytics = analytics
}
func makeBookingFeature() -> BookingFeature {
BookingFeature(dependencies: .init(
repository: bookings,
analytics: analytics
))
}
}
"Session scope" stops being a library setting and becomes an object. On logout endSession() is called, SessionGraph dies, and the repository and caches it holds go with it. For the previous user's data to stay in memory, someone has to keep a strong reference to SessionGraph, and you can catch that with a test (shown below).
These classes look like a container, and in a sense they are. The difference is that only the composition root knows them. No view model and no feature imports the AppGraph type.
Small factories for objects created later
The trip detail view model is created when the user taps a row and the tripID is known. This is the usual excuse for passing the container around: "I can't create the object yet". The answer is a narrow type that can produce that one object:
@MainActor
struct TripDetailFactory {
let repository: any BookingRepository
let analytics: any Analytics
func make(tripID: Trip.ID) -> TripDetailModel {
TripDetailModel(tripID: tripID, repository: repository, analytics: analytics)
}
}
struct TripListScreen: View {
let model: TripListModel
let detailFactory: TripDetailFactory
var body: some View {
List(model.trips) { trip in
NavigationLink(trip.title, value: trip.id)
}
.navigationDestination(for: Trip.ID.self) { id in
TripDetailScreen(model: detailFactory.make(tripID: id))
}
}
}
TripListScreen can now produce exactly one thing: a trip detail. It cannot reach the payment service, because it holds no path to it. You can also pass the factory as a closure ((Trip.ID) -> TripDetailModel). We prefer a named type once there are three or more parameters or more than one make method, because it reads better at the call site.
A composition root per feature
If the app-level graph knows every feature's internal types, you have rewritten the god container by hand. Each feature builds its own internal graph and exposes two things: what it needs and where you enter.
public struct BookingDependencies {
public var repository: any BookingRepository
public var analytics: any Analytics
public init(repository: any BookingRepository, analytics: any Analytics) {
self.repository = repository
self.analytics = analytics
}
}
@MainActor
public final class BookingFeature {
private let dependencies: BookingDependencies
public init(dependencies: BookingDependencies) {
self.dependencies = dependencies
}
public func makeTripList() -> some View {
TripListScreen(
model: TripListModel(repository: dependencies.repository),
detailFactory: TripDetailFactory(
repository: dependencies.repository,
analytics: dependencies.analytics
)
)
}
}
TripListModel, TripDetailFactory and the screens stay internal. The app target sees only BookingDependencies and BookingFeature. When the feature gains a new external dependency, BookingDependencies.init changes and the app stops building. In this setup, the forgotten registration scenario is a compile error.
Where a library helps
The setup above needs no library, and in a mid-sized app the hand-written wiring grows less than people expect. Still, here is where the four common options stand today and where each one fits.
Swinject (2.10.0, September 2025) is the classic runtime container: you register, resolve returns an Optional, and the scopes are .transient, .graph (the default), .container and .weak. It offers no compile-time guarantee. If it is already in your project, keep it inside the composition root only, and make sure feature code never imports Swinject.
Factory (3.x, with the module now named FactoryKit) has you declare registrations as computed properties in a Container extension, so you cannot write a key path to a factory that does not exist and the code will not compile. The default scope is unique, with .singleton, .cached, .shared and custom scopes on top. It solves the forgotten registration. What it does not solve is that @Injected(\.myService) and Container.shared still leave the dependency list outside the init signature. If you choose Factory, we suggest calling the factories in the composition root and passing the results to init; the README shows that usage as well.
swift-dependencies (1.17.x) provides values read with @Dependency(\.key) and built on task locals. Overrides are confined to a withDependencies block, and reaching a live dependency in a test without overriding it produces a test failure. That is a considerably safer ambient value than a global container. We find it reasonable for system-like values that really are needed everywhere, such as the clock, the date and UUIDs. We do not use it for wiring between features, because the dependency is still absent from the signature.
Needle reads the component hierarchy from source (through SourceKit), generates the wiring code and turns a missing dependency into a build error. In exchange you add a generator step to the build. Commits continue in the repository, but the last tagged release (v0.25.1) dates from September 2024, so try the generator against your own Xcode and Swift versions before adopting it.
Our verdict: hand wiring is the default. We move to a library when the composition root code has become a real burden, and only on the condition that it stays inside the root.
DI across modules: who owns the wiring
In a modularised project, keeping the container in a shared Core module means every feature imports that module and resolves whatever it likes. The module graph looks clean on paper while the real dependencies are established at run time through type names.
This is the structure we set up:
App (composition root: AppGraph, SessionGraph)
├─ BookingFeature ──► BookingInterface, PaymentsInterface
├─ PaymentsFeature ──► PaymentsInterface
├─ BookingInterface (BookingRepository protocol, Trip model)
└─ PaymentsInterface (PaymentFlowStarting protocol, PaymentResult)
BookingFeature wants to start the payment flow but does not import PaymentsFeature. It depends on the PaymentFlowStarting protocol in PaymentsInterface and asks for it through BookingDependencies. The only module that knows both features is App, and it makes the connection: BookingDependencies(payments: paymentsFeature, ...). Features know neither each other nor the type that builds the graph.
Two side benefits follow. A feature module depends on nothing beyond interface modules, so it builds on its own and runs in its own demo app with fake dependencies. And the answer to "what does this feature depend on" is the field list of a single struct.
Testing: what good wiring gives you for free
With constructor injection, test setup consists of creating the object. There is no global container to prepare, reset afterwards or keep tests in order for.
import Testing
@testable import BookingFeature
@MainActor
struct TripDetailModelTests {
@Test
func cancellingTracksTheEvent() async throws {
let repository = BookingRepositoryStub(trips: [.sample])
let analytics = AnalyticsSpy()
let model = TripDetailModel(
tripID: Trip.sample.id,
repository: repository,
analytics: analytics
)
try await model.cancel()
#expect(repository.cancelledIDs == [Trip.sample.id])
#expect(analytics.events == [.tripCancelled(Trip.sample.id)])
}
}
@MainActor
struct SessionLifecycleTests {
@Test
func logoutReleasesTheSessionGraph() {
let app = AppGraph(configuration: .testing)
weak var session = app.startSession(user: .sample, token: .sample)
app.endSession()
#expect(session == nil)
}
}
The second test verifies that the scope really closes. If someone one day captures SessionGraph in a closure owned by a long-lived object, this test breaks, and the "previous user's data shows up after logout" bug is caught before it ships. Each test builds its own objects, so Swift Testing's parallel execution causes no trouble either.
You no longer need a separate "does every registration resolve" test for the composition root. The compiler does that job.
Migrating from a resolve-everywhere codebase, step by step
Do not try to convert an existing project in one sprint. The order below leaves a shippable app after every step.
1. Measure. Count service locator usage outside the composition folder and write the number down:
grep -rnE "\.resolve\(|@Injected|Container\.shared" Sources --include="*.swift" \
| grep -v "/Composition/" | wc -l
2. Freeze. Block any increase of that number in CI. New code is written with constructor injection; old code moves when its turn comes. We described ways to turn the rule into a permanent lint or build check in the article on enforcing architecture rules.
3. Start at the leaves. Pick a view model and turn everything it resolves into init parameters. The resolve calls are not deleted yet, they move up one level: the coordinator or view that creates the view model now resolves and passes the results in. From that moment the view model can be tested without a container.
4. Replace container parameters with factories. Give a narrow factory to every type that holds the container only "to create something later". At the end of this step the types that know the container are limited to coordinators and assemblies.
5. Carve out the session scope. Move user-bound registrations into SessionGraph, drop it on logout and write the weak test shown above. This is usually the step that surfaces the most bugs.
6. Convert registrations to code, feature by feature. For each feature write a Dependencies struct and an entry type, and delete that feature's registrations from the assembly file. The resolve calls in coordinators become plain Swift in the composition root.
7. Decide when the number reaches zero. The container now lives inside the root only. You can replace it with AppGraph altogether or leave it as an implementation detail of the root. Both are acceptable, because the rest of the app cannot tell the difference.
You will know it worked when the number from step one is zero, a feature module builds and tests without the app target, and a missing dependency shows up as a build error instead of a runtime crash.
Where this approach stops paying off
In an app with ten screens, per-feature composition roots and interface modules are too much. A single forty-line AppGraph plus constructor injection is enough, and you add module boundaries when the team and the code grow.
At the other end, in a project with hundreds of modules, hand-written wiring reaches a noticeable volume and updating chains of Dependencies structs becomes tiring. Code generation (Needle and its kind) or a key-path-based container (such as Factory) is a legitimate choice at that point. The condition stays the same: the library remains in the composition root and feature code keeps living on init.
In SwiftUI, passing every value through ten levels of views as a parameter is also a mistake. For values that belong to the view tree, relate to the UI and have a meaningful default, the environment is the right tool.
Checklist
- Occurrences of
resolve(,@InjectedandContainer.sharedoutside the composition folder are counted, and CI blocks any increase. - No view model, non-coordinator type or feature module imports the container type or takes it as a parameter.
- Every view model's dependencies can be read from its
initsignature. - Objects created later come from narrow factory types (or closures); the container is not passed along.
- Each of the app, session, flow and screen scopes is owned by an object and ends with that object.
- The session graph is released on logout, and a
weakreference test verifies it. - One-implementation protocols have been reviewed; those with no IO, module boundary or dependency direction reason are now concrete types.
- The SwiftUI environment holds UI values only; repositories and services enter through the view model's
init. - Each feature exposes a
Dependenciesstruct and an entry type; features know each other through interface modules. - If a DI library is used, it is imported in the composition root only.
Need support on this?
Let's review together whether these controls are set up correctly in your app. We're here with security audits and advisory.
Sources
- Martin Fowler · Inversion of Control Containers and the Dependency Injection pattern
- Mark Seemann · Service Locator is an Anti-Pattern
- Mark Seemann · Composition Root
- Apple · Environment
- Apple · EnvironmentObject
- Apple · State
- Swinject · Object Scopes
- Factory · README
- Point-Free · swift-dependencies
- Uber · Needle code generator
Comments and questions
Disagree with something, or stuck on this in your own project? Write to us and let's talk.