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 Analytics package with a no-op LogAnalytics and a MockAnalytics for tests.
  • Add a second feature package (Settings) that depends on DesignSystem only. Verify changing it doesn’t recompile Quote.
  • Introduce an Interfaces/QuoteInterface package containing only the QuoteFeatureRouting protocol. Have Settings depend on QuoteInterface (not Quote) 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.


Next: Lab 12.3 — Mock Technical Interview