The Composition Root in SwiftUI Apps
SwiftUI removes a lot of UIKit ceremony, but it does not remove the need for a Composition Root.
We no longer have to start from SceneDelegate, create a UIWindow, or push view controllers through a UINavigationController. Still, the app needs a place where concrete dependencies are chosen, configured, and connected to the features that use them.
That place is still the Composition Root.
#The Composition Root in a Small SwiftUI App
In a pure SwiftUI app, the most obvious composition root is the @main App type.
@main
struct ProductsApp: App {
private let compositionRoot = CompositionRoot()
var body: some Scene {
WindowGroup {
compositionRoot.makeRootView()
}
}
}
This keeps the entry point boring. The App type starts the application, while CompositionRoot decides what the root feature is and which concrete dependencies it receives.
struct CompositionRoot {
func makeRootView() -> some View {
ProductsFeatureFactory(
productsRepository: makeProductsRepository()
)
.makeProductsListView()
}
private func makeProductsRepository() -> ProductsRepository {
URLSessionProductsRepository(
httpClient: URLSessionHTTPClient()
)
}
}
The important part is not the specific class names. The important part is the direction: concrete implementations are created at the edge of the app, and features depend on abstractions.
#Feature Factories
As the app grows, putting every screen creation method in a single CompositionRoot can become noisy. A useful next step is to introduce feature factories.
struct ProductsFeatureFactory {
let productsRepository: ProductsRepository
func makeProductsListView() -> ProductsListView {
let viewModel = ProductsListViewModel()
let useCase = GetProductsUseCase(
productsRepository: productsRepository,
output: viewModel
)
ProductsListView(
viewModel: viewModel,
onAppear: {
await useCase.execute()
}
)
}
}
This keeps the feature's construction close to the feature, while the app-level Composition Root still owns the concrete choices. The ViewModel does not create or call the use case directly; it receives the use case output and maps it into view state.
struct CompositionRoot {
private let httpClient = URLSessionHTTPClient()
private let analytics = FirebaseAnalyticsTracker()
func makeProductsFeature() -> ProductsFeatureFactory {
ProductsFeatureFactory(
productsRepository: AnalyticsProductsRepositoryDecorator(
decoratee: URLSessionProductsRepository(httpClient: httpClient),
analytics: analytics
)
)
}
func makeRootView() -> some View {
makeProductsFeature().makeProductsListView()
}
}
This is the same idea we use in UIKit: the app decides the concrete graph, and the feature receives what it needs.
#Owning Observable Objects Explicitly
SwiftUI adds an ownership detail that UIKit does not have: the property wrapper should describe who owns the observable object.
If a factory creates a ViewModel and injects it into the view, the view observes an object owned by the composed feature:
struct ProductsListView: View {
@ObservedObject var viewModel: ProductsListViewModel
let onAppear: () async -> Void
var body: some View {
List(viewModel.products) { product in
Text(product.name)
}
.task {
await onAppear()
}
}
}
If the view creates the observable object and owns its lifecycle, @StateObject is the right signal. If the object is created elsewhere and injected, @ObservedObject is usually clearer.
This is the important distinction: the view may observe state, but it should not decide how to build the ViewModel's dependencies. It receives a user-interface intent closure from the factory, and that closure triggers the use case that was composed outside the view.
The key distinction is subtle but important:
- SwiftUI views can own state.
@StateObjectmeans this view owns the observable object's lifetime.@ObservedObjectmeans this view receives an already-created observable object.- The Composition Root should own dependency decisions.
- ViewModels should expose renderable state and receive outputs, not secretly assemble or drive business operations.
#NavigationStack Composition
SwiftUI navigation also changes the shape of composition. Instead of pushing view controllers, we often model navigation with values.
enum ProductsRoute: Hashable {
case detail(Product.ID)
}
extension ProductsFeatureFactory {
func makeProductsListView(
onProductSelected: @escaping (Product) -> Void
) -> ProductsListView {
let viewModel = ProductsListViewModel(
onProductSelected: onProductSelected
)
let useCase = GetProductsUseCase(
productsRepository: productsRepository,
output: viewModel
)
ProductsListView(
viewModel: viewModel,
onAppear: {
await useCase.execute()
}
)
}
func makeProductDetailView(productID: Product.ID) -> ProductDetailView {
let viewModel = ProductDetailViewModel()
let useCase = GetProductUseCase(
productID: productID,
productsRepository: productsRepository,
output: viewModel
)
ProductDetailView(
viewModel: viewModel,
onAppear: {
await useCase.execute()
}
)
}
}
struct ProductsRootView: View {
@State private var path: [ProductsRoute] = []
let factory: ProductsFeatureFactory
var body: some View {
NavigationStack(path: $path) {
factory.makeProductsListView(
onProductSelected: { product in
path.append(.detail(product.id))
}
)
.navigationDestination(for: ProductsRoute.self) { route in
switch route {
case let .detail(productID):
factory.makeProductDetailView(productID: productID)
}
}
}
}
}
The flow is still composed outside the leaf views. ProductsListView does not need to know whether selecting a product pushes a detail screen, opens a sheet, logs analytics, or starts a checkout journey. It only exposes an intent.
#Previews Should Have Their Own Composition
One of the nicest parts of SwiftUI is previews, and previews become much more useful when composition is explicit.
#Preview {
ProductsFeatureFactory(
productsRepository: InMemoryProductsRepository(
products: Product.previewProducts
)
)
.makeProductsListView()
}
This is a good smell. If a preview can swap a remote dependency with an in-memory one without touching the feature code, the boundaries are probably helping you.
#What About Environment?
SwiftUI's environment is powerful, but it can become a service locator if we use it for everything.
This can be convenient:
@Environment(\.analytics) private var analytics
But if every feature silently pulls repositories, clients, trackers, and use cases from the environment, dependencies become harder to see. The code may look clean locally while the actual dependency graph becomes implicit.
As a rule of thumb:
- Use constructor injection for feature dependencies.
- Use
@Environmentfor app-wide context, UI concerns, and dependencies that truly behave like environment values. - Avoid hiding important business dependencies just to make initializers shorter.
#What you should remember
SwiftUI changes the mechanics of composition, not the reason for composition. The @main app, feature factories, previews, and navigation roots are all good places to wire concrete dependencies. Leaf views should stay focused on rendering state and sending user intents.
Read next
Clean MVVM in SwiftUI: Use Cases, ViewModels, and Composition Root
Learn clean MVVM in SwiftUI using use cases, view models, adapters, and composition root wiring.