Dependency Injection in SwiftUI: Composing Views Without Singletons

Search for a command to run...

No comments yet. Be the first to comment.
Every article in this series has ended with the same instruction: wire everything at the Composition Root. We've used it as the place where RemoteFeedRepository meets CachingFeedRepository, where User

Unit tests verify logic. UI tests verify user flows. But neither catches the moment a refactor silently shifts a layout, changes a font, or breaks a dark mode style. Snapshot testing fills that gap. O

Of all the SOLID principles, the Dependency Inversion Principle (DIP) is the one that most directly enables testability and long-term flexibility. Yet it's also the most commonly misunderstood. DIP st

In previous articles, we built a CachingFeedRepository by writing the implementation first and tests after. That works — but it leaves a subtle gap: the tests are shaped by the implementation you alre

The Single Responsibility Principle (SRP) states that a class should have one reason to change. It's the first letter of SOLID, and arguably the one most frequently violated in SwiftUI projects. The c

One of the most common pitfalls in SwiftUI apps is the overuse of singletons and global state. It feels convenient at first — you just reach for UserDefaults.standard, a shared NetworkService.shared, or a global @EnvironmentObject — but it quietly makes your code untestable, hard to reason about, and tightly coupled.
This article shows how to apply Dependency Injection (DI) in SwiftUI to keep your views composable, your ViewModels testable, and your architecture clean.
Let's say you're building a simple favorites feature. A common approach looks like this:
class FavoritesViewModel: ObservableObject {
@Published var favorites: [Item] = []
func loadFavorites() {
// Directly coupled to a concrete singleton
favorites = FavoritesStore.shared.getAll()
}
}
And the view:
struct FavoritesView: View {
@StateObject private var viewModel = FavoritesViewModel()
var body: some View {
List(viewModel.favorites) { item in
Text(item.name)
}
.onAppear { viewModel.loadFavorites() }
}
}
This works at runtime. But try writing a unit test for FavoritesViewModel:
You can't control what FavoritesStore.shared returns.
Tests become dependent on shared mutable state.
Running tests in parallel risks interference between test cases.
The dependency on FavoritesStore.shared is hidden inside the ViewModel. Hidden dependencies are a form of technical debt that compounds over time.
The fix is to invert the dependency: instead of the ViewModel reaching out for what it needs, we give it what it needs.
Start by defining a protocol that abstracts the store:
protocol FavoritesStoreProtocol {
func getAll() -> [Item]
}
Make the real store conform to it:
class FavoritesStore: FavoritesStoreProtocol {
static let shared = FavoritesStore()
func getAll() -> [Item] {
// real implementation
}
}
Now inject the dependency into the ViewModel:
class FavoritesViewModel: ObservableObject {
@Published var favorites: [Item] = []
private let store: FavoritesStoreProtocol
init(store: FavoritesStoreProtocol) {
self.store = store
}
func loadFavorites() {
favorites = store.getAll()
}
}
The ViewModel no longer knows (or cares) how favorites are retrieved. It only knows what to call.
With DI in place, the view needs to receive a configured ViewModel:
struct FavoritesView: View {
@StateObject private var viewModel: FavoritesViewModel
init(viewModel: FavoritesViewModel) {
_viewModel = StateObject(wrappedValue: viewModel)
}
var body: some View {
List(viewModel.favorites) { item in
Text(item.name)
}
.onAppear { viewModel.loadFavorites() }
}
}
The Composition Root — where you wire everything together — lives at the entry point of your app:
@main
struct FavoritesApp: App {
var body: some Scene {
WindowGroup {
FavoritesView(
viewModel: FavoritesViewModel(store: FavoritesStore.shared)
)
}
}
}
Notice that FavoritesStore.shared appears only once, at the top. The rest of the codebase never references it directly.
Now you can test the ViewModel in complete isolation with a stub:
class FavoritesStoreStub: FavoritesStoreProtocol {
var stubbedFavorites: [Item] = []
func getAll() -> [Item] {
stubbedFavorites
}
}
final class FavoritesViewModelTests: XCTestCase {
func test_loadFavorites_populatesListWithStoredItems() {
let store = FavoritesStoreStub()
store.stubbedFavorites = [Item(name: "SwiftUI"), Item(name: "TDD")]
let sut = FavoritesViewModel(store: store)
sut.loadFavorites()
XCTAssertEqual(sut.favorites.map(\.name), ["SwiftUI", "TDD"])
}
func test_loadFavorites_withEmptyStore_producesEmptyList() {
let store = FavoritesStoreStub()
let sut = FavoritesViewModel(store: store)
sut.loadFavorites()
XCTAssertTrue(sut.favorites.isEmpty)
}
}
These tests are:
Fast — no network, no disk, no shared state.
Deterministic — the stub always returns what you configure.
Isolated — each test controls its own world.
For deeper view hierarchies, you have two clean options.
Pass dependencies through initializers at every level. It's verbose, but the dependency graph is explicit and auditable.
struct RootView: View {
let store: FavoritesStoreProtocol
var body: some View {
NavigationStack {
FavoritesView(
viewModel: FavoritesViewModel(store: store)
)
}
}
}
@EnvironmentObject with a Protocol WrapperWhen explicit passing becomes impractical (e.g. very deep hierarchies), @EnvironmentObject is acceptable — but inject at the Composition Root, not a singleton:
// Define a concrete container
class AppDependencies: ObservableObject {
let store: FavoritesStoreProtocol
init(store: FavoritesStoreProtocol) {
self.store = store
}
}
// Inject once at the root
@main
struct FavoritesApp: App {
var body: some Scene {
WindowGroup {
RootView()
.environmentObject(AppDependencies(store: FavoritesStore.shared))
}
}
}
The key difference from a singleton: you control what goes into AppDependencies. In tests, you inject a stub version. No global mutable state.
A nice side effect of DI: Xcode Previews become trivial to configure.
#Preview {
let store = FavoritesStoreStub()
store.stubbedFavorites = [
Item(name: "Dependency Injection"),
Item(name: "TDD"),
Item(name: "Clean Architecture")
]
return FavoritesView(viewModel: FavoritesViewModel(store: store))
}
No more fighting Previews because they hit the real network or real database.
| Before (Singleton) | After (DI) |
|---|---|
| Hidden dependencies | Explicit, visible dependencies |
| Hard to test | Fully unit-testable |
| Shared mutable state in tests | Each test controls its own world |
| Previews require real infrastructure | Previews use stubs |
| Tight coupling | Views and ViewModels are composable |
Dependency Injection is not a complex pattern — it's simply the discipline of giving a component what it needs rather than letting it reach out for it. In SwiftUI:
Define protocols for your dependencies.
Inject concrete implementations through initializers.
Wire everything at the Composition Root (your @main struct).
Use stubs in tests and previews.
This one practice unlocks testability, composability, and clear architecture — all at once. It's the foundation on which Clean Architecture, the Decorator pattern, and every other technique rely on.