You're probably already doing dependency injection in Swift, just not consistently.
A typical SwiftUI app starts simple. One ObservableObject owns too much, a couple of services live as singletons, previews get awkward, and tests either hit real code paths or don't get written at all. Then concurrency enters the picture, AI coding tools start generating files faster than you can review them, and hidden dependencies become the part that keeps biting you.
That's where modern dependency injection Swift practices stop being architecture theater and start becoming survival. The goal isn't to impress anyone with patterns. The goal is to make dependencies obvious, keep SwiftUI code ergonomic, and stop shared state from leaking across places it shouldn't.
Table of Contents
- What Is Dependency Injection Really?
- Idiomatic Dependency Injection Patterns in Swift
- Modern DI for SwiftUI Apps
- Choosing Your DI Strategy Framework vs Manual
- How to Test Your Swift Code with DI
- Best Practices and Common Pitfalls
- Conclusion Building Maintainable Swift Apps
What Is Dependency Injection Really?
You feel the cost of missing DI the first time a SwiftUI preview crashes because a view model inadvertently spins up production services, or an async test starts hitting the network because URLSession.shared was created deep inside the type under test.
Dependency injection fixes that by making a type state its collaborators up front, then receiving them from the outside.

A simple mental model
In Swift, a dependency is any object or service a type relies on to do real work. That usually means API clients, repositories, stores, loggers, analytics trackers, clocks, feature flags, and sometimes actors that coordinate async work.
The important part is ownership. If a type creates those dependencies itself, it also hardcodes implementation details, lifecycle, and setup. If those dependencies are passed in, the type stays focused on its job.
Practical rule: A type should declare what it needs and let composition happen somewhere else.
That is dependency injection in plain terms. One object receives the collaborators it needs instead of constructing them internally.
Testing gets easier for the same reason. Mocks and stubs can replace live services, so a unit test exercises one behavior at a time. The result is less setup noise and fewer failures caused by networking, persistence, time, or other unrelated systems, as described in this Swift DI write-up from OneUptime.
What changes in code
The bad version usually looks harmless:
final class UserProfileViewModel: ObservableObject {
private let api = LiveAPIClient()
private let analytics = FirebaseAnalytics()
func load() async {
// use api and analytics
}
}
This works for a while. Then the trade-offs show up.
You cannot swap LiveAPIClient for a test double without changing production code. SwiftUI previews inherit real app wiring they never asked for. Concurrency makes the problem sharper, because hidden dependencies often carry hidden thread, actor, or lifecycle assumptions too.
The injected version is boring on purpose:
protocol APIClient {
func loadProfile() async throws -> UserProfile
}
protocol AnalyticsTracking {
func track(_ event: String)
}
final class UserProfileViewModel: ObservableObject {
private let api: APIClient
private let analytics: AnalyticsTracking
init(api: APIClient, analytics: AnalyticsTracking) {
self.api = api
self.analytics = analytics
}
func load() async {
// use api and analytics
}
}
Now the contracts are explicit. The view model tells you what it depends on, and Swift helps catch missing wiring at compile time. Tests can pass mocks. Previews can pass stubs. AI coding tools also do better with this shape of code, because the dependency boundaries are visible instead of buried in implementation details.
That matters more in modern SwiftUI apps than it did in older MVC-heavy codebases. SwiftUI encourages small views, observable state, async loading, and frequent previewing. DI keeps those pieces replaceable, which is what makes fast iteration possible in a real indie app and not just in sample code.
Idiomatic Dependency Injection Patterns in Swift
Swift doesn't require a container or a third-party library to do DI well. In most apps, the language already gives you the tools you need.
Initializer injection first
This is the default. If a dependency is required for the type to do its job, put it in the initializer.
Donny Wals points out that in Swift, dependency injection is most commonly implemented through initializer injection because it makes dependencies explicit at compile time, and passing an object conforming to a protocol is already DI. He also notes that third-party DI libraries are usually unnecessary for most iOS apps, which matches day-to-day practice in many teams. See Donny Wals on dependency injection in Swift.
protocol DataProviding {
func fetchFeed() async throws -> [FeedItem]
}
final class FeedViewModel: ObservableObject {
@Published private(set) var items: [FeedItem] = []
private let dataProvider: DataProviding
init(dataProvider: DataProviding) {
self.dataProvider = dataProvider
}
@MainActor
func load() async {
do {
items = try await dataProvider.fetchFeed()
} catch {
items = []
}
}
}
Why this works well:
- Required means required: You can't create
FeedViewModelwithout aDataProviding. - State stays stable: A
letdependency won't get swapped halfway through execution. - The call site stays honest: Whoever constructs the object has to decide what implementation is appropriate.
The downside is obvious. Large objects can grow ugly initializers. That's not a reason to abandon initializer injection. It's usually a sign you either need a composition root, a factory, or a type with fewer responsibilities.
Property injection when the dependency is contextual
Property injection has a place, but it's not the default for core business dependencies.
protocol ErrorReporting {
func capture(_ error: Error)
}
final class CheckoutViewModel: ObservableObject {
var errorReporter: ErrorReporting?
func completePurchase() async {
do {
// purchase flow
} catch {
errorReporter?.capture(error)
}
}
}
This pattern fits optional collaborators. Analytics, error reporting, feature flags, or preview-only helpers can work well here.
Use it carefully. If the object can't function correctly without the dependency, property injection creates an invalid state. That's exactly what initializer injection prevents.
If you can accidentally forget to set it, it probably belongs in
init.
Method injection for one-off collaborators
Sometimes a dependency is only relevant for a single operation. Passing it into the method keeps your object smaller and avoids fake ownership.
protocol ExportLogger {
func log(_ message: String)
}
struct ReportExporter {
func export(
report: Report,
logger: ExportLogger
) async throws -> URL {
logger.log("Starting export")
// export work
logger.log("Finished export")
return URL(fileURLWithPath: "/tmp/report.pdf")
}
}
Method injection is useful when:
- The collaborator is transient: It's needed for one task, not the type's lifetime.
- The configuration varies by call: Different loggers, contexts, or strategies can be used each time.
- You want sharper ownership: The type doesn't pretend it owns something it only borrows briefly.
The practical rule is straightforward. Start with initializer injection. Use property injection for optional or framework-driven cases. Use method injection for one-shot context.
Modern DI for SwiftUI Apps
SwiftUI changes the shape of the problem. A rigid “everything must go through initializers” approach sounds pure, but it often fights the framework.
When Environment fits better than initializers
SwiftUI's @Environment and @EnvironmentObject are built to register, overwrite, and read values through the view tree. That makes them different from classic constructor injection and often better for cross-cutting app state. Lucas van Dongen makes the key point well: the core question isn't which pattern is best, but which one minimizes refactor cost at your scale. See his comparison of Swift DI approaches.
That matches what works in production SwiftUI apps:
import SwiftUI
@MainActor
final class SessionStore: ObservableObject {
@Published var isLoggedIn = false
@Published var userName: String?
}
struct RootView: View {
@EnvironmentObject private var session: SessionStore
var body: some View {
Group {
if session.isLoggedIn {
HomeView()
} else {
LoginView()
}
}
}
}
This is good SwiftUI. Authentication state, theme settings, navigation coordination, and app-wide user session data often belong in the environment because many views need access and SwiftUI already knows how to propagate that state.
If you're still getting comfortable with how data moves through a SwiftUI app, this SwiftUI getting started guide is a useful companion for the view hierarchy side of the equation.
Where initializer injection still wins
Environment is not a blank check to hide everything.
A leaf view model with a narrow job usually benefits from an explicit initializer:
struct ProductDetailsScreen: View {
@StateObject private var viewModel: ProductDetailsViewModel
init(productID: String, repository: ProductRepository) {
_viewModel = StateObject(
wrappedValue: ProductDetailsViewModel(
productID: productID,
repository: repository
)
)
}
var body: some View {
Text("...")
}
}
That's better when the dependency is local, feature-specific, or needed by only one part of the tree. You get clearer contracts and easier previews.
A practical split that holds up:
- Use Environment for app-wide session state, shared settings, routing coordination, and services many views require.
- Use initializer injection for repositories, feature services, use cases, and view models with clear boundaries.
- Avoid pushing everything into Environment just because it's convenient. That turns explicit dependencies into hidden ones.
SwiftUI gives you an ergonomic dependency channel. It doesn't remove the need to choose what belongs there.
The mistake I see most often is treating EnvironmentObject like a global backpack. Once five unrelated services sit in the environment, every view can reach everything, and your dependency graph becomes harder to reason about than the singleton setup you were trying to escape.
Choosing Your DI Strategy Framework vs Manual
A common SwiftUI moment goes like this: the app ships fine with manual wiring, then async flows, background refresh, previews, widgets, and a couple of AI-generated features start piling on. Suddenly the question is no longer whether DI matters. The question is whether your current setup still makes object creation obvious.
For plenty of apps, manual DI still wins.

What manual DI gets right
Manual DI works well because Swift already gives you strong defaults. Initializers, protocols, small factories, and a clear composition root cover a lot of ground without extra machinery.
That matters in real projects:
- Object creation stays readable: You can trace how a view model gets its repository, client, and config without jumping through container registrations.
- Compiler errors catch wiring problems early: If a dependency is missing, it usually fails where you assemble the type.
- Previews and tests stay simple: You can pass a fake implementation directly instead of bootstrapping a framework first.
- AI-generated code behaves better: Code assistants tend to produce safer changes when dependencies are explicit and local.
That last point matters more now than it did a couple of years ago. If you use AI to scaffold a feature, manual DI gives the model a better shot at extending the code without reaching for globals or inventing hidden resolution paths. A broader iOS app development tool overview is useful here because DI is only one part of the tooling choices that affect how maintainable your project stays.
Manual DI is usually the best fit for indie apps, client work, and small product teams. It keeps the architecture legible. It also matches SwiftUI well, where many dependencies are short-lived, feature-scoped, or tied to a specific async workflow.
When a framework earns its place
A framework starts to make sense when assembly becomes the problem, not before. I usually see that point when teams are repeating the same construction logic across features, fighting scope management, or coordinating dependencies across multiple modules.
Typical signs:
- Feature wiring is duplicated in several places: The same services get rebuilt across screens, flows, and extensions.
- You need consistent lifetimes: Some dependencies should live for the app session, others per feature flow, others per task.
- Modules are isolated: Shared registration patterns can reduce copy-paste assembly code.
- Concurrency adds setup complexity: Actors, async services, and task-bound collaborators can make ad hoc wiring harder to reason about.
The trade-off is real. A DI framework can reduce boilerplate, but it can also make dependencies harder to see. If a repository or view model resolves collaborators from a container at runtime, you have slipped toward service locator territory. SwiftUI code gets harder to read, previews get less predictable, and debugging turns into "where was this registered?"
Swift DI Framework Comparison
| Approach | Best For | Compile-Time Safety | Learning Curve |
|---|---|---|---|
| Manual DI | Small to medium apps, focused SwiftUI features, teams that value explicit wiring | Strong when using initializer injection and protocols | Low |
| Lightweight factory/container | Growing apps that want less boilerplate without fully abstracting object creation | Good if resolution stays type-safe and close to composition roots | Medium |
| Full DI framework | Large apps with complex graphs, scopes, or multiple modules | Depends on the framework and how strictly the team uses it | Higher |
A practical filter works better than ideology:
- Start with manual DI. If you can still understand object creation by reading the feature entry point, keep it.
- Add factories before a container-heavy framework. Factories remove repetition and usually preserve clarity.
- Adopt a framework when scopes and graph assembly are slowing the team down. That is the point where the abstraction starts paying rent.
The worst reason to add a framework is aesthetics. The best reason is repeated wiring pain in a codebase that has already outgrown straightforward assembly.
How to Test Your Swift Code with DI
The best argument for DI isn't theoretical purity. It's that tests stop being a chore.
Start with a ViewModel that depends on a protocol instead of a concrete networking service. That gives you a seam where tests can insert a mock.

A ViewModel that is easy to test
import Foundation
import SwiftUI
struct TodoItem: Equatable, Decodable {
let title: String
}
protocol TodoLoading {
func fetchTodos() async throws -> [TodoItem]
}
@MainActor
final class TodoListViewModel: ObservableObject {
@Published private(set) var items: [TodoItem] = []
@Published private(set) var errorMessage: String?
private let loader: TodoLoading
init(loader: TodoLoading) {
self.loader = loader
}
func load() async {
do {
items = try await loader.fetchTodos()
errorMessage = nil
} catch {
items = []
errorMessage = "Failed to load"
}
}
}
The dependency is obvious. No singleton, no hidden URLSession, no reaching into a container from inside the method.
A useful next step after these unit tests is to separate them from broader end-to-end verification. This integration testing best practices guide is a good reference for where DI-backed unit tests stop and integration tests should begin.
Here's a short walkthrough if you prefer video before code:
A test with a mock service
import XCTest
final class MockTodoLoader: TodoLoading {
var result: Result<[TodoItem], Error> = .success([])
func fetchTodos() async throws -> [TodoItem] {
try result.get()
}
}
@MainActor
final class TodoListViewModelTests: XCTestCase {
func testLoadSuccessPublishesItems() async {
let mock = MockTodoLoader()
mock.result = .success([TodoItem(title: "Ship build")])
let viewModel = TodoListViewModel(loader: mock)
await viewModel.load()
XCTAssertEqual(viewModel.items, [TodoItem(title: "Ship build")])
XCTAssertNil(viewModel.errorMessage)
}
func testLoadFailurePublishesError() async {
enum TestError: Error { case failed }
let mock = MockTodoLoader()
mock.result = .failure(TestError.failed)
let viewModel = TodoListViewModel(loader: mock)
await viewModel.load()
XCTAssertEqual(viewModel.items, [])
XCTAssertEqual(viewModel.errorMessage, "Failed to load")
}
}
This is the payoff:
- Tests run in isolation: No real network, auth, or database behavior slips in.
- Failures are narrower: When the test breaks, the ViewModel is the likely cause.
- Refactoring gets safer: You can change implementation details without losing confidence.
Good DI doesn't just make code testable. It makes tests worth writing because they stay fast and trustworthy.
Best Practices and Common Pitfalls
By the time a codebase “has DI problems,” the underlying issue usually isn't missing abstractions. It's unclear ownership and hidden access paths.

Practices worth keeping
A few rules hold up across app sizes:
- Prefer initializer injection for required collaborators. It gives the strongest contract and keeps types valid from the moment they're created.
- Depend on protocols where replacement matters. Repositories, clients, persistence, analytics, and feature services are common candidates.
- Keep composition near the app boundary. Construct objects in app setup, feature factories, or dedicated assembly code, not deep inside business logic.
- Use Environment intentionally. Reserve it for fundamental cross-cutting view-tree concerns.
- Keep factories boring. A small factory that creates a feature module is often enough.
Swift concurrency adds another layer. Recent Swift community discussion around newer DI approaches highlights that the ecosystem is evolving toward async-safe, type-safe injection, while much of the older advice still assumes synchronous apps. The sharper modern failure mode is hidden shared state crossing concurrency domains, which is why a good DI strategy now has to be concurrency-aware. See the Swift Forums discussion introducing a concurrency-focused DI library.
That point matters in real apps. If several tasks can touch the same injected mutable service, you need to think about actor isolation, @MainActor boundaries, and whether that service should even be shared.
Mistakes that quietly rot a codebase
The biggest ones are predictable:
- Service locator disguised as DI: If code calls
Container.shared.resolve()from random places, dependencies are hidden again. - Over-injecting everything: Not every helper needs a protocol. Abstraction has a cost.
- Using Environment as a global dump: Convenience turns into invisible coupling fast.
- Ignoring concurrency semantics: A “shared” dependency that crosses tasks without clear isolation becomes a debugging problem.
- Trusting AI-generated wiring without review: Agentic tools are good at producing code that compiles. They're not automatically good at choosing ownership boundaries.
The cleanest DI setup is the one another developer can understand in a quick read, then modify without hunting for runtime registrations.
If you're migrating an existing app, don't rewrite everything. Start where pain is highest: networking, persistence, analytics, and feature ViewModels. Pull singletons out of those seams first. You'll usually get most of the benefit before touching the entire codebase.
Conclusion Building Maintainable Swift Apps
Dependency injection isn't a library choice. It's a design habit.
In SwiftUI projects, the best results usually come from a mix of patterns. Use initializer injection for required feature dependencies. Use SwiftUI Environment for app-wide state that belongs in the view tree. Add factories when object creation gets repetitive. Reach for a framework only when graph management becomes the problem, not before.
That approach pays off in three places that matter. Code gets easier to test. Concurrency bugs become easier to reason about because shared state is more visible. AI-assisted development gets safer because the structure of the app is explicit instead of hidden behind globals and runtime lookups.
If you want your app to still feel workable months from now, dependency injection Swift practices are worth getting right early. Not because they're trendy. Because they keep your codebase honest.
If you want a faster path to a production-ready SwiftUI codebase with modern architecture, prewired app foundations, and conventions that work well with AI coding agents, take a look at Spaceport. It's built for indie iOS teams who want to ship real apps quickly without starting every project from scratch.
