How to Build Modular Architecture in SwiftUI
Create a local Swift Package inside your Xcode project with separate Core, Domain, and FeatureHome targets. SPM enforces the one-way dependency graph at compile time — no module can import one above it in the stack.
// Packages/AppModules/Package.swift
let package = Package(
name: "AppModules",
platforms: [.iOS(.v17)],
products: [
.library(name: "Core", targets: ["Core"]),
.library(name: "FeatureHome", targets: ["FeatureHome"]),
],
targets: [
.target(name: "Core"),
.target(name: "FeatureHome", dependencies: ["Core"]),
]
)
// Add via: File ▸ Add Package Dependencies ▸ Add Local…
Full implementation
The canonical three-layer split uses Core for shared models, Domain for protocol-driven business logic, and FeatureHome for SwiftUI views and @Observable view-models. The main app target sits above all three and acts as the composition root — it creates concrete implementations and injects them downward. Because each module only knows its immediate dependencies, features stay completely independent of one another.
// ── Packages/AppModules/Package.swift ──────────────────────────
import PackageDescription
let package = Package(
name: "AppModules",
platforms: [.iOS(.v17)],
products: [
.library(name: "Core", targets: ["Core"]),
.library(name: "Domain", targets: ["Domain"]),
.library(name: "FeatureHome", targets: ["FeatureHome"]),
],
targets: [
.target(name: "Core", path: "Sources/Core"),
.target(name: "Domain", dependencies: ["Core"], path: "Sources/Domain"),
.target(name: "FeatureHome", dependencies: ["Domain", "Core"], path: "Sources/FeatureHome"),
.testTarget(name: "DomainTests", dependencies: ["Domain"], path: "Tests/DomainTests"),
]
)
// ── Sources/Core/Item.swift ─────────────────────────────────────
public struct Item: Identifiable, Sendable {
public let id: UUID
public var title: String
public init(id: UUID = UUID(), title: String) {
self.id = id
self.title = title
}
}
// ── Sources/Domain/ItemRepository.swift ────────────────────────
import Core
public protocol ItemRepository: Sendable {
func fetchAll() async throws -> [Item]
func add(_ item: Item) async throws
}
@MainActor
public final class InMemoryItemRepository: ItemRepository {
private var store: [Item] = []
public init() {}
public func fetchAll() async throws -> [Item] { store }
public func add(_ item: Item) async throws { store.append(item) }
}
// ── Sources/FeatureHome/HomeView.swift ─────────────────────────
import SwiftUI
import Core
import Domain
@MainActor @Observable
public final class HomeViewModel {
public var items: [Item] = []
private let repository: any ItemRepository
public init(repository: any ItemRepository) {
self.repository = repository
}
public func load() async {
items = (try? await repository.fetchAll()) ?? []
}
public func addItem(title: String) async {
try? await repository.add(Item(title: title))
await load()
}
}
public struct HomeView: View {
@State private var vm: HomeViewModel
public init(vm: HomeViewModel) { _vm = State(wrappedValue: vm) }
public var body: some View {
NavigationStack {
List(vm.items) { item in
Label(item.title, systemImage: "checkmark.circle")
.accessibilityLabel(item.title)
}
.navigationTitle("Home")
.overlay { if vm.items.isEmpty { ContentUnavailableView("No items", systemImage: "tray") } }
}
.task { await vm.load() }
}
}
// ── App/MyApp.swift ─────────────────────────────────────────────
import SwiftUI
import FeatureHome
import Domain
@main
struct MyApp: App {
private let repo: any ItemRepository = InMemoryItemRepository()
var body: some Scene {
WindowGroup {
HomeView(vm: HomeViewModel(repository: repo))
}
}
}
#Preview {
let repo = InMemoryItemRepository()
let vm = HomeViewModel(repository: repo)
return HomeView(vm: vm)
.task {
try? await repo.add(Item(title: "Buy groceries"))
try? await repo.add(Item(title: "Review PR"))
await vm.load()
}
}
How it works
- Layered dependency graph in Package.swift. The targets array declares FeatureHome → Domain → Core. SPM builds this graph at compile time and rejects any circular import with a hard error. Core physically cannot import FeatureHome — the architecture is structurally enforced, not just a convention.
- Public access control as an API contract. Swift generates internal memberwise initializers and hides all non-public symbols at module boundaries. Every exported type in Core — including Item's init — must be explicitly marked public. This forces you to deliberately design the surface area each module exposes.
- Protocol-driven Domain layer enables test doubles. ItemRepository is a protocol in Domain. The main app injects InMemoryItemRepository; unit tests in DomainTests inject a mock. HomeView and HomeViewModel in FeatureHome never change regardless of which concrete type backs the repository.
- @Observable replaces ObservableObject (iOS 17+). HomeViewModel is annotated with @Observable and @MainActor. SwiftUI's observation runtime tracks only the specific properties accessed during a given render pass — items in this case — and skips re-renders when unrelated state changes. No @Published wrappers required.
- Composition root keeps features decoupled. MyApp is the only place where concrete types (InMemoryItemRepository) are mentioned. Adding a second feature module — say FeatureSettings — requires zero changes to FeatureHome, Domain, or Core. You add the new target in Package.swift and wire it up only in MyApp.
Variants
Shared DesignSystem module
Add a DesignSystem target between Core and your feature modules. It holds reusable SwiftUI components — buttons, color tokens, typography — that every feature imports, eliminating styling duplication without creating a feature-to-feature dependency.
// Package.swift — inserting a DesignSystem layer
targets: [
.target(name: "Core"),
.target(name: "DesignSystem", dependencies: ["Core"]),
.target(name: "Domain", dependencies: ["Core"]),
.target(name: "FeatureHome", dependencies: ["Domain", "DesignSystem"]),
.target(name: "FeatureSettings", dependencies: ["Domain", "DesignSystem"]),
]
// Sources/DesignSystem/PrimaryButton.swift
import SwiftUI
public struct PrimaryButton: View {
let title: String
let action: () -> Void
public init(_ title: String, action: @escaping () -> Void) {
self.title = title
self.action = action
}
public var body: some View {
Button(title, action: action)
.buttonStyle(.borderedProminent)
.controlSize(.large)
.accessibilityLabel(title)
}
}
Per-module unit test targets
Pair every target with a .testTarget. Because Domain contains pure Swift — no SwiftUI, no UIKit — its tests run as a native host executable without launching a simulator. Feedback loops drop to under a second per module. Use @testable import Domain in test files to reach internal symbols when needed, while keeping your public API surface intentionally narrow.
Common pitfalls
- ⚠️ Omitting platforms in Package.swift. If you leave out .iOS(.v17), SPM defaults to an ancient deployment target. You'll get cryptic "unavailable on this platform" errors for @Observable, the updated .onChange(of:) two-argument signature, ContentUnavailableView, and other iOS 17 APIs — even though your Xcode project itself targets iOS 17. Set it explicitly in Package.swift.
- ⚠️ Forgetting public init on exported types. Swift generates a silent internal memberwise initializer by default. Another module can see your type but not construct it, producing a confusing "initializer is inaccessible" error at the call site. Always write public init explicitly for every type you intend to export.
- ⚠️ Defining EnvironmentKey types in more than one module. If two feature modules independently declare the same EnvironmentKey struct, Swift treats them as distinct types at runtime — your injected value will be invisible in the other feature. Always place shared environment keys in Core or DesignSystem so every module refers to the same type identity.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement modular architecture in SwiftUI for iOS 17+. Use Swift Package Manager with a local package containing Core, Domain, and FeatureHome targets. Enforce one-way dependencies: FeatureHome → Domain → Core. Make it accessible (VoiceOver labels on all interactive views). Add a #Preview with realistic sample data pre-loaded.
Drop this into Soarias during the Build phase once your screen flow is finalized — Claude Code will scaffold the full package folder structure, write the Package.swift, stub each module, and wire up the composition root in your @main App struct in a single pass.
Related
FAQ
Does this work on iOS 16?
Yes — change .iOS(.v17) to .iOS(.v16) in Package.swift and replace @Observable with ObservableObject plus @Published properties. The SPM module structure, dependency graph, and composition-root pattern are identical on iOS 16. You lose fine-grained observation tracking but gain nothing in architectural complexity — it's a one-line downgrade.
Can I share these modules with a macOS or watchOS target?
Absolutely. Add .macOS(.v14) or .watchOS(.v10) to the platforms array in Package.swift. Guard any UIKit-specific APIs with #if os(iOS). In practice, Core and Domain — pure Swift with no UIKit or SwiftUI dependencies — compile for every platform with zero changes. Only FeatureHome typically needs platform guards.
What's the UIKit equivalent?
UIKit apps use the exact same local SPM package approach. Feature modules expose UIViewController subclasses instead of SwiftUI View types, and the composition root assembles them in a UIApplicationDelegate or SceneDelegate. You can also migrate incrementally — wrap SwiftUI views in UIHostingController inside an otherwise UIKit feature module and the modular SPM graph stays untouched.
Last reviewed: 2026-05-12 by the Soarias team.