Modularizing Swift Apps with SPM

If you've ever worked on an app that grew beyond a handful of files, you know the feeling. Builds get slower. A change in one corner triggers recompilation across the whole target. Any file can import any other file, and over time they do. You end up with a codebase where everything depends on everything, and "just a quick refactor" turns into an afternoon of untangling.

Local Swift packages fix this. By splitting your app into modules with explicit dependency declarations, SPM enforces boundaries at compile time. You literally cannot import something you haven't declared as a dependency. That constraint, which sounds limiting, is actually freeing.

In this post I'll walk through how to structure a local package using a three-layer architecture. The example is a Landmarks package, building on patterns from my series on building iOS apps with Swift, but the approach works for any app.

You can find the complete working code for this post on GitHub.

The layer cake

The core idea is three tiers of modules, each with a clear responsibility and strict dependency direction:

Common  (Env, Logger, Toolkit) - no feature dependencies
    ↑
Services  (Api + Domain) - network and business logic
    ↑
Features  (Landmarks, Profile, Schedule...) - SwiftUI views

Common sits at the bottom. It holds shared utilities like environment helpers, logging wrappers, and small toolkit extensions. It depends on nothing internal.

Services is the middle layer, split into two sub-modules. API defines the contract with your server (API models, endpoints). Domain holds your business logic, domain models, services, and mocks. Domain depends on API because it maps API models into domain models.

Features are SwiftUI modules at the top. Each feature imports Domain (never API directly) and gets everything it needs through the environment. Features never see API models, only domain models.

Dependencies flow in one direction: up. A module can only depend on layers below it.

Setting up the package

Here's a Package.swift for a LandmarksPackage with this three-tier structure:

// swift-tools-version: 6.0
import PackageDescription

let package = Package(
    name: "LandmarksPackage",
    platforms: [.iOS(.v18)],
    products: [
        .library(name: "LandmarksDomain", targets: ["LandmarksDomain"]),
        .library(name: "LandmarksFeature", targets: ["LandmarksFeature"]),
    ],
    targets: [
        // Common layer
        .target(name: "Env"),
        .target(name: "Logger"),
        .target(name: "Toolkit"),

        // Api layer
        .target(
            name: "LandmarksApi",
            dependencies: [.env, .toolkit]
        ),

        // Domain layer
        .target(
            name: "LandmarksDomain",
            dependencies: [.landmarksApi, .env, .toolkit]
        ),

        // Feature layer
        .target(
            name: "LandmarksFeature",
            dependencies: [.landmarksDomain, .env, .toolkit]
        ),

        // Tests
        .testTarget(
            name: "LandmarksDomainTests",
            dependencies: [.landmarksDomain]
        ),
    ]
)

You'll notice the dependency declarations use dot-syntax like .env and .landmarksApi instead of raw strings. That comes from a small extension that makes the manifest much cleaner:

extension Target.Dependency {
    // Common
    static let env: Target.Dependency = "Env"
    static let logger: Target.Dependency = "Logger"
    static let toolkit: Target.Dependency = "Toolkit"

    // Services
    static let landmarksApi: Target.Dependency = "LandmarksApi"
    static let landmarksDomain: Target.Dependency = "LandmarksDomain"

    // Features
    static let landmarksFeature: Target.Dependency = "LandmarksFeature"
}

Drop this at the bottom of your Package.swift. As your package grows, you add one line per target and every dependency declaration stays readable.

The API module

The API module is the contract with your server. It defines exactly what your network layer sends and receives, and nothing more.

Start with a marker protocol so every API model is consistent:

public protocol ApiModel: Codable, Hashable, Sendable {}

Then define your models to match the server's JSON shape exactly:

public struct LandmarkApiModel: ApiModel {
    public let id: Int
    public let name: String
    public let category: String
    public let city: String
    public let state: String
    public let isFeatured: Bool
    public let isFavorite: Bool
    public let park: String
    public let description: String
    public let imageName: String
    public let coordinates: CoordinatesApiModel
}

I covered the full rationale for keeping API models separate from domain models in Domain Models vs API Models in Swift. The short version: API models match the server, domain models match your app's needs, and a mapping layer in between protects you from changes on either side.

The API module also owns your typed endpoint definitions:

public enum LandmarkEndpoints {
    public static func list() -> Endpoint<EmptyBody, [LandmarkApiModel]> {
        Endpoint(path: "/api/landmarks", method: .get)
    }

    public static func get(id: Int) -> Endpoint<EmptyBody, LandmarkApiModel> {
        Endpoint(path: "/api/landmarks/\(id)", method: .get)
    }

    public static func byCategory(
        _ category: String
    ) -> Endpoint<EmptyBody, [LandmarkApiModel]> {
        Endpoint(
            path: "/api/landmarks",
            method: .get,
            queryItems: [URLQueryItem(name: "category", value: category)]
        )
    }
}

If this endpoint pattern looks familiar, it's from Building the Full Landmarks App where I showed how a generic Endpoint struct gives you compile-time safety on request and response types.

The Domain module

This is where most of the interesting work happens. The Domain module owns four things: domain models, API-to-domain mapping, services, and mocks. That last one is the key architectural insight.

Domain models

public protocol DomainModel: Equatable, Hashable, Sendable {}

public struct Landmark: DomainModel, Identifiable {
    public let id: Int
    public let name: String
    public let category: Category
    public let city: String
    public let state: String
    public let isFeatured: Bool
    public var isFavorite: Bool
    public let park: String
    public let description: String
    public let imageName: String
    public let latitude: Double
    public let longitude: Double

    public init(
        id: Int,
        name: String,
        category: Category,
        city: String,
        state: String,
        isFeatured: Bool,
        isFavorite: Bool,
        park: String,
        description: String,
        imageName: String,
        latitude: Double,
        longitude: Double
    ) {
        self.id = id
        self.name = name
        self.category = category
        self.city = city
        self.state = state
        self.isFeatured = isFeatured
        self.isFavorite = isFavorite
        self.park = park
        self.description = description
        self.imageName = imageName
        self.latitude = latitude
        self.longitude = longitude
    }
}

Mapping

The domain layer imports API and defines the mapping:

import LandmarksApi

extension LandmarkApiModel {
    public var domainModel: Landmark? {
        guard let category = Category(rawValue: category) else {
            return nil
        }
        return Landmark(
            id: id,
            name: name,
            category: category,
            city: city,
            state: state,
            isFeatured: isFeatured,
            isFavorite: isFavorite,
            park: park,
            description: description,
            imageName: imageName,
            latitude: coordinates.latitude,
            longitude: coordinates.longitude
        )
    }
}

This returns an optional because invalid records (like an unknown category) get filtered out rather than crashing your app. I went deep on this pattern in Domain Models vs API Models in Swift.

Closure-based services

Services are structs with closure properties, not protocols. This gives you the same testability with less ceremony:

public struct LandmarkService: Sendable {
    public var fetchLandmarks: @Sendable () async throws -> [Landmark]
    public var fetchLandmark: @Sendable (Int) async throws -> Landmark
    public var fetchLandmarksByCategory: @Sendable (Category) async throws -> [Landmark]
    public var toggleFavorite: @Sendable (Int) async throws -> Landmark

    public init(
        fetchLandmarks: @escaping @Sendable () async throws -> [Landmark],
        fetchLandmark: @escaping @Sendable (Int) async throws -> Landmark,
        fetchLandmarksByCategory: @escaping @Sendable (Category) async throws -> [Landmark],
        toggleFavorite: @escaping @Sendable (Int) async throws -> Landmark
    ) {
        self.fetchLandmarks = fetchLandmarks
        self.fetchLandmark = fetchLandmark
        self.fetchLandmarksByCategory = fetchLandmarksByCategory
        self.toggleFavorite = toggleFavorite
    }
}

Now here's the important part. The .live, .mock, and .unimplemented implementations all live right here in the Domain module:

extension LandmarkService {
    public static func live(
        client: NetworkClient,
        baseURL: URL
    ) -> LandmarkService {
        LandmarkService(
            fetchLandmarks: {
                let apiModels = try await client.get(
                    baseURL, LandmarkEndpoints.list()
                )
                return apiModels.compactMap(\.domainModel)
            },
            fetchLandmark: { id in
                let apiModel = try await client.get(
                    baseURL, LandmarkEndpoints.get(id: id)
                )
                guard let landmark = apiModel.domainModel else {
                    throw LandmarkError.invalidData
                }
                return landmark
            },
            fetchLandmarksByCategory: { category in
                let apiModels = try await client.get(
                    baseURL, LandmarkEndpoints.byCategory(category.rawValue)
                )
                return apiModels.compactMap(\.domainModel)
            },
            toggleFavorite: { id in
                let apiModel = try await client.put(
                    baseURL, LandmarkEndpoints.toggleFavorite(id: id)
                )
                guard let landmark = apiModel.domainModel else {
                    throw LandmarkError.invalidData
                }
                return landmark
            }
        )
    }
}

Mocks live here too

This is a deliberate choice. Mocks return domain models, not API models. That means they belong in the Domain layer:

extension LandmarkService {
    public static let mock = LandmarkService(
        fetchLandmarks: {
            try? await Task.sleep(for: .milliseconds(200))
            return Landmark.mocks
        },
        fetchLandmark: { id in
            try? await Task.sleep(for: .milliseconds(100))
            guard let landmark = Landmark.mocks.first(
                where: { $0.id == id }
            ) else {
                throw LandmarkError.notFound
            }
            return landmark
        },
        fetchLandmarksByCategory: { category in
            try? await Task.sleep(for: .milliseconds(150))
            return Landmark.mocks.filter { $0.category == category }
        },
        toggleFavorite: { id in
            try? await Task.sleep(for: .milliseconds(100))
            guard var landmark = Landmark.mocks.first(
                where: { $0.id == id }
            ) else {
                throw LandmarkError.notFound
            }
            landmark.isFavorite.toggle()
            return landmark
        }
    )

    public static let unimplemented = LandmarkService(
        fetchLandmarks: { fatalError("fetchLandmarks unimplemented") },
        fetchLandmark: { _ in fatalError("fetchLandmark unimplemented") },
        fetchLandmarksByCategory: { _ in
            fatalError("fetchLandmarksByCategory unimplemented")
        },
        toggleFavorite: { _ in fatalError("toggleFavorite unimplemented") }
    )
}

Why does this matter for modularization? Because feature modules and previews only need to import Domain to get working mocks. They never need to import API at all. The .mock implementation handles everything internally using domain models and the feature layer stays completely decoupled from your networking layer.

I covered the full rationale for closure-based services in Dependency Injection in SwiftUI Without the Ceremony.

Environment key

The @Entry macro makes registration a one-liner:

import SwiftUI

extension EnvironmentValues {
    @Entry var landmarkService: LandmarkService = .unimplemented
}

Starting with .unimplemented means any feature that forgets to inject a real service will crash immediately in development with a clear message, rather than silently returning empty data.

Feature modules

Feature modules sit at the top of the dependency graph. They import Domain and Common, never API. This is the whole point of the layering.

A Landmarks feature module might look like this:

import SwiftUI
import LandmarksDomain

struct LandmarkListView: View {
    @Environment(\.landmarkService) private var service
    @State private var landmarks: [Landmark] = []

    var body: some View {
        List(landmarks) { landmark in
            NavigationLink(value: landmark) {
                LandmarkRow(landmark: landmark)
            }
        }
        .task {
            do {
                landmarks = try await service.fetchLandmarks()
            } catch {
                // handle error
            }
        }
    }
}

#Preview {
    NavigationStack {
        LandmarkListView()
    }
    .environment(\.landmarkService, .mock)
}

Notice the preview. It uses .mock directly from the domain layer. No test doubles to maintain in the feature module, no importing API to set up fake responses. The domain layer provides everything the feature needs to render realistic previews.

Because features only depend on Domain, they compile independently of your networking code. Change an endpoint path? Only API and Domain recompile. Add a new view to Landmarks? Only the feature module recompiles. SPM's incremental builds make this fast.

ServiceEnvironment: bulk injection

Once you have more than two or three services, injecting them one by one gets tedious. A container struct solves this:

public struct ServiceEnvironment: Sendable {
    public let landmarkService: LandmarkService
    public let profileService: ProfileService
    public let scheduleService: ScheduleService

    public init(
        landmarkService: LandmarkService,
        profileService: ProfileService,
        scheduleService: ScheduleService
    ) {
        self.landmarkService = landmarkService
        self.profileService = profileService
        self.scheduleService = scheduleService
    }
}

Add presets for common configurations:

extension ServiceEnvironment {
    public static func live(
        client: NetworkClient,
        baseURL: URL
    ) -> ServiceEnvironment {
        ServiceEnvironment(
            landmarkService: .live(client: client, baseURL: baseURL),
            profileService: .live(client: client, baseURL: baseURL),
            scheduleService: .live(client: client, baseURL: baseURL)
        )
    }

    public static let mock = ServiceEnvironment(
        landmarkService: .mock,
        profileService: .mock,
        scheduleService: .mock
    )

    public static let unimplemented = ServiceEnvironment(
        landmarkService: .unimplemented,
        profileService: .unimplemented,
        scheduleService: .unimplemented
    )
}

The .mock preset works because every mock is defined at the domain layer. No extra setup needed.

Then a view modifier handles injection:

struct ServiceEnvironmentModifier: ViewModifier {
    let environment: ServiceEnvironment

    func body(content: Content) -> some View {
        content
            .environment(\.landmarkService, environment.landmarkService)
            .environment(\.profileService, environment.profileService)
            .environment(\.scheduleService, environment.scheduleService)
    }
}

extension View {
    public func withServiceEnvironment(
        _ environment: ServiceEnvironment
    ) -> some View {
        modifier(ServiceEnvironmentModifier(environment: environment))
    }
}

Your app entry point becomes a single line of injection:

@main
struct LandmarksApp: App {
    let services: ServiceEnvironment = .live(
        client: .default,
        baseURL: URL(string: "https://api.example.com")!
    )

    var body: some Scene {
        WindowGroup {
            ContentView()
                .withServiceEnvironment(services)
        }
    }
}

And previews are just as clean:

#Preview {
    LandmarkListView()
        .withServiceEnvironment(.mock)
}

The dependency rule

Here's the full dependency graph, spelled out:

  • Common (Env, Logger, Toolkit) depends on nothing internal
  • API (LandmarksApi) depends on Common
  • Domain (LandmarksDomain) depends on API + Common. Owns models, mapping, services, and mocks
  • Features (LandmarksFeature) depend on Domain + Common. Never import API

This is enforced by SPM at compile time. If someone on your team tries to import LandmarksApi from a feature module, it won't build. That's not a code review comment that gets ignored - it's a compiler error that blocks the PR.

The Domain layer is the linchpin. It imports API so it can map API models to domain models. It exports domain models, services, and mocks so features never need to know about the network layer. Everything flows through Domain.

What you get

Splitting into modules takes some upfront effort. Here's what you get back:

Faster incremental builds. Change a view in LandmarksFeature? Only that module recompiles. Your API and Domain modules don't rebuild. On a large app this can cut iteration time from 30 seconds to under 5.

Enforced boundaries. The compiler prevents a feature from reaching into the API layer. No discipline required. No linter rules to configure. If it compiles, the architecture is intact.

Independent testability. Test your domain mapping without importing SwiftUI. Test your services with mock network clients. Preview your features with domain-layer mocks. Each layer has a clean testing surface.

Parallel development. One developer works on the API layer for a new endpoint. Another builds the feature UI with mock data. They never conflict because their modules don't overlap. The domain layer is the integration point, and it's small.

If your app is still a single target with a few dozen files, you probably don't need this yet. But the moment you feel that friction, when builds slow down, when a "small change" cascades across unrelated code, when you can't preview a view without the full networking stack, that's when modularization pays for itself immediately.