The to-do app from the earlier articles in this series lives in one Xcode project with one package beside it. A second app that needs the same code has two poor options: copy the files, or point a second project at a folder inside the first. A monorepo, one repository holding several apps and the code they share, avoids both, and an Xcode workspace ties it together: it lists projects and packages, opens them in one window, and builds them as one.

This article turns the todo-list folder from The Xcode Build Process from the Command Line, with make into a folder called my-apps that holds two iOS apps, the existing TodoList and a new Shopping list, and three local Swift packages: TodoKit (the to-do model), CoreKit (a text-cleaning rule both apps use), and DesignSystem (a checklist row both apps draw). One workspace, MyApps.xcworkspace, holds all five. At the end you will change one line in DesignSystem, rebuild with make build, and run each app to see both pick up the change on the simulator. A top-level Makefile tests every package in dependency order, builds every app, runs the UI test, and runs or archives any one app with APP=.

What you end up with

  • my-apps/MyApps.xcworkspace, listing two app projects under Apps/ and three packages under Packages/.
  • App projects that name the package products they link and record no path to any package, so the workspace is the only place an app finds its packages.
  • CoreKit used by TodoKit and by Shopping, and DesignSystem used by both apps.
  • Shared schemes for both apps, saved in the repository.
  • A Makefile with make test for everything and APP=TodoList|Shopping for settings, run, screenshot, and archive.

Prerequisites

  • The finished project from The Xcode Build Process from the Command Line, with make: the todo-list folder with the TodoList project, the TodoKit package, the Config folder of .xcconfig files, the Makefile, and the TodoList-iPhone simulator. Its UI test leaves a to-do named “Buy milk”, marked done, in the app, which Step 11 shows.
  • Xcode 27 or later on a Mac with Apple silicon. Validated on Xcode 27.1 (build 27A9275) with Swift 6.4, on macOS 27.0.1.
  • Steps 2, 3, 6, 7, 8 and 9 are done in Xcode; a command after each one checks the result.

Run the commands in Step 1 from the folder that contains todo-list, and every later command from inside my-apps.

Step 1: Move the app and package into a monorepo layout

A monorepo needs a place for each kind of thing, so that a third app or a fourth package has an obvious home. This article uses two folders: Apps/ for Xcode app projects and Packages/ for Swift packages. Start by deleting the old build output, because build/ caches full paths to the folders you are about to move. Then rename the folder and move its contents:

make -C todo-list clean
mv todo-list my-apps
cd my-apps
mkdir Apps Packages
mv TodoList Config Apps/
mv TodoKit Packages/

Config moves into Apps/ beside TodoList on purpose. The project records each .xcconfig file as ../Config/<name>, relative to the folder that holds TodoList.xcodeproj. Moving both folders together keeps that path true.

The package path did not survive the move. The project recorded TodoKit as ../TodoKit (Build a Swift Package with Swift Package Manager on macOS showed the relativePath line in project.pbxproj), and TodoKit is no longer beside the project. Building the project now fails. grep keeps the error lines and sed shortens the current folder to .:

xcodebuild -project Apps/TodoList/TodoList.xcodeproj -scheme TodoList -derivedDataPath build -destination 'platform=iOS Simulator,name=TodoList-iPhone' -quiet build 2>&1 | grep -A1 'error:' | sed "s#$PWD#.#g"
xcodebuild: error: Could not resolve package dependencies:
  the package at './Apps/TodoKit' cannot be accessed (./Apps/TodoKit doesn't exist in file system)

You could repair the path in Xcode. The next two steps remove it instead, so that no app project records where a package lives.

Step 2: Create the workspace

A workspace is a folder ending in .xcworkspace whose contents.xcworkspacedata file lists the projects and packages it contains, each by a path relative to the workspace. Building through a workspace makes every package in it available to every project in it. This is a separate, top-level workspace, not the private project.xcworkspace every .xcodeproj already contains. Create it in Xcode:

  1. Choose File → New → Workspace….
  2. Name it MyApps, choose the my-apps folder, and click Save. Xcode opens an empty workspace window.

Add the package and the project with File → Add Files to “MyApps”…, once for each:

  1. Select the Packages/TodoKit folder and click Add.
  2. In the options sheet that appears, leave Action at Reference files in place and click Finish.
  3. Click an empty area of the Project navigator, the file list on the left, so nothing is selected. With a row selected, the next item is added inside that row instead of at the top of the workspace.
  4. Repeat with Apps/TodoList/TodoList.xcodeproj.

The options sheet should show only Action and Targets. If it also shows Groups, Xcode is about to add the item inside a project rather than to the workspace: click Cancel, clear the selection, and try again.

Check what Xcode wrote:

cat MyApps.xcworkspace/contents.xcworkspacedata
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
   version = "1.0">
   <FileRef
      location = "group:Packages/TodoKit">
   </FileRef>
   <FileRef
      location = "group:Apps/TodoList/TodoList.xcodeproj">
   </FileRef>
</Workspace>

group: means relative to the workspace’s own folder, my-apps. These two lines are now the only record of where the package and the project live. Xcode keeps your window layout in MyApps.xcworkspace/xcuserdata/, which the .gitignore from Build Your First SwiftUI App on macOS already ignores, so the workspace needs no new entry there.

Step 3: Let the workspace supply TodoKit

The project still holds its own broken reference to ../TodoKit. Delete it, so that the app finds TodoKit through the workspace:

  1. In the Project navigator, click TodoList, the blue project icon.
  2. In the editor, select TodoList under PROJECT (not under TARGETS), then the Package Dependencies tab.
  3. Select the TodoKit row, whose location is ../TodoKit, and click the − button below the list.

Xcode removes the reference and keeps the app target’s link to the TodoKit product. Check both:

grep -c relativePath Apps/TodoList/TodoList.xcodeproj/project.pbxproj
grep -o 'productName = TodoKit;' Apps/TodoList/TodoList.xcodeproj/project.pbxproj
0
productName = TodoKit;

The project now says only “link the product named TodoKit”. The workspace says where that product comes from, which is why the project no longer builds on its own:

xcodebuild -project Apps/TodoList/TodoList.xcodeproj -scheme TodoList -derivedDataPath build -destination 'platform=iOS Simulator,name=TodoList-iPhone' -quiet build 2>&1 | grep 'error:' | sed "s#$PWD#.#g"
./Apps/TodoList/TodoList.xcodeproj: error: Missing package product 'TodoKit' (in target 'TodoList' from project 'TodoList')

Built through the workspace, it succeeds, and xcodebuild -list shows a scheme for the package as well as the app:

xcodebuild -workspace MyApps.xcworkspace -scheme TodoList -derivedDataPath build -destination 'platform=iOS Simulator,name=TodoList-iPhone' -quiet build
xcodebuild -list -quiet -workspace MyApps.xcworkspace
Information about workspace "MyApps":
    Schemes:
        TodoKit
        TodoList

The build prints nothing when it succeeds, though it may print the IDERunDestination log line the previous article described; the output blocks in this article leave that line out. From here on, every build goes through -workspace. Moving a package means changing one line in contents.xcworkspacedata, not every app project that uses it.

Step 4: Create the CoreKit package

CoreKit holds code with no knowledge of to-dos or shopping. Its first rule is the one TodoKit already has for titles: trim the whitespace a person typed around an entry, and reject an entry that is blank once trimmed. Shopping needs the same rule for item names, so it moves to a package both can use. Create the package with SwiftPM, as the package article did for TodoKit, and delete the two template files the listings below replace:

mkdir Packages/CoreKit
cd Packages/CoreKit
swift package init --type library
cd ../..
rm Packages/CoreKit/Sources/CoreKit/CoreKit.swift Packages/CoreKit/Tests/CoreKitTests/CoreKitTests.swift
Creating library package: CoreKit
Creating Package.swift
Creating .gitignore
Creating Sources
Creating Sources/CoreKit/CoreKit.swift
Creating Tests/
Creating Tests/CoreKitTests/
Creating Tests/CoreKitTests/CoreKitTests.swift

Create the file

touch Packages/CoreKit/Package.swift

The file exists already. Replace its contents with the listing below.

Add the code: Packages/CoreKit/Package.swift

// swift-tools-version: 6.4
import PackageDescription

let package = Package(
    name: "CoreKit",
    platforms: [
        .iOS(.v17),
        .macOS(.v14),
    ],
    products: [
        .library(name: "CoreKit", targets: ["CoreKit"]),
    ],
    targets: [
        .target(
            name: "CoreKit",
            swiftSettings: [
                .enableUpcomingFeature("ApproachableConcurrency"),
            ]
        ),
        .testTarget(
            name: "CoreKitTests",
            dependencies: ["CoreKit"],
            swiftSettings: [
                .enableUpcomingFeature("ApproachableConcurrency"),
            ]
        ),
    ]
)

Create the file

touch Packages/CoreKit/Sources/CoreKit/TextInput.swift

Add the code: Packages/CoreKit/Sources/CoreKit/TextInput.swift

import Foundation

/// Rules for text a person types into an app.
public enum TextInput {
    /// Trims surrounding whitespace and returns nil when nothing is left,
    /// so a blank entry can be rejected before it is stored.
    public static func cleaned(_ raw: String) -> String? {
        let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
        return trimmed.isEmpty ? nil : trimmed
    }
}

Create the file

touch Packages/CoreKit/Tests/CoreKitTests/TextInputTests.swift

Add the code: Packages/CoreKit/Tests/CoreKitTests/TextInputTests.swift

import Testing
import CoreKit

struct TextInputTests {
    @Test(arguments: [
        ("Apples", "Apples"),
        ("  Oat milk  ", "Oat milk"),
        ("\tBread ", "Bread"),
    ])
    func textIsTrimmed(raw: String, expected: String) {
        #expect(TextInput.cleaned(raw) == expected)
    }

    @Test(arguments: ["", "   ", "\t"])
    func blankTextIsRejected(raw: String) {
        #expect(TextInput.cleaned(raw) == nil)
    }
}

Detailed breakdown

  • The manifest matches TodoKit’s. The same platforms minimums let any app in the workspace use it, and macOS 14 lets swift test run its tests on the Mac without a simulator. The template’s comments are dropped; the package article explained each part.
  • TextInput is an enum with no cases. It cannot be instantiated, so it works as a namespace: callers write TextInput.cleaned(raw), which says where the rule comes from. public makes it visible to code outside the CoreKit module; without it, neither app nor TodoKit could call it.
  • The tests cover both halves of the rule, trimming and rejecting. Swift Testing’s arguments: runs each test once per input; each run is a test case, which is why the output counts 2 tests but 3 test cases each. Step 11 breaks this function on purpose to show how make reports a failure here.
  • SwiftPM wrote a .gitignore into the package. It ignores the package’s .build/ folder, which the top-level .gitignore also covers.

Step 5: Create the DesignSystem package

A design system is a set of shared visual building blocks that keeps several apps looking alike. This one starts with a single view, CheckRow: a title with a symbol showing whether the item is done. The to-do app draws that row today in its own TodoRow view; moving it here lets Shopping draw the same row. Create the package the same way:

mkdir Packages/DesignSystem
cd Packages/DesignSystem
swift package init --type library
cd ../..
rm Packages/DesignSystem/Sources/DesignSystem/DesignSystem.swift Packages/DesignSystem/Tests/DesignSystemTests/DesignSystemTests.swift

Create the file

touch Packages/DesignSystem/Package.swift

The file exists already. Replace its contents with the listing below.

Add the code: Packages/DesignSystem/Package.swift

// swift-tools-version: 6.4
import PackageDescription

let package = Package(
    name: "DesignSystem",
    platforms: [
        .iOS(.v17),
        .macOS(.v14),
    ],
    products: [
        .library(name: "DesignSystem", targets: ["DesignSystem"]),
    ],
    targets: [
        .target(
            name: "DesignSystem",
            swiftSettings: [
                .enableUpcomingFeature("ApproachableConcurrency"),
            ]
        ),
        .testTarget(
            name: "DesignSystemTests",
            dependencies: ["DesignSystem"],
            swiftSettings: [
                .enableUpcomingFeature("ApproachableConcurrency"),
            ]
        ),
    ]
)

Create the file

touch Packages/DesignSystem/Sources/DesignSystem/CheckRow.swift

Add the code: Packages/DesignSystem/Sources/DesignSystem/CheckRow.swift

import SwiftUI

/// One line of a checklist: a title, and a symbol that shows whether it is done.
public struct CheckRow: View {
    let title: String
    let isDone: Bool

    public init(title: String, isDone: Bool) {
        self.title = title
        self.isDone = isDone
    }

    public var body: some View {
        Label {
            Text(title)
                .strikethrough(isDone)
                .foregroundStyle(isDone ? .secondary : .primary)
        } icon: {
            Image(systemName: Self.symbolName(isDone: isDone))
        }
    }

    /// The SF Symbol for each state, public so the tests can check it.
    nonisolated public static func symbolName(isDone: Bool) -> String {
        isDone ? "checkmark.circle.fill" : "circle"
    }
}

Create the file

touch Packages/DesignSystem/Tests/DesignSystemTests/CheckRowTests.swift

Add the code: Packages/DesignSystem/Tests/DesignSystemTests/CheckRowTests.swift

import AppKit
import Testing
import DesignSystem

struct CheckRowTests {
    // Image(systemName:) draws nothing for a name that does not exist, and
    // reports no error, so look each name up in the Mac's symbol library.
    @Test(arguments: [false, true])
    func symbolExists(isDone: Bool) {
        let name = CheckRow.symbolName(isDone: isDone)
        #expect(NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil)
    }

    @Test func doneAndNotDoneDiffer() {
        #expect(CheckRow.symbolName(isDone: true) != CheckRow.symbolName(isDone: false))
    }
}

Detailed breakdown

  • CheckRow is the old TodoRow with plain values. TodoRow took a TodoItem; CheckRow takes a title and a done flag, so it does not depend on TodoKit and an app with a different model, such as Shopping, can use it.
  • A public init is required. Swift generates a memberwise initializer for a struct, but it is internal, usable only inside the DesignSystem module. Without the explicit public init, the apps could see CheckRow and still not create one.
  • The symbols are SF Symbols, Apple’s library of icons that ship with the system, named by strings such as checkmark.circle.fill. A misspelled name compiles without a warning and shows an empty space. symbolName(isDone:) is public so the test can read the names, and the test looks each one up with NSImage, which returns nil for an unknown name. The test target imports AppKit because the tests run on the Mac with swift test; the package itself uses only SwiftUI. Running the DesignSystem scheme’s tests on an iOS simulator from Xcode would fail, because iOS has no AppKit.
  • nonisolated lets the tests call symbolName. A SwiftUI view runs on the main actor, so its members are main-actor-isolated by default, and the test structs are not. Without nonisolated, each call in the tests produces warning: call to main actor-isolated static method 'symbolName(isDone:)' in a synchronous nonisolated context, which the Makefile’s result filter would hide. The function only returns a string, so it needs no actor.
  • doneAndNotDoneDiffer guards against both states being given the same symbol, which would make the row’s state invisible.

Step 6: Add the packages to the workspace, then connect TodoKit to CoreKit

The two new packages are folders on disk that nothing refers to yet. Add each to the workspace with File → Add Files to “MyApps”…, as in Step 2: clear the selection in the Project navigator first, pick Packages/CoreKit, click Add, check that the sheet shows no Groups row, and click Finish. Then do the same for Packages/DesignSystem.

grep location MyApps.xcworkspace/contents.xcworkspacedata
      location = "group:Packages/TodoKit">
      location = "group:Apps/TodoList/TodoList.xcodeproj">
      location = "group:Packages/CoreKit">
      location = "group:Packages/DesignSystem">

Now make TodoKit use CoreKit’s rule instead of its own copy. A package cannot see the workspace, so it names the packages it depends on in its own manifest, by path. This is the one kind of package path that stays in the repository, and it points sideways within Packages/, so it survives moving Packages/ as a whole.

Create the file

touch Packages/TodoKit/Package.swift

The file exists already. Replace its contents with the listing below.

Add the code: Packages/TodoKit/Package.swift

// swift-tools-version: 6.4
import PackageDescription

let package = Package(
    name: "TodoKit",
    platforms: [
        .iOS(.v17),
        .macOS(.v14),
    ],
    products: [
        .library(name: "TodoKit", targets: ["TodoKit"]),
    ],
    dependencies: [
        .package(path: "../CoreKit"),
    ],
    targets: [
        .target(
            name: "TodoKit",
            dependencies: ["CoreKit"],
            swiftSettings: [
                .enableUpcomingFeature("ApproachableConcurrency"),
            ]
        ),
        .testTarget(
            name: "TodoKitTests",
            dependencies: ["TodoKit"],
            swiftSettings: [
                .enableUpcomingFeature("ApproachableConcurrency"),
            ]
        ),
    ]
)

Create the file

touch Packages/TodoKit/Sources/TodoKit/TodoItem.swift

The file exists already. Replace its contents with the listing below; only the import CoreKit line and the body of cleanedTitle change.

Add the code: Packages/TodoKit/Sources/TodoKit/TodoItem.swift

import CoreKit
import Foundation
import SwiftData

@Model
public final class TodoItem {
    public var title: String
    public var isDone: Bool
    public var createdAt: Date

    public init(title: String, createdAt: Date = .now) {
        self.title = title
        self.isDone = false
        self.createdAt = createdAt
    }

    /// Trims surrounding whitespace and returns nil when nothing is left,
    /// so a blank entry can never become a to-do.
    public static func cleanedTitle(_ raw: String) -> String? {
        TextInput.cleaned(raw)
    }

    /// The line shown under the list: what is left to do.
    public static func summary(of items: [TodoItem]) -> String {
        let remaining = items.filter { !$0.isDone }.count
        if items.isEmpty { return "Nothing to do" }
        if remaining == 0 { return "All done" }
        return "\(remaining) remaining"
    }
}

extension ModelContext {
    /// Inserts a new to-do if the title is not blank, and returns it.
    @discardableResult
    public func addTodo(titled raw: String) -> TodoItem? {
        guard let title = TodoItem.cleanedTitle(raw) else { return nil }
        let item = TodoItem(title: title)
        insert(item)
        return item
    }
}

Detailed breakdown

  • A package dependency has two halves. The package-level dependencies entry, .package(path: "../CoreKit"), tells SwiftPM where to find the package. The target-level dependencies: ["CoreKit"] says which target imports which product. Leave out the second and import CoreKit fails with no such module 'CoreKit'.
  • The path is relative to TodoKit’s folder, so ../CoreKit is Packages/CoreKit. swift test --package-path Packages/TodoKit follows it on its own; Xcode follows it too, and finds the same folder the workspace lists.
  • cleanedTitle keeps its name and its tests. The app and the five TodoKit tests still call TodoItem.cleanedTitle, so they now test CoreKit’s rule through TodoKit, with no change to either.
  • Add the packages to the workspace before this edit. Once TodoKit depends on CoreKit, the Project navigator shows CoreKit in its own Package Dependencies section. During validation, an attempt to add CoreKit to the workspace after this edit showed the Groups row from Step 2, with a project selected; clearing the selection fixed it. Adding the packages first keeps that section out of the way.

Run the three packages’ tests on the Mac:

SWT_SF_SYMBOLS_ENABLED=0 swift test --package-path Packages/CoreKit 2>&1 | tail -1
SWT_SF_SYMBOLS_ENABLED=0 swift test --package-path Packages/DesignSystem 2>&1 | tail -1
SWT_SF_SYMBOLS_ENABLED=0 swift test --package-path Packages/TodoKit 2>&1 | tail -1
✔ Test run with 2 tests in 1 suite passed after 0.001 seconds.
✔ Test run with 2 tests in 1 suite passed after 0.019 seconds.
✔ Test run with 5 tests in 1 suite passed after 0.010 seconds.

SWT_SF_SYMBOLS_ENABLED=0 makes Swift Testing print ✔ and ✘; without it, each line starts with an SF Symbol that most terminals cannot draw. Your timings will differ.

Step 7: Create the Shopping app inside the workspace

The second app shows the shared packages work for more than one project. Create it with Xcode’s App template, as the first article did for TodoList, but let the wizard put it into the workspace:

  1. Choose File → New → Project…, then Choose Template… at the bottom of the palette, then the iOS tab, App, and Next.
  2. Set Product Name to Shopping, Team to None, Organization Identifier to com.example (check it: Xcode reuses the last one you typed), Interface to SwiftUI, Language to Swift, Testing System to None, and Storage to None. The bundle identifier below the fields reads com.example.Shopping. Click Next.
  3. In the save panel, open my-apps/Apps, leave Create Git repository on my Mac unchecked, set Add to to MyApps and Group to MyApps, and click Create.

Shopping has no test target because its logic lives in the packages, which have their own tests. Check where it landed:

grep -c Shopping MyApps.xcworkspace/contents.xcworkspacedata
ls Apps/Shopping Apps/Shopping/Shopping
1
Apps/Shopping:
Shopping
Shopping.xcodeproj

Apps/Shopping/Shopping:
Assets.xcassets
ContentView.swift
ShoppingApp.swift

Link the two package products it uses. In Xcode, the app target’s Build Phases tab holds the same list as the Frameworks, Libraries, and Embedded Content section of the General tab. This article uses Build Phases, because showing the General tab can write settings into the target; Step 8 shows where that matters.

  1. Click Shopping, the blue project icon in the Project navigator, select the Shopping target under TARGETS, and open Build Phases.
  2. Expand Link Binary With Libraries and click +.
  3. The sheet lists a Workspace section with CoreKit, DesignSystem and TodoKit. Expand CoreKit, select the CoreKit library inside it, and click Add.
  4. Click + again and add the DesignSystem library the same way.
grep -E 'productName = (CoreKit|DesignSystem);|relativePath' Apps/Shopping/Shopping.xcodeproj/project.pbxproj
			productName = CoreKit;
			productName = DesignSystem;

Two product names and no relativePath: Shopping, like TodoList after Step 3, relies on the workspace to find its packages. Xcode also added an empty Frameworks group to the project; it does no harm.

Now write the app. The template’s ShoppingApp.swift already shows ContentView, so only that file changes.

Create the file

touch Apps/Shopping/Shopping/ContentView.swift

The file exists already. Replace its contents with the listing below.

Add the code: Apps/Shopping/Shopping/ContentView.swift

import SwiftUI
import CoreKit
import DesignSystem

struct ShoppingItem: Identifiable {
    let id = UUID()
    var name: String
    var isBought = false
}

struct ContentView: View {
    // Kept in memory, so every launch starts from these two items.
    @State private var items = [
        ShoppingItem(name: "Apples"),
        ShoppingItem(name: "Bread", isBought: true),
    ]
    @State private var newName = ""

    var body: some View {
        NavigationStack {
            List {
                Section {
                    HStack {
                        TextField("New item", text: $newName)
                            .onSubmit(add)
                        Button("Add", action: add)
                            .disabled(TextInput.cleaned(newName) == nil)
                    }
                }
                Section {
                    ForEach($items) { $item in
                        Button {
                            item.isBought.toggle()
                        } label: {
                            CheckRow(title: item.name, isDone: item.isBought)
                        }
                        .buttonStyle(.plain)
                    }
                    .onDelete { items.remove(atOffsets: $0) }
                }
            }
            .navigationTitle("Shopping")
        }
    }

    private func add() {
        guard let name = TextInput.cleaned(newName) else { return }
        items.append(ShoppingItem(name: name))
        newName = ""
    }
}

#Preview {
    ContentView()
}

Detailed breakdown

  • Both imports are workspace packages. TextInput.cleaned decides whether Add is enabled and what gets stored, the same rule TodoList applies to to-do titles through TodoKit. CheckRow draws each item.
  • The list lives in @State, so it is kept in memory and resets on every launch. That keeps this app to one file; the two starting items, one bought and one not, show both of CheckRow’s states as soon as the app opens.
  • ForEach($items) { $item in ... } iterates over bindings, so tapping a row can flip isBought in place.
  • .buttonStyle(.plain) stops the button from tinting the row’s text in the accent color. Without it, Shopping’s titles turn blue while TodoList’s stay black, so the shared row looks different in each app.

Step 8: Use CheckRow in TodoList

TodoList links TodoKit already. It needs DesignSystem too, and then its own TodoRow can go. Link the library through Build Phases, not the General tab. In Xcode 27.1, merely showing the General tab for this target wrote INFOPLIST_KEY_CFBundleDisplayName = "To-Do Dev" into both the Debug and Release settings of the TodoList target. Target settings sit above the project’s .xcconfig files, so Release builds were then named To-Do Dev as well. If the editor opens on General when you select the target, switch tabs and run the check below.

  1. Click TodoList, the blue project icon, select the TodoList target, and open the Build Phases tab.
  2. Expand Link Binary With Libraries, click +, expand DesignSystem, select its library, and click Add.

make settings CONFIG=Release in Step 11 checks the display name again, and Troubleshooting has the fix.

grep -c CFBundleDisplayName Apps/TodoList/TodoList.xcodeproj/project.pbxproj
grep -o 'productName = [A-Za-z]*;' Apps/TodoList/TodoList.xcodeproj/project.pbxproj
0
productName = TodoList;
productName = TodoListUITests;
productName = TodoKit;
productName = DesignSystem;

The first two productName lines are the targets’ own; the last two are the linked packages.

Create the file

touch Apps/TodoList/TodoList/ContentView.swift

The file exists already. Replace its contents with the listing below: it imports DesignSystem, draws CheckRow inside each NavigationLink, and no longer defines TodoRow.

Add the code: Apps/TodoList/TodoList/ContentView.swift

import SwiftUI
import SwiftData
import DesignSystem
import TodoKit

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    @Query(sort: \TodoItem.createdAt) private var items: [TodoItem]
    @State private var newTitle = ""

    var body: some View {
        NavigationStack {
            List {
                Section {
                    HStack {
                        TextField("New to-do", text: $newTitle)
                            .accessibilityIdentifier("newTodoField")
                            .onSubmit(add)
                        Button("Add", action: add)
                            .accessibilityIdentifier("addTodoButton")
                            .disabled(TodoItem.cleanedTitle(newTitle) == nil)
                    }
                }
                Section {
                    ForEach(items) { item in
                        NavigationLink(value: item) {
                            CheckRow(title: item.title, isDone: item.isDone)
                        }
                    }
                    .onDelete(perform: delete)
                } footer: {
                    Text(TodoItem.summary(of: items))
                        .accessibilityIdentifier("summary")
                }
            }
            .navigationTitle("To-Do")
            .navigationDestination(for: TodoItem.self) { item in
                TodoDetailView(item: item)
            }
        }
    }

    private func add() {
        if modelContext.addTodo(titled: newTitle) != nil {
            newTitle = ""
            save()
        }
    }

    private func delete(at offsets: IndexSet) {
        for index in offsets {
            modelContext.delete(items[index])
        }
        save()
    }

    // SwiftData autosaves only now and then; save explicitly so a
    // crash or force-quit right after a change does not lose it.
    private func save() {
        do {
            try modelContext.save()
        } catch {
            print("Save failed: \(error)")
        }
    }
}

#Preview {
    ContentView()
        .modelContainer(for: TodoItem.self, inMemory: true)
}

Detailed breakdown

  • CheckRow(title: item.title, isDone: item.isDone) replaces TodoRow(item: item). The row looks the same as before, because CheckRow is TodoRow’s body moved into the package.
  • The UI test still finds the row. It looks for a button labeled with the to-do’s title, and a NavigationLink containing a Label is still a button with that label.
  • import DesignSystem needs the link from the steps above. Without it, the import fails, usually with no such module 'DesignSystem'.

Step 9: Share the app schemes

Each scheme names what to build and how to run, test, and archive it; make test relies on the TodoList scheme’s test action to run the UI test. So far Xcode has generated the schemes each time it opened the workspace, keeping only their order and visibility in xcuserdata/, which .gitignore excludes. A fresh clone gets newly generated schemes, and any change you make to one stays on your Mac. A shared scheme is saved inside the project, in xcshareddata/xcschemes/, and is committed like any other file.

Xcode 27.1 lists generated schemes with Shared already ticked under Product → Scheme → Manage Schemes…, but writes no file until the scheme is opened in Edit Scheme. Save each app’s scheme:

  1. Choose Product → Scheme → TodoList, then Product → Scheme → Edit Scheme….
  2. Check that Shared at the bottom of the sheet is ticked, and click Close.
  3. Repeat with Product → Scheme → Shopping.
find Apps -name '*.xcscheme'
Apps/TodoList/TodoList.xcodeproj/xcshareddata/xcschemes/TodoList.xcscheme
Apps/Shopping/Shopping.xcodeproj/xcshareddata/xcschemes/Shopping.xcscheme

find lists them in whatever order the file system returns, so yours may differ. The package schemes (CoreKit, DesignSystem, TodoKit) are left generated: the Makefile tests packages with swift test, which does not use schemes.

Step 10: Replace the Makefile

The previous Makefile built one project. This one builds the workspace, knows which apps and packages exist, and runs the package tests in dependency order. Replace the whole file.

Create the file

touch Makefile

Add the code: Makefile

WORKSPACE   := MyApps.xcworkspace
APPS        := TodoList Shopping
# Leaves first: each package comes after the packages it depends on.
PACKAGES    := CoreKit DesignSystem TodoKit
# The apps with a UI-test target.
UI_TESTED   := TodoList
# The app that settings, run, screenshot, and archive act on.
APP         := TodoList
ifeq ($(filter $(APP),$(APPS)),)
$(error APP must be one of $(APPS), not "$(APP)")
endif
# Debug or Release; override with `make build CONFIG=Release`.
CONFIG      := Debug
ifeq ($(filter $(CONFIG),Debug Release),)
$(error CONFIG must be Debug or Release, not "$(CONFIG)")
endif
BUNDLE_ID   := com.example.$(APP)
SIM_NAME    := TodoList-iPhone
DEVICE_TYPE := iPhone 17
DERIVED     := build
APP_PATH    := $(DERIVED)/Build/Products/$(CONFIG)-iphonesimulator/$(APP).app
ARCHIVE     := $(DERIVED)/$(APP).xcarchive
SIM         := -destination 'platform=iOS Simulator,name=$(SIM_NAME)'
DEVICE      := -destination 'generic/platform=iOS'
# Keep only result lines from the test logs: Swift Testing (✔/✘),
# XCTest ("Test Case ... passed"), errors, and xcodebuild's final verdict.
RESULTS     := grep -E '(✔|✘) Test|Test Case .*(passed|failed)|error:|\*\* TEST'
# The build settings `make settings` prints, out of several hundred.
SETTINGS    := CONFIGURATION|INFOPLIST_KEY_CFBundleDisplayName|MARKETING_VERSION|PRODUCT_BUNDLE_IDENTIFIER|SWIFT_TREAT_WARNINGS_AS_ERRORS
XCODEBUILD  := xcodebuild -workspace $(WORKSPACE) -derivedDataPath $(DERIVED)
TEST_FLAGS  := -parallel-testing-enabled NO -collect-test-diagnostics never

# bash, so the recipes can use `set -o pipefail`.
SHELL         := /bin/bash
.DEFAULT_GOAL := help
.PHONY: help simulator settings package-test build test run screenshot archive clean delete-simulator

help: ## Show this help
	@echo "Usage: make <target> [APP=$(subst $() ,|,$(APPS))] [CONFIG=Debug|Release]"
	@echo
	@grep -E '^[a-z-]+:.*## ' $(MAKEFILE_LIST) | \
		awk 'BEGIN {FS = ":.*## "}; {printf "  %-17s %s\n", $$1, $$2}'

simulator: ## Create the TodoList-iPhone simulator if it is missing
	@xcrun simctl list devices available | grep -q "$(SIM_NAME) (" || \
		xcrun simctl create "$(SIM_NAME)" "$(DEVICE_TYPE)"

settings: ## Show the key build settings for APP in CONFIG
	@set -o pipefail; \
		$(XCODEBUILD) -scheme $(APP) $(DEVICE) -configuration $(CONFIG) -showBuildSettings 2>/dev/null | \
		grep -E '^ *($(SETTINGS)) =' | sed 's/^ *//'

# `|| exit 1` stops at the first package that fails, so the failure is
# reported against the lowest package that broke.
package-test: ## Test every package on the Mac, leaves first
	@set -o pipefail; for pkg in $(PACKAGES); do \
		echo "--- $$pkg"; \
		SWT_SF_SYMBOLS_ENABLED=0 swift test --package-path Packages/$$pkg \
			--disable-xctest 2>&1 | $(RESULTS) || exit 1; \
	done

build: simulator ## Build every app for the simulator in CONFIG
	@for app in $(APPS); do \
		echo "--- $$app"; \
		$(XCODEBUILD) -scheme $$app $(SIM) -configuration $(CONFIG) -quiet build || exit 1; \
	done

test: package-test build ## Test the packages, build every app, run the UI tests
	@set -o pipefail; for app in $(UI_TESTED); do \
		echo "--- $$app UI tests"; \
		$(XCODEBUILD) -scheme $$app $(SIM) $(TEST_FLAGS) test 2>&1 | $(RESULTS) || exit 1; \
	done

run: simulator ## Build APP in CONFIG, then install and launch it
	$(XCODEBUILD) -scheme $(APP) $(SIM) -configuration $(CONFIG) -quiet build
	xcrun simctl bootstatus "$(SIM_NAME)" -b
	open "$$(xcode-select -p)/../Applications/DeviceHub.app"
	xcrun simctl install "$(SIM_NAME)" "$(APP_PATH)"
	xcrun simctl launch "$(SIM_NAME)" $(BUNDLE_ID)

screenshot: ## Save a screenshot of the simulator to screenshots/APP.png
	@mkdir -p screenshots
	xcrun simctl io "$(SIM_NAME)" screenshot screenshots/$(APP).png

# An archive is a Release build for real devices. It is left unsigned,
# because signing needs a development team.
archive: ## Archive an unsigned Release build of APP into build/
	$(XCODEBUILD) -scheme $(APP) $(DEVICE) -archivePath $(ARCHIVE) CODE_SIGNING_ALLOWED=NO -quiet archive
	@plutil -extract ApplicationProperties json -o - $(ARCHIVE)/Info.plist; echo

clean: ## Delete the build folders
	rm -rf $(DERIVED) $(addsuffix /.build,$(addprefix Packages/,$(PACKAGES)))

delete-simulator: ## Delete the TodoList-iPhone simulator
	xcrun simctl delete "$(SIM_NAME)"

Detailed breakdown

  • XCODEBUILD uses -workspace and no -scheme. Each recipe adds the scheme it needs, because one workspace now has several. -project would fail with the Missing package product error from Step 3.
  • APPS and PACKAGES are the monorepo’s inventory. A third app or a fourth package is one word added here, plus its entry in the workspace. build and package-test loop over these lists with a shell for loop; || exit 1 makes the loop stop at the first failure instead of carrying on and finishing with success.
  • PACKAGES is in dependency order, leaves first. A leaf is a package that depends on no other package in the repository: CoreKit and DesignSystem. TodoKit comes after CoreKit because it depends on it. swift test would build CoreKit for TodoKit in any order; the order matters for what you read when something breaks. If CoreKit is broken and TodoKit is tested first, the failures appear in TodoKit’s tests, though TodoKit’s code is fine. Step 11 shows the leaf-first run stopping at CoreKit.
  • APP picks one app for settings, run, screenshot and archive, and BUNDLE_ID, APP_PATH and ARCHIVE follow from it. That relies on each app’s bundle identifier being com.example. plus its name, which both are. The ifeq check rejects any other value before xcodebuild runs, for the reason the previous article checked CONFIG: a misspelled scheme name gives a less direct error.
  • UI_TESTED lists the apps with a UI-test target. xcodebuild test on Shopping’s scheme fails with Scheme Shopping is not currently configured for the test action, because its test action has no test targets, so test builds every app but tests only these.
  • SETTINGS swaps DEBUG_INFORMATION_FORMAT for PRODUCT_BUNDLE_IDENTIFIER, which tells the apps apart.
  • clean deletes each package’s .build/ as well as build/.
  • run builds only APP, so running Shopping does not wait for TodoList to build.

Check the help screen:

make
Usage: make <target> [APP=TodoList|Shopping] [CONFIG=Debug|Release]

  help              Show this help
  simulator         Create the TodoList-iPhone simulator if it is missing
  settings          Show the key build settings for APP in CONFIG
  package-test      Test every package on the Mac, leaves first
  build             Build every app for the simulator in CONFIG
  test              Test the packages, build every app, run the UI tests
  run               Build APP in CONFIG, then install and launch it
  screenshot        Save a screenshot of the simulator to screenshots/APP.png
  archive           Archive an unsigned Release build of APP into build/
  clean             Delete the build folders
  delete-simulator  Delete the TodoList-iPhone simulator

Step 11: Test everything, then run both apps

make test covers the whole repository: the three packages on the Mac, a build of both apps, then TodoList’s UI test on the simulator.

make test
--- CoreKit
✔ Test textIsTrimmed(raw:expected:) with 3 test cases passed after 0.001 seconds.
✔ Test blankTextIsRejected(raw:) with 3 test cases passed after 0.001 seconds.
✔ Test run with 2 tests in 1 suite passed after 0.001 seconds.
--- DesignSystem
✔ Test doneAndNotDoneDiffer() passed after 0.001 seconds.
✔ Test symbolExists(isDone:) with 2 test cases passed after 0.019 seconds.
✔ Test run with 2 tests in 1 suite passed after 0.019 seconds.
--- TodoKit
✔ Test summaryCountsOnlyUnfinishedItems() passed after 0.010 seconds.
✔ Test blankTitlesAreRejected(raw:) with 3 test cases passed after 0.010 seconds.
✔ Test addTodoIgnoresBlankTitles() passed after 0.010 seconds.
✔ Test addTodoStoresTheCleanedTitle() passed after 0.010 seconds.
✔ Test titlesAreTrimmed(raw:expected:) with 3 test cases passed after 0.010 seconds.
✔ Test run with 5 tests in 1 suite passed after 0.010 seconds.
--- TodoList
--- Shopping
--- TodoList UI tests
Test Case '-[TodoListUITests.TodoListUITests testTodoIsSavedAcrossLaunches]' passed (17.377 seconds).
** TEST SUCCEEDED **

Tests inside a package can finish in a different order on each run, and the timings vary.

A broken leaf stops the run

To see why PACKAGES is ordered, break CoreKit for a moment by trimming only line breaks, not spaces and tabs:

sed -i '' 's/in: \.whitespacesAndNewlines)/in: .newlines)/' Packages/CoreKit/Sources/CoreKit/TextInput.swift
make package-test 2>&1 | tail -4
✘ Test textIsTrimmed(raw:expected:) with 3 test cases failed after 0.001 seconds with 2 issues.
✘ Test blankTextIsRejected(raw:) with 3 test cases failed after 0.001 seconds with 2 issues.
✘ Test run with 2 tests in 1 suite failed after 0.001 seconds with 4 issues.
make: *** [package-test] Error 1

The order of the ✘ lines can vary between runs. The run stopped at CoreKit, the package with the bug, and never reached TodoKit. Testing TodoKit alone at this point would fail four of its five tests, though none of its own code changed. Put the line back:

sed -i '' 's/in: \.newlines)/in: .whitespacesAndNewlines)/' Packages/CoreKit/Sources/CoreKit/TextInput.swift
make package-test 2>&1 | tail -1
✔ Test run with 5 tests in 1 suite passed after 0.010 seconds.

Check the settings of each app

make settings now takes APP:

make settings
make settings APP=Shopping
make settings CONFIG=Release
CONFIGURATION = Debug
INFOPLIST_KEY_CFBundleDisplayName = To-Do Dev
MARKETING_VERSION = 1.0
PRODUCT_BUNDLE_IDENTIFIER = com.example.TodoList
SWIFT_TREAT_WARNINGS_AS_ERRORS = YES
CONFIGURATION = Debug
MARKETING_VERSION = 1.0
PRODUCT_BUNDLE_IDENTIFIER = com.example.Shopping
CONFIGURATION = Release
INFOPLIST_KEY_CFBundleDisplayName = To-Do
MARKETING_VERSION = 1.0
PRODUCT_BUNDLE_IDENTIFIER = com.example.TodoList
SWIFT_TREAT_WARNINGS_AS_ERRORS = YES

TodoList still reads its .xcconfig files from Apps/Config: To-Do Dev in Debug, To-Do in Release. Shopping uses no .xcconfig file, so it has no display name setting (its home-screen name is its target name) and does not treat warnings as errors. The Release display name is also the check for the General tab problem from Step 8: it must read To-Do.

Run both apps

Run Shopping, then screenshot it:

make run APP=Shopping
make screenshot APP=Shopping
xcrun simctl launch "TodoList-iPhone" com.example.Shopping
com.example.Shopping: 14405
xcrun simctl io "TodoList-iPhone" screenshot screenshots/Shopping.png
Detected file type from extension: PNG
Note: No display specified. Defaulting to display: F122299D-1DEB-41C4-BCDC-21708D5A8ECC (screenID: 1, name: LCD)

The first two lines are the end of make run’s output. make screenshot also prints a last line, Wrote screenshot to: and the full path, left out here. The process ID and the display identifier differ on your Mac. screenshots/Shopping.png shows Apples with an empty circle and Bread struck through with a filled check circle.

Then run TodoList on the same simulator:

xcrun simctl terminate TodoList-iPhone com.example.Shopping
make run
make screenshot

screenshots/TodoList.png shows Buy milk, saved by the UI test, struck through with the same filled check circle, and All done below it. Both rows are drawn by the same CheckRow.

Archive either app

make archive takes APP too, so each app archives on its own:

make archive APP=Shopping
xcodebuild -workspace MyApps.xcworkspace -derivedDataPath build -scheme Shopping -destination 'generic/platform=iOS' -archivePath build/Shopping.xcarchive CODE_SIGNING_ALLOWED=NO -quiet archive
{"CFBundleShortVersionString":"1.0","CFBundleIdentifier":"com.example.Shopping","Team":"","ApplicationPath":"Applications\/Shopping.app","SigningIdentity":"","Architectures":["arm64"],"CFBundleVersion":"1"}

As in the previous article, the archive is unsigned: Team and SigningIdentity are empty because signing needs a development team.

Step 12: Change a shared package and see both apps change

This is what the monorepo is for. Change the symbols in DesignSystem from circles to squares:

sed -i '' 's/"checkmark.circle.fill" : "circle"/"checkmark.square.fill" : "square"/' Packages/DesignSystem/Sources/DesignSystem/CheckRow.swift
grep -n square Packages/DesignSystem/Sources/DesignSystem/CheckRow.swift
25:        isDone ? "checkmark.square.fill" : "square"

Check that the new names exist with the package tests, then build every app:

make package-test 2>&1 | grep 'symbolExists'
make build
✔ Test symbolExists(isDone:) with 2 test cases passed after 0.019 seconds.
--- TodoList
--- Shopping

Both apps rebuilt against the edited package; nothing in either app changed. Run them again:

make run
make screenshot
xcrun simctl terminate TodoList-iPhone com.example.TodoList
make run APP=Shopping
make screenshot APP=Shopping
open screenshots/TodoList.png screenshots/Shopping.png

screenshots/TodoList.png now shows Buy milk with a filled check square, and screenshots/Shopping.png shows Apples with an empty square and Bread with a filled one. One line in one package changed both apps.

What the symbol test catches

A misspelled symbol name is a mistake the compiler cannot see. Misspell one and run both checks:

sed -i '' 's/"checkmark.square.fill"/"checkmark.sqare.fill"/' Packages/DesignSystem/Sources/DesignSystem/CheckRow.swift
make build
make package-test 2>&1 | tail -4
--- TodoList
--- Shopping
✘ Test symbolExists(isDone:) recorded an issue with 1 argument isDone → true at CheckRowTests.swift:11:9: Expectation failed: NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil
✘ Test symbolExists(isDone:) with 2 test cases failed after 0.018 seconds with 1 issue.
✘ Test run with 2 tests in 1 suite failed after 0.019 seconds with 1 issue.
make: *** [package-test] Error 1

The order of the ✘ lines can vary. Both apps built, and either would have shown a blank space where a done item’s square belongs. The package test failed. Fix the name:

sed -i '' 's/"checkmark.sqare.fill"/"checkmark.square.fill"/' Packages/DesignSystem/Sources/DesignSystem/CheckRow.swift
make package-test 2>&1 | grep symbolExists
✔ Test symbolExists(isDone:) with 2 test cases passed after 0.019 seconds.

Troubleshooting

  • Missing package product 'TodoKit' (or CoreKit, DesignSystem). The build used -project, or the package is missing from the workspace. Build with -workspace MyApps.xcworkspace, and check contents.xcworkspacedata lists the package.
  • Could not resolve package dependencies. A project still holds its own package reference with an old path. Remove it under PROJECT → Package Dependencies, as in Step 3.
  • The options sheet in Add Files shows a Groups row, or the added package appears as a plain folder with its files listed. It was added inside a project. Cancel, or select the new item, press Delete and choose Remove Reference (not Move to Trash) if Xcode asks, then clear the selection in the Project navigator and add it again.
  • Release builds of TodoList are named To-Do Dev. Opening the TodoList target’s General tab wrote the Debug display name into the target. Select the TodoList target, open Build Settings, search for Display Name, select the row, and press Delete, so the .xcconfig files apply again. make settings CONFIG=Release should read To-Do.
  • no such module 'DesignSystem' (or CoreKit). Usually the target does not link the product. Add it under Build Phases → Link Binary With Libraries. In a package, the target’s dependencies list is missing the product.
  • A row shows no symbol. The SF Symbol name is misspelled. make package-test names the state whose symbol is missing.
  • APP must be one of TodoList Shopping. The name is case-sensitive and must be one of the names in APPS.

Recap

my-apps is now a monorepo: two app projects in Apps/, three packages in Packages/, and one workspace that lists all five by path. The app projects name the products they link and record no package paths, so the workspace is the one place an app finds its packages, and the only package path outside the workspace is TodoKit’s manifest pointing at its neighbor CoreKit. Both app schemes are shared files in the repository. One Makefile tests the packages leaves first, builds both apps, runs the UI test, and runs, inspects or archives either app with APP=. Changing one line in DesignSystem changed both apps on the simulator, and the package’s own test catches a mistake the compiler cannot.

Next improvements:

  • Move the settings both apps should share, such as treating warnings as errors, into a base .xcconfig file that each app’s own files include.
  • Give Shopping a UI test target and add it to UI_TESTED.
  • Persist Shopping’s list with SwiftData, with its model in a package of its own, as TodoKit does for TodoList.