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
↑ ↑
LandmarksDomain ← ReservationsDomain
↑ ↑
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.