The Composition Root Pattern in SwiftUI Apps

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 UserNotificationScheduler gets injected into ReminderViewModel, where Decorators stack on top of each other.
But we've never stopped to examine the Composition Root itself.
This article is about that. What is a Composition Root, why does it matter, how to structure it as your app grows, and what goes wrong when you don't have one.
What Is the Composition Root?
The Composition Root is the single place in your application where you assemble the object graph — connecting all concrete types together.
Mark Seemann, who coined the term, defines it as: "a (preferably) unique location in an application where modules are composed together."
In a SwiftUI app, the natural Composition Root is the @main struct. It's the entry point. It starts before any view is shown. It has no business logic of its own. It is the perfect place to wire everything together.
@main
struct FeedApp: App {
var body: some Scene {
WindowGroup {
// Everything is composed here — and only here
FeedView(
viewModel: FeedViewModel(
loadFeed: LoadFeedUseCase(
repository: CachingFeedRepository(
decoratee: RemoteFeedRepository(url: feedURL),
cache: CodableFeedStore(storeURL: storeURL)
)
)
)
)
}
}
}
One place. All concrete types. All wiring. Nowhere else.
What Goes Wrong Without One
Without a Composition Root, composition happens everywhere. ViewModels instantiate their own dependencies. Views create their own ViewModels. Repositories reference shared singletons. The object graph is implicit, scattered, and impossible to test.
// ❌ No Composition Root — composition scattered across the codebase
final class FeedViewModel: ObservableObject {
// Creates its own dependency — hidden, untestable
private let repository = RemoteFeedRepository(url: URL(string: "https://api.example.com/feed")!)
}
struct FeedView: View {
// Creates its own ViewModel — impossible to inject a stub
@StateObject private var viewModel = FeedViewModel()
}
Every class that instantiates its own dependencies is doing two jobs: its own job, and the job of the Composition Root. That's a SRP violation — and it means you can never test any of it in isolation.
Step 1 — Extracting the Composition Root into a Factory
As the app grows, the @main struct becomes cluttered. Extract the wiring into a dedicated factory:
// CompositionRoot/FeedUIComposer.swift
enum FeedUIComposer {
static func feedViewController(feedURL: URL, storeURL: URL) -> FeedView {
let store = CodableFeedStore(storeURL: storeURL)
let remoteRepository = RemoteFeedRepository(url: feedURL)
let cachingRepository = CachingFeedRepository(
decoratee: remoteRepository,
cache: store
)
let useCase = LoadFeedUseCase(repository: cachingRepository)
let viewModel = FeedViewModel(loadFeed: useCase)
return FeedView(viewModel: viewModel)
}
}
The @main struct becomes a clean delegation:
@main
struct FeedApp: App {
private let feedURL = URL(string: "https://api.example.com/feed")!
private let storeURL = FileManager.default
.urls(for: .cachesDirectory, in: .userDomainMask)[0]
.appendingPathComponent("feed.store")
var body: some Scene {
WindowGroup {
FeedUIComposer.feedViewController(feedURL: feedURL, storeURL: storeURL)
}
}
}
The FeedUIComposer is the Composition Root for the feed feature. It knows about every concrete type in the feature. Nothing else does.
Step 2 — Handling Navigation at the Composition Root
Navigation is a composition concern. A view should not know what screen comes next — it should tell a coordinator what happened, and the coordinator decides where to go.
Define the coordination interface in the Presentation layer:
// Presentation/FeedCoordinator.swift
protocol FeedCoordinator {
func showItemDetail(_ item: FeedItem)
}
Implement it at the Composition Root:
// CompositionRoot/FeedCoordinatorImpl.swift
final class FeedCoordinatorImpl: FeedCoordinator {
private let navigationPath: Binding<NavigationPath>
init(navigationPath: Binding<NavigationPath>) {
self.navigationPath = navigationPath
}
func showItemDetail(_ item: FeedItem) {
navigationPath.wrappedValue.append(item)
}
}
Wire it in the composer:
enum FeedUIComposer {
static func feedScene(
feedURL: URL,
storeURL: URL,
navigationPath: Binding<NavigationPath>
) -> some View {
let coordinator = FeedCoordinatorImpl(navigationPath: navigationPath)
let viewModel = FeedViewModel(
loadFeed: LoadFeedUseCase(
repository: CachingFeedRepository(
decoratee: RemoteFeedRepository(url: feedURL),
cache: CodableFeedStore(storeURL: storeURL)
)
),
coordinator: coordinator
)
return FeedView(viewModel: viewModel)
}
}
The FeedView and FeedViewModel have no idea what NavigationPath is. Navigation is a plug-in, defined by a protocol and wired at the Composition Root.
Step 3 — Composing Multiple Features
Real apps have multiple features. Each feature gets its own composer. The app-level Composition Root composes the composers:
// CompositionRoot/AppComposer.swift
struct AppComposer: View {
@State private var navigationPath = NavigationPath()
private let feedURL = URL(string: "https://api.example.com/feed")!
private let storeURL = FileManager.default
.urls(for: .cachesDirectory, in: .userDomainMask)[0]
.appendingPathComponent("feed.store")
var body: some View {
NavigationStack(path: $navigationPath) {
FeedUIComposer.feedScene(
feedURL: feedURL,
storeURL: storeURL,
navigationPath: $navigationPath
)
.navigationDestination(for: FeedItem.self) { item in
ItemDetailUIComposer.detailScene(item: item)
}
}
}
}
Each feature's internal wiring is invisible to the others. The app-level composer only knows about the entry point of each feature — not its internals.
The Composition Root Is the Only Place That Can
The Composition Root has a unique privilege: it is the only place allowed to reference concrete types from multiple layers simultaneously.
| Allowed where | Domain layer | Data layer | Presentation layer | Composition Root |
|---|---|---|---|---|
Reference FeedRepository (protocol) |
✅ | ✅ | ✅ | ✅ |
Reference RemoteFeedRepository (concrete) |
❌ | ✅ | ❌ | ✅ |
Reference CachingFeedRepository (concrete) |
❌ | ✅ | ❌ | ✅ |
Reference FeedViewModel (concrete) |
❌ | ❌ | ✅ | ✅ |
| Instantiate and connect all of the above | ❌ | ❌ | ❌ | ✅ |
If you find yourself instantiating a RemoteFeedRepository inside a ViewModel, or creating a FeedViewModel inside another ViewModel, something has leaked out of the Composition Root. Bring it back.
Testing the Composition Root
The Composition Root itself is hard to unit test — and that's fine. Its job is wiring, not logic. What you can test is that the object graph it produces behaves correctly end-to-end:
final class FeedUIComposerIntegrationTests: XCTestCase {
func test_feedScene_rendersLoadedItems() async throws {
let items = [FeedItem(id: UUID(), title: "Article", imageURL: anyURL())]
let store = InMemoryFeedStore(stubbedItems: items)
let viewModel = FeedViewModel(
loadFeed: LoadFeedUseCase(
repository: CachingFeedRepository(
decoratee: AlwaysFailingRepository(),
cache: store
)
),
coordinator: NullCoordinator()
)
await viewModel.onAppear()
XCTAssertEqual(viewModel.items, items)
}
private func anyURL() -> URL { URL(string: "https://any-url.com")! }
}
Integration tests at the composition boundary verify the wiring produces the expected behaviour — without hitting a real network or real file system.
Everything Comes Together
Looking back at the full series, the Composition Root is the thread connecting every pattern:
| Pattern | What it contributes to the Composition Root |
|---|---|
| Dependency Injection | All dependencies are injected — the Root is where they're created |
| Clean Architecture | The Root is the only place that references all three layers |
| Decorator Pattern | Decorators are stacked here — nowhere else |
| Single Responsibility | The Root's only job is composition — no business logic |
| TDD Cache Layer | CodableFeedStore is instantiated here |
| Dependency Inversion | Concrete types implement protocols; the Root connects them |
| Snapshot Testing | Tests stub the graph the Root would produce |
Every principle and pattern ultimately serves the same goal: keep each module unaware of how it's assembled, so the Composition Root can assemble it any way the current context demands — production, tests, previews, or a different product entirely.
What You Gain
| Without Composition Root | With Composition Root |
|---|---|
| Object graph assembled everywhere | Assembled in exactly one place |
| Changing infrastructure touches many files | One file changes |
| Testing requires unwinding hidden dependencies | Inject any configuration from tests |
| Features coupled through shared singletons | Features communicate through protocols |
| Adding a Decorator requires touching business logic | Stack it at the Root, touch nothing else |
Summary
The Composition Root is not a framework feature or a library — it's a discipline. One place, one job: assembling the object graph.
In SwiftUI:
The
@mainstruct is the natural Composition Root.Extract feature wiring into a UIComposer per feature as the app grows.
Wire navigation through a coordinator protocol — implement it at the Root.
Compose multiple features by having the app-level Root combine feature-level Roots.
Integration test the wiring by constructing the graph with controlled collaborators.
A codebase with a clear Composition Root is a codebase where every module is independently testable, every infrastructure choice is replaceable, and every new feature is added by extension — not by modification.
That is the promise of every principle in this series, fulfilled in one place.





