Adding Reservations to Your Modular App

In the first post of this series, we built a LandmarksPackage with three clean layers: Common, Services (API + Domain), and Features. Everything compiled, tests passed, previews worked. But a single feature is easy. The real test of any modular architecture is what happens when you add a second one.

That's what we'll do here. We're adding a Reservations domain to the same package, right alongside Landmarks. By the end you'll see how two features can coexist in the same package without coupling to each other, sharing only the Common layer beneath them.

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

What we're building

A Reservations feature that lets users book a campsite at a landmark. The reservation has a date range, a landmark reference, and a status. The feature module shows a list of reservations and a booking form.

The important thing: ReservationsFeature will depend on ReservationsDomain, and ReservationsDomain will depend on LandmarksDomain (because a reservation references a landmark). But LandmarksFeature knows nothing about reservations. The dependency flows one way.

Updating Package.swift

First, add the new targets to your package manifest:

// 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"]),
        .library(name: "ReservationsDomain", targets: ["ReservationsDomain"]),
        .library(name: "ReservationsFeature", targets: ["ReservationsFeature"]),
    ],
    targets: [
        // Common layer
        .target(name: "Env"),
        .target(name: "Logger"),
        .target(name: "Toolkit"),

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

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

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

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

        // Reservations Domain layer
        .target(
            name: "ReservationsDomain",
            dependencies: [
                .reservationsApi,
                .landmarksDomain,
                .env,
                .toolkit,
            ]
        ),

        // Reservations Feature layer
        .target(
            name: "ReservationsFeature",
            dependencies: [.reservationsDomain, .env, .toolkit]
        ),

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

And extend the Target.Dependency helper:

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

    // Landmarks
    static let landmarksApi: Target.Dependency = "LandmarksApi"
    static let landmarksDomain: Target.Dependency = "LandmarksDomain"
    static let landmarksFeature: Target.Dependency = "LandmarksFeature"

    // Reservations
    static let reservationsApi: Target.Dependency = "ReservationsApi"
    static let reservationsDomain: Target.Dependency = "ReservationsDomain"
    static let reservationsFeature: Target.Dependency = "ReservationsFeature"
}

Notice the key dependency: ReservationsDomain depends on LandmarksDomain. A reservation needs to reference a landmark. But LandmarksFeature has zero knowledge of reservations. The features are siblings, not coupled.

The Reservations API module

The API model matches your server's JSON shape:

public struct ReservationApiModel: ApiModel {
    public let id: Int
    public let landmarkId: Int
    public let startDate: String
    public let endDate: String
    public let status: String
    public let guestCount: Int
    public let notes: String?
}

And the typed endpoints:

import Foundation
import Toolkit

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

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

    public static func forLandmark(
        id: Int
    ) -> Endpoint<EmptyBody, [ReservationApiModel]> {
        Endpoint(
            path: "/api/reservations",
            method: .get,
            queryItems: [URLQueryItem(name: "landmarkId", value: "\(id)")]
        )
    }
}

Same patterns as the Landmarks API. Nothing surprising - that's the point. Once you establish a convention, every new domain follows it.

The Reservations Domain module

Domain model

The domain model converts raw strings into proper Swift types:

import Foundation
import LandmarksDomain

public struct Reservation: DomainModel, Identifiable {
    public let id: Int
    public let landmarkId: Int
    public let startDate: Date
    public let endDate: Date
    public let status: Status
    public let guestCount: Int
    public let notes: String?

    public init(
        id: Int,
        landmarkId: Int,
        startDate: Date,
        endDate: Date,
        status: Status,
        guestCount: Int,
        notes: String?
    ) {
        self.id = id
        self.landmarkId = landmarkId
        self.startDate = startDate
        self.endDate = endDate
        self.status = status
        self.guestCount = guestCount
        self.notes = notes
    }
}

public enum Status: String, CaseIterable, Sendable {
    case pending = "pending"
    case confirmed = "confirmed"
    case cancelled = "cancelled"
}

Notice that Reservation conforms to DomainModel, the same marker protocol from LandmarksDomain. It also references landmarkId - a lightweight reference rather than embedding the full Landmark struct. The feature layer resolves that reference when it needs the landmark's name or location.

Mapping

import Foundation
import ReservationsApi

extension ReservationApiModel {
    public var domainModel: Reservation? {
        let formatter = ISO8601DateFormatter()
        guard
            let start = formatter.date(from: startDate),
            let end = formatter.date(from: endDate),
            let status = Status(rawValue: status)
        else {
            return nil
        }
        return Reservation(
            id: id,
            landmarkId: landmarkId,
            startDate: start,
            endDate: end,
            status: status,
            guestCount: guestCount,
            notes: notes
        )
    }
}

Same pattern as the landmark mapping: return an optional, filter out invalid records rather than crashing.

Service

public struct ReservationService: Sendable {
    public var fetchReservations: @Sendable () async throws -> [Reservation]
    public var fetchReservation: @Sendable (Int) async throws -> Reservation
    public var fetchReservationsForLandmark: @Sendable (Int) async throws -> [Reservation]

    public init(
        fetchReservations: @escaping @Sendable () async throws -> [Reservation],
        fetchReservation: @escaping @Sendable (Int) async throws -> Reservation,
        fetchReservationsForLandmark: @escaping @Sendable (Int) async throws -> [Reservation]
    ) {
        self.fetchReservations = fetchReservations
        self.fetchReservation = fetchReservation
        self.fetchReservationsForLandmark = fetchReservationsForLandmark
    }
}

With the same .live, .mock, and .unimplemented pattern:

extension ReservationService {
    public static let mock = ReservationService(
        fetchReservations: {
            try? await Task.sleep(for: .milliseconds(200))
            return Reservation.mocks
        },
        fetchReservation: { id in
            try? await Task.sleep(for: .milliseconds(100))
            guard let reservation = Reservation.mocks.first(
                where: { $0.id == id }
            ) else {
                throw ReservationError.notFound
            }
            return reservation
        },
        fetchReservationsForLandmark: { landmarkId in
            try? await Task.sleep(for: .milliseconds(150))
            return Reservation.mocks.filter {
                $0.landmarkId == landmarkId
            }
        }
    )

    public static let unimplemented = ReservationService(
        fetchReservations: {
            fatalError("fetchReservations unimplemented")
        },
        fetchReservation: { _ in
            fatalError("fetchReservation unimplemented")
        },
        fetchReservationsForLandmark: { _ in
            fatalError("fetchReservationsForLandmark unimplemented")
        }
    )
}

Environment key

import SwiftUI

extension EnvironmentValues {
    @Entry var reservationService: ReservationService = .unimplemented
}

The Reservations Feature module

Now the feature view. It imports ReservationsDomain and LandmarksDomain (transitively available through ReservationsDomain's dependency), but never touches either API module:

import SwiftUI
import ReservationsDomain
import LandmarksDomain

public struct ReservationListView: View {
    @Environment(\.reservationService) private var reservationService
    @Environment(\.landmarkService) private var landmarkService
    @State private var reservations: [Reservation] = []
    @State private var landmarks: [Int: Landmark] = [:]

    public init() {}

    public var body: some View {
        List(reservations) { reservation in
            ReservationRow(
                reservation: reservation,
                landmarkName: landmarks[reservation.landmarkId]?.name
            )
        }
        .navigationTitle("Reservations")
        .task {
            do {
                reservations = try await reservationService.fetchReservations()
                let allLandmarks = try await landmarkService.fetchLandmarks()
                landmarks = Dictionary(
                    uniqueKeysWithValues: allLandmarks.map { ($0.id, $0) }
                )
            } catch {
                // handle error
            }
        }
    }
}

struct ReservationRow: View {
    let reservation: Reservation
    let landmarkName: String?

    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            Text(landmarkName ?? "Unknown Landmark")
                .font(.headline)
            Text(reservation.startDate.formatted(date: .abbreviated, time: .omitted))
                .font(.subheadline)
                .foregroundStyle(.secondary)
            Text(reservation.status.rawValue.capitalized)
                .font(.caption)
                .padding(.horizontal, 8)
                .padding(.vertical, 2)
                .background(statusColor.opacity(0.2))
                .clipShape(Capsule())
        }
    }

    private var statusColor: Color {
        switch reservation.status {
        case .confirmed: .green
        case .pending: .orange
        case .cancelled: .red
        }
    }
}

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

The preview injects mocks from both domain layers. No networking code, no server required. The feature works in complete isolation.

Cross-domain references without coupling

Here's the key architectural decision: ReservationsDomain depends on LandmarksDomain, but LandmarksDomain knows nothing about reservations. The dependency graph looks like this:

Common (Env, Logger, Toolkit)
    ↑
LandmarksApi    ReservationsApi
    ↑               ↑
LandmarksDomainReservationsDomain
    ↑               ↑
LandmarksFeature  ReservationsFeature

ReservationsDomain imports LandmarksDomain because it needs the DomainModel protocol and potentially the Landmark type for richer domain operations. But the features stay independent. You can build and test LandmarksFeature without compiling anything in the Reservations stack.

If you need Reservations to navigate to a Landmark detail view, that navigation happens in the app layer (coming in the next post), not within the feature modules themselves.

Updating ServiceEnvironment

With the new domain, update the service container:

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

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

And the view modifier gains one line:

struct ServiceEnvironmentModifier: ViewModifier {
    let environment: ServiceEnvironment

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

Adding a new domain is exactly four steps: create the three-layer modules, add them to Package.swift, add the service to ServiceEnvironment, and add one line to the view modifier. The pattern scales linearly.

What this proves

Adding a second domain to an existing modular package validated a few things:

The three-layer pattern is repeatable. Reservations follows the exact same structure as Landmarks. API, Domain, Feature. Same protocols, same service pattern, same mock strategy. A new developer on the team learns the pattern once and can create any feature domain.

Cross-domain dependencies work cleanly. ReservationsDomain depends on LandmarksDomain, but LandmarksDomain is completely unaware. The compiler enforces this. You can't accidentally create a circular dependency because SPM won't let you.

Features stay independent. LandmarksFeature and ReservationsFeature don't know about each other. They can be developed, tested, and previewed in isolation. The only shared code is the Common layer beneath them.

Build times stay fast. Changing a view in ReservationsFeature only recompiles that module. The Landmarks stack doesn't rebuild. As your app grows, each feature module stays small and fast to compile.

In the next post, we'll build the main app target that brings these features together with a TabView, shared navigation, and the ServiceEnvironment wired up at the root.