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 ViewModels With StateObject
SwiftUI adds an ownership detail that UIKit does not have: if a view creates an observable object, that object should usually be owned with @StateObject.
That can make factory code a little less obvious. This version is tempting:
struct ProductsListView: View {
@StateObject private var viewModel: ProductsListViewModel
let onAppear: () async -> Void
init(
viewModel: ProductsListViewModel,
onAppear: @escaping () async -> Void
) {
_viewModel = StateObject(wrappedValue: viewModel)
self.onAppear = onAppear
}
var body: some View {
List(viewModel.products) { product in
Text(product.name)
}
.task {
await onAppear()
}
}
}
This is fine. The view owns the lifecycle of the view model, but it does not decide how to build the view model'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.
- 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.