Skip to main content

Command Palette

Search for a command to run...

The Composition Root Pattern in SwiftUI Apps

Updated
8 min readView as Markdown
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:

  1. The @main struct is the natural Composition Root.

  2. Extract feature wiring into a UIComposer per feature as the app grows.

  3. Wire navigation through a coordinator protocol — implement it at the Root.

  4. Compose multiple features by having the app-level Root combine feature-level Roots.

  5. 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.