Lab 12.2 — Modularize a Monolith
Goal: take a single-target SwiftUI app and extract a DesignSystem package, a Networking package, and one feature package, learning the mechanics and pain points of SPM-based modularization.
Time: ~3 hours.
Prereqs: Xcode 16+, basic familiarity with Swift Package Manager (consuming, not yet authoring).
Setup
Create a new iOS App, “MonolithToModular”. Add the following starter code in ContentView.swift:
import SwiftUI
struct PrimaryButton: View {
let title: String
let action: () -> Void
var body: some View {
Button(action: action) {
Text(title)
.font(.headline)
.foregroundColor(.white)
.padding()
.frame(maxWidth: .infinity)
.background(Color.accentColor)
.cornerRadius(12)
}
}
}
struct Card<Content: View>: View {
let content: Content
init(@ViewBuilder content: () -> Content) { self.content = content() }
var body: some View {
content.padding().background(Color(.secondarySystemBackground)).cornerRadius(16)
}
}
struct Quote: Codable, Identifiable {
let id = UUID()
let content: String
let author: String
enum CodingKeys: String, CodingKey { case content, author }
}
final class QuoteAPI {
static let shared = QuoteAPI()
func fetchQuote() async throws -> Quote {
let url = URL(string: "https://api.quotable.io/random")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(Quote.self, from: data)
}
}
@Observable @MainActor
final class QuoteViewModel {
var quote: Quote?
var isLoading = false
func load() async {
isLoading = true
defer { isLoading = false }
quote = try? await QuoteAPI.shared.fetchQuote()
}
}
struct QuoteView: View {
@State private var vm = QuoteViewModel()
var body: some View {
VStack(spacing: 20) {
if vm.isLoading { ProgressView() }
if let q = vm.quote {
Card {
VStack(alignment: .leading, spacing: 8) {
Text("\u{201C}\(q.content)\u{201D}").font(.body)
Text("\u{2014} \(q.author)").font(.caption).foregroundColor(.secondary)
}
}
}
PrimaryButton(title: "New quote") { Task { await vm.load() } }
}
.padding()
.task { await vm.load() }
}
}
struct ContentView: View {
var body: some View { QuoteView() }
}
Run it, confirm a quote loads.
Tasks
Task 1 — Create the workspace structure (15 min)
Quit Xcode. In Finder, create:
MonolithToModular/
├── MonolithToModular.xcodeproj ← existing
├── App/ ← move .xcodeproj here later (optional)
└── Modules/
├── Core/
│ ├── DesignSystem/
│ └── Networking/
└── Features/
└── Quote/
Reopen Xcode.
Task 2 — Extract DesignSystem (45 min)
In Xcode, File → New → Package → Library. Name DesignSystem. Save under Modules/Core/. In the package’s Package.swift:
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "DesignSystem",
platforms: [.iOS(.v17)],
products: [.library(name: "DesignSystem", targets: ["DesignSystem"])],
targets: [
.target(name: "DesignSystem", path: "Sources/DesignSystem"),
.testTarget(name: "DesignSystemTests", dependencies: ["DesignSystem"]),
]
)
Move PrimaryButton and Card into Sources/DesignSystem/. Mark them public. Mark init and any properties used externally public.
In Xcode’s project navigator, drag the DesignSystem folder into the Xcode project. Then in the app target’s General → Frameworks, Libraries, and Embedded Content, add DesignSystem.
In ContentView.swift, add import DesignSystem. Delete the duplicate PrimaryButton and Card from the app target. Build. Fix public/private errors as they surface.
Task 3 — Extract Networking (45 min)
Same pattern. Create Modules/Core/Networking/Package.swift. Move Quote and QuoteAPI into Sources/Networking/. Mark everything public. Drop the static let shared singleton — replace with constructor injection:
public final class QuoteAPI {
private let session: URLSession
public init(session: URLSession = .shared) { self.session = session }
public func fetchQuote() async throws -> Quote { /* … */ }
}
Link Networking to the app target. Update QuoteViewModel to take a QuoteAPI in its init.
Task 4 — Extract the Quote feature (45 min)
Create Modules/Features/Quote/Package.swift. Declare it depends on DesignSystem and Networking:
dependencies: [
.package(path: "../../Core/DesignSystem"),
.package(path: "../../Core/Networking"),
],
targets: [
.target(name: "Quote", dependencies: ["DesignSystem", "Networking"]),
.testTarget(name: "QuoteTests", dependencies: ["Quote"]),
]
Move QuoteViewModel and QuoteView into Sources/Quote/. Mark public.
Add Quote to the app target. In ContentView.swift, just import Quote and use QuoteView().
The app target now contains: @main struct, root ContentView, and Info.plist. Everything else lives in packages.
Task 5 — Verify isolation (15 min)
Make a trivial change in DesignSystem (e.g., change PrimaryButton corner radius from 12 to 14). Build incrementally. Observe: only DesignSystem and Quote recompile, not Networking. This is the modularization payoff.
Task 6 — Add tests (30 min)
In QuoteTests, write:
import XCTest
import Networking
@testable import Quote
final class QuoteViewModelTests: XCTestCase {
func testLoadSetsQuote() async {
// create a mock URLSession that returns a canned Quote payload
// assert vm.quote != nil after load()
}
}
The fact you can @testable import Quote without the whole app target compiling is another modularization win.
Stretch
- Extract an
Analyticspackage with a no-opLogAnalyticsand aMockAnalyticsfor tests. - Add a second feature package (
Settings) that depends onDesignSystemonly. Verify changing it doesn’t recompileQuote. - Introduce an
Interfaces/QuoteInterfacepackage containing only theQuoteFeatureRoutingprotocol. HaveSettingsdepend onQuoteInterface(notQuote) to navigate to the quote screen. - Measure clean and incremental build times before and after modularization. Record in a table.
Notes
The first time you do this, expect 30 minutes of “why won’t this compile” — public modifiers, missing target memberships, package cycle errors. The second time you’ll do it in 90 minutes. By the fifth you’ll set up a new modular project in 30. This is muscle memory you build by doing.