A small app can live in one target. A big one cannot, for three reasons: builds get slow, any file can depend on any other so changes ripple everywhere, and several people cannot work in parallel without conflicts. Modularisation and a clear architecture are the answer, and *how would you structure a large app?* is a standard senior question. This lesson shows the practical version: Swift packages, layers, one place that wires everything, and the tools that find performance problems.
Swift Package Manager for your own code
Swift Package Manager (SPM) is built into Xcode. It adds third-party libraries, and it also lets you split *your own* code into local packages, each with its own tests. A package describes its targets in a Package.swift file.
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "ExpensesKit",
platforms: [.iOS(.v17)],
products: [
.library(name: "ExpensesDomain", targets: ["ExpensesDomain"]),
.library(name: "ExpensesData", targets: ["ExpensesData"])
],
targets: [
.target(name: "ExpensesDomain"),
.target(name: "ExpensesData", dependencies: ["ExpensesDomain"]),
.testTarget(name: "ExpensesDomainTests", dependencies: ["ExpensesDomain"])
]
)
- Add it to the app with File > Add Package Dependencies > Add Local, then link the library to the app target.
- Anything another module must see has to be marked `public`, including
inits. The default access level,internal, hides things outside their own module. This is both the pain and the point of modules: they force you to decide what each one offers. - Dependencies must point one way. In the package above,
ExpensesDataknowsExpensesDomain, andExpensesDomainknows nothing aboutExpensesData. If you try to make a circle, the build fails, and that is useful.
Clean Architecture in three layers
Clean Architecture says that *business rules must not depend on details*. For an iOS app that becomes three layers, with the arrows pointing inward.
- Domain: your entities (
Expense), the rules, use cases (AddExpense), and protocols for what the outside must provide (ExpenseRepository). Pure Swift, with no SwiftUI, no SwiftData, no networking. It is the easiest layer to test. - Data: the real work behind those protocols: a SwiftData store, a REST client, a cache. It imports Domain.
- Presentation: SwiftUI views and view models. It asks use cases for things and turns them into screen state.
// ExpensesDomain
public struct Expense: Identifiable, Equatable, Sendable {
public let id: UUID
public var title: String
public var amount: Double
public init(id: UUID = UUID(), title: String, amount: Double) {
self.id = id; self.title = title; self.amount = amount
}
}
public protocol ExpenseRepository: Sendable {
func all() async throws -> [Expense]
func save(_ expense: Expense) async throws
}
public struct AddExpense: Sendable {
private let repository: any ExpenseRepository
public init(repository: any ExpenseRepository) { self.repository = repository }
public func callAsFunction(title: String, amount: Double) async throws {
let trimmed = title.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty, amount > 0 else { throw ExpenseError.invalid }
try await repository.save(Expense(title: trimmed, amount: amount))
}
}
public enum ExpenseError: Error { case invalid }
The rule *a title cannot be empty and the amount must be above zero* now lives in one place, with no screen. It is tested with a fake repository in a few lines, and it works the same if you move from SwiftData to a server next year. A view model calls try await addExpense(title: t, amount: a), and nothing else changes.
The composition root: where everything is wired
Someone has to create the real objects and hand them in. Do it in one place, near the start of the app, called the *composition root*. Everywhere else just receives what it needs, the dependency injection from the first course, now at app scale.
import SwiftUI
struct AppEnvironment {
let expenses: any ExpenseRepository
let addExpense: AddExpense
static func live() -> AppEnvironment {
let repo = SwiftDataExpenseRepository() // from the Data module
return AppEnvironment(expenses: repo, addExpense: AddExpense(repository: repo))
}
static func preview() -> AppEnvironment {
let repo = InMemoryExpenseRepository(sample: true)
return AppEnvironment(expenses: repo, addExpense: AddExpense(repository: repo))
}
}
@main
struct ExpenseTrackerApp: App {
private let environment = AppEnvironment.live()
var body: some Scene {
WindowGroup {
RootView(environment: environment)
}
}
}
Frameworks you will hear about
- The Composable Architecture (TCA) is a popular third-party library, from Point-Free, for state management with strict, testable rules. It is powerful and has a real learning curve. A team should adopt it together, not one person alone.
- Dependency containers such as *Factory* or *Swinject*: libraries that register and resolve dependencies. Many teams find that plain initialiser injection and a small environment struct, as above, are enough.
- Third-party packages in general: pin versions, read the licence, and ask whether the library will still be maintained in two years. Each package is code you now own.
When not to modularise
Every module is extra set-up and extra public keywords. For one developer and a few screens, a single target with clear folders is better. Split when builds become slow, when teams collide, or when a part such as the design system or the network layer is reused by more than one app. Architecture should answer a problem you have, and not one you imagine.
Performance: measure first
Feelings about speed are often wrong. Use Xcode's tools.
- Time Profiler (Instruments) shows which functions use the CPU. Look at the *heaviest stack trace*. The slow part is often not the one you suspect.
- Allocations and Leaks show memory growth, as in lesson 3.
- Hangs and the SwiftUI template in Instruments show moments when the main thread was blocked, and how often a view body ran.
- App launch time matters. Do as little as possible before the first screen: no network calls, no big file reads, no setting up of every service. Start the rest after the first frame.
- Images are the usual cause of memory spikes. A 12-megapixel photo shown in a 100-point thumbnail should be downsampled, for example with
UIImage.preparingThumbnail(of:), and not decoded at full size. - Lists: use lazy containers, stable ids and cheap row views. Avoid
AnyView, which hides the real type from SwiftUI and can make updates slower. - Field data: Xcode's Organizer shows crash and hang reports from real users, and
MetricKitdelivers performance numbers from the field. Your own phone on fast Wi-Fi is not your users.
import XCTest
final class ParsingPerformanceTests: XCTestCase {
func testParsingIsFastEnough() {
let data = makeLargeSampleJSON() // your own helper
measure {
_ = try? JSONDecoder().decode([ExpenseDTO].self, from: data)
}
}
}
measure runs the block several times and reports the average. Xcode lets you save a *baseline*, so a later change that makes the code much slower fails the test.
Common mistakes
- Splitting into twenty modules on day one.
- A Domain module that imports SwiftUI or SwiftData, which breaks the idea.
- Optimising before measuring.
- Putting every service in a global singleton. It hides dependencies and makes tests hard.
Your task
- Move your Expense model, a repository protocol and one use case into a local Swift package called
ExpensesKit, with its own tests. - Add an in-memory repository and use it in a
#Previewthrough anAppEnvironment. - Run the Time Profiler on your app's launch and write down the three heaviest calls.
- Show a large image in a thumbnail, then downsample it and compare memory in the debug navigator.
How to check it worked
- The Domain package builds and tests with no import of SwiftUI or SwiftData.
- Previews work with fake data and no network.
- You can describe the three layers and which way their dependencies point.