Wiring It All Together: The Main App

We've built two feature domains in our local package: Landmarks and Reservations. Each has its own API, Domain, and Feature layers. They compile independently, preview with mock data, and the tests pass. But right now they're just libraries sitting in a package. Nothing actually runs them.

That's where the main app target comes in. It's the thinnest layer in the entire architecture: a composition root that wires services together, sets up navigation, and gets out of the way.

You can find the complete working code for this post on GitHub.

The app target

Your Xcode project has a single app target that depends on the feature libraries from your local package. Its Package.swift dependency looks like this:

dependencies: [
    .package(path: "../LandmarksPackage"),
]

And the app target imports exactly what it needs:

import LandmarksFeature
import ReservationsFeature
import LandmarksDomain

The app target doesn't import LandmarksApi or ReservationsApi. It doesn't import Toolkit or Env directly. It talks to Domain for service setup and Feature for views. That's it.

The composition root

Your App struct is where all the services get their real implementations:

import SwiftUI
import LandmarksDomain
import LandmarksFeature
import ReservationsFeature
import Toolkit

@main
struct LandmarksApp: App {
    let services: ServiceEnvironment = .live(
        client: .default,
        baseURL: URL(string: "https://api.example.com")!
    )

    var body: some Scene {
        WindowGroup {
            ContentView()
                .withServiceEnvironment(services)
        }
    }
}

One line of service injection at the root. Every feature view below this point gets real services through the environment. In previews and tests, swap .live for .mock and everything works.

The .live factory creates all services with the same NetworkClient and base URL. If different services need different configurations (maybe your reservations API is on a different host), you can construct the ServiceEnvironment manually:

let services = ServiceEnvironment(
    landmarkService: .live(client: .default, baseURL: landmarksURL),
    reservationService: .live(client: .default, baseURL: reservationsURL),
    profileService: .live(client: .default, baseURL: profileURL),
    scheduleService: .live(client: .default, baseURL: scheduleURL)
)

TabView as the navigation shell

The app brings features together with a TabView. Each tab owns its own NavigationStack:

struct ContentView: View {
    var body: some View {
        TabView {
            Tab("Landmarks", systemImage: "map") {
                NavigationStack {
                    LandmarkListView()
                }
            }

            Tab("Reservations", systemImage: "calendar") {
                NavigationStack {
                    ReservationListView()
                }
            }
        }
    }
}

#Preview {
    ContentView()
        .withServiceEnvironment(.mock)
}

Each feature module exposes its root view as a public type. The app just places them in tabs. The feature handles everything from there: loading data, displaying lists, navigating to detail views.

Cross-module navigation

Here's where it gets interesting. What happens when you're looking at a reservation and want to tap through to the landmark detail? The ReservationsFeature module doesn't contain LandmarkDetailView - that lives in LandmarksFeature. And we don't want ReservationsFeature to depend on LandmarksFeature.

The solution is to handle cross-module navigation at the app layer. Pass a navigation action as a closure:

struct ReservationsTab: View {
    @State private var navigationPath = NavigationPath()

    var body: some View {
        NavigationStack(path: $navigationPath) {
            ReservationListView(
                onLandmarkTapped: { landmarkId in
                    navigationPath.append(
                        LandmarkDestination(id: landmarkId)
                    )
                }
            )
            .navigationDestination(for: LandmarkDestination.self) { dest in
                LandmarkDetailView(landmarkId: dest.id)
            }
        }
    }
}

struct LandmarkDestination: Hashable {
    let id: Int
}

The app layer knows about both feature modules, so it can wire the navigation between them. The features themselves stay decoupled. ReservationListView just calls a closure when a landmark is tapped - it doesn't know or care what happens next.

What the app layer should and shouldn't do

The app target is a composition root. Here's what belongs there:

  • Service configuration (.live, .mock, etc.)
  • Service injection via .withServiceEnvironment
  • Tab layout and top-level navigation structure
  • Cross-module navigation wiring
  • App lifecycle handling (ScenePhase, deep links, notifications)

Here's what doesn't belong:

  • Business logic (that's Domain)
  • API calls (that's the service layer)
  • Complex view hierarchies (that's Features)
  • Shared UI components (that'll be the Design System, coming later in this series)

If your app target is more than a few hundred lines of code, something has leaked out of a module. The app target should be so boring that nobody ever needs to touch it unless the tab structure changes or a new feature ships.

Environment-driven configuration

You can use the same environment pattern for app-level configuration. For example, feature flags:

extension EnvironmentValues {
    @Entry var isReservationsEnabled: Bool = true
}

Then conditionally show tabs:

struct ContentView: View {
    @Environment(\.isReservationsEnabled) private var isReservationsEnabled

    var body: some View {
        TabView {
            Tab("Landmarks", systemImage: "map") {
                NavigationStack {
                    LandmarkListView()
                }
            }

            if isReservationsEnabled {
                Tab("Reservations", systemImage: "calendar") {
                    NavigationStack {
                        ReservationListView()
                    }
                }
            }
        }
    }
}

Feature flags flow through the same environment system as services. No separate infrastructure needed.

Previewing the full app

Because the ServiceEnvironment has a .mock preset, you can preview the entire app composition:

#Preview("Full App - Mock Data") {
    ContentView()
        .withServiceEnvironment(.mock)
}

#Preview("Full App - Empty State") {
    ContentView()
        .withServiceEnvironment(.empty)
}

You can create as many service presets as you need: .mock for happy paths, .empty for empty states, .error for error handling. Each one is just a static property on ServiceEnvironment that assembles the right combination of closures.

The full dependency graph

Here's what the complete architecture looks like now:

LandmarksPackage/
├── Env, Logger, Toolkit         (Common - no dependencies)
├── LandmarksApi                 (depends on Common)
├── LandmarksDomain              (depends on LandmarksApi + Common)
├── LandmarksFeature             (depends on LandmarksDomain + Common)
├── ReservationsApi              (depends on Common)
├── ReservationsDomain           (depends on ReservationsApi + LandmarksDomain + Common)
└── ReservationsFeature          (depends on ReservationsDomain + Common)

App Target/
└── LandmarksApp                 (depends on Features + Domains)

The app target is the only thing that sees the full picture. Everything else has a narrow view: features see domains, domains see APIs, APIs see common utilities. SPM enforces these boundaries at compile time.

What you get

With the app wired up, you now have:

A running app that uses real service implementations hitting your API.

Preview coverage at every level: individual views with feature-level mocks, full tab composition with app-level mocks.

Clear ownership boundaries. The app target owns composition. Features own their UI. Domains own business logic. Nobody reaches across boundaries.

Easy testing. Swap .live for .mock at any level. Unit test domains without SwiftUI. Preview features without a network connection. The architecture makes the right thing easy and the wrong thing impossible.

In the next post, we'll take this a step further by creating dedicated preview app targets. Instead of previewing individual views, you'll be able to build and run mini-apps for each feature with full navigation, real data flows, and none of the compile time overhead of the main app.