Using Everything We've Learned to Build a Fully Featured App

Over the last five posts, we've built a lot of pieces: type-safe navigation, separated API and domain models, closure-based services with observable stores, tiered caching, and a Vapor backend. Each post introduced a pattern in isolation. Now it's time to see them all work together.

This is the integration post. We're connecting the iOS app to the real Vapor server, wiring up the network layer, and watching every architectural decision from the series pay off. If something feels unclear, go back to the relevant post because we're going to move fast.

You can find the complete app on GitHub.

The Architecture at a Glance

Before we write code, let's trace the full data flow from server to screen:

Vapor ServerJSON response
Network Client (HTTP layer)
    ↓ Data
API Models (LandmarkApiModel, CategoryApiModel)
    ↓ .domainModel mapping
Domain Models (Landmark, Category)
    ↓ cache.set()
Tiered Cache (memory + disk)
    ↓ store.setLandmarks()
Observable Store (LandmarkStore)
    ↓ @Observable
SwiftUI Views

Every layer has a single responsibility. The network client handles HTTP. API models handle decoding. The mapping layer handles validation. The cache handles persistence. The store handles observable state. The views handle rendering.

No layer reaches into another's business. The network client doesn't know about caching. The cache doesn't know about views. The store doesn't know about HTTP. Each piece is independently testable and replaceable.

Pointing the App at the Real Server

With the Vapor server from the previous post running on localhost:8080, we need to update our Services container to hit real endpoints instead of mock data.

extension Services {
    static func live(baseURL: URL) -> Services {
        let networkClient = NetworkClient.live
        let cache = TieredCache(
            memory: MemoryCache(maxItemsPerType: 100),
            disk: DiskCache()
        )

        return Services(
            landmarks: .live(
                client: networkClient,
                baseURL: baseURL,
                cache: cache
            ),
            analytics: .live
        )
    }
}

At the app level, inject the live services:

@main
struct LandmarksApp: App {
    let services: Services

    init() {
        #if DEBUG
        let baseURL = URL(string: "http://localhost:8080")!
        #else
        let baseURL = URL(string: "https://api.yourapp.com")!
        #endif

        services = .live(baseURL: baseURL)
    }

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

The #if DEBUG switch points to localhost during development and your production URL for release builds. We'll set up the production URL properly in the deployment post.

The Network Layer

The network client follows the same closure-based pattern from Post 3. Each HTTP method is a closure that can be swapped for testing:

struct NetworkClient: Sendable {
    var get: @Sendable (URL) async throws -> Data
    var post: @Sendable (URL, Data?) async throws -> Data
    var put: @Sendable (URL, Data?) async throws -> Data
    var delete: @Sendable (URL) async throws -> Data
    var decoder: JSONDecoder
    var encoder: JSONEncoder
}

The live implementation uses URLSession:

extension NetworkClient {
    static var live: NetworkClient {
        let session = URLSession.shared
        let decoder = JSONDecoder()
        let encoder = JSONEncoder()

        return NetworkClient(
            get: { url in
                let (data, response) = try await session.data(from: url)
                try validateResponse(response)
                return data
            },
            post: { url, body in
                var request = URLRequest(url: url)
                request.httpMethod = "POST"
                request.setValue("application/json", forHTTPHeaderField: "Content-Type")
                request.httpBody = body
                let (data, response) = try await session.data(for: request)
                try validateResponse(response)
                return data
            },
            put: { url, body in
                var request = URLRequest(url: url)
                request.httpMethod = "PUT"
                request.setValue("application/json", forHTTPHeaderField: "Content-Type")
                request.httpBody = body
                let (data, response) = try await session.data(for: request)
                try validateResponse(response)
                return data
            },
            delete: { url in
                var request = URLRequest(url: url)
                request.httpMethod = "DELETE"
                let (data, response) = try await session.data(for: request)
                try validateResponse(response)
                return data
            },
            decoder: decoder,
            encoder: encoder
        )
    }
}

private func validateResponse(_ response: URLResponse) throws {
    guard let httpResponse = response as? HTTPURLResponse else {
        throw NetworkError.invalidResponse
    }

    switch httpResponse.statusCode {
    case 200...299:
        return
    case 404:
        throw NetworkError.notFound
    case 400...499:
        throw NetworkError.clientError(httpResponse.statusCode)
    case 500...599:
        throw NetworkError.serverError(httpResponse.statusCode)
    default:
        throw NetworkError.unknown(httpResponse.statusCode)
    }
}

enum NetworkError: Error, LocalizedError {
    case invalidResponse
    case notFound
    case clientError(Int)
    case serverError(Int)
    case unknown(Int)

    var errorDescription: String? {
        switch self {
        case .invalidResponse: "Invalid server response"
        case .notFound: "Resource not found"
        case .clientError(let code): "Client error (\(code))"
        case .serverError(let code): "Server error (\(code))"
        case .unknown(let code): "Unexpected error (\(code))"
        }
    }
}

The network client doesn't know about landmarks, caching, or UI state. It's a thin HTTP abstraction. This means you can reuse it for any service that talks to your backend.

Typed Endpoints

Right now, if you wanted to call the landmarks API, you'd need to manually construct a URL, pick the right convenience method (fetch vs send), and pass the correct types. That works for a handful of calls, but as your app grows, you end up with URL strings and HTTP methods scattered across every service closure. Change a route on the server and you're hunting through service code to find every place that references it.

A typed Endpoint struct solves this. It bundles the path, HTTP method, request body, and response type into a single value. You define your endpoints once, and the compiler enforces that you're sending the right types to the right places.

Start with a simple HTTP method enum and a couple of marker types for endpoints that don't need a request body or don't return meaningful data:

enum HTTPMethod: String, Sendable {
    case get = "GET"
    case post = "POST"
    case put = "PUT"
    case delete = "DELETE"
}

struct EmptyBody: Encodable, Sendable {}
struct EmptyResponse: Decodable, Sendable {}

The Endpoint itself is a generic struct with two type parameters - the request body and the response:

struct Endpoint<Request: Encodable & Sendable, Response: Decodable & Sendable>: Sendable {
    var path: String
    var method: HTTPMethod
    var body: Request?
    var queryItems: [URLQueryItem]?

    func url(relativeTo baseURL: URL) -> URL {
        var components = URLComponents(
            url: baseURL.appendingPathComponent(path),
            resolvingAgainstBaseURL: true
        )!
        if let queryItems, !queryItems.isEmpty {
            components.queryItems = queryItems
        }
        return components.url!
    }
}

No protocols, no associated types, no inheritance. Just a plain value type that holds everything the network client needs to make a request. The url(relativeTo:) method handles combining the path and query items with a base URL, so that logic lives in one place instead of being repeated in every service closure.

Now give NetworkClient an execute method that routes to the right HTTP closure based on the endpoint's method:

extension NetworkClient {
    func execute<Request, Response>(
        _ endpoint: Endpoint<Request, Response>,
        baseURL: URL
    ) async throws -> Response {
        let url = endpoint.url(relativeTo: baseURL)

        let responseData: Data
        switch endpoint.method {
        case .get:
            responseData = try await get(url)
        case .post:
            let body = try endpoint.body.map { try encoder.encode($0) }
            responseData = try await post(url, body)
        case .put:
            let body = try endpoint.body.map { try encoder.encode($0) }
            responseData = try await put(url, body)
        case .delete:
            responseData = try await delete(url)
        }

        return try decoder.decode(Response.self, from: responseData)
    }
}

This replaces the old fetch and send convenience methods with a single entry point. The endpoint carries all the information, so there's no ambiguity about which HTTP method to use or what types to expect.

Now define the Landmarks API surface in one place:

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

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

    static func byCategory(_ category: Category) -> Endpoint<EmptyBody, [LandmarkApiModel]> {
        Endpoint(
            path: "landmarks/category/\(category.rawValue.lowercased())",
            method: .get
        )
    }

    static func update(id: UUID, body: UpdateLandmarkRequest) -> Endpoint<UpdateLandmarkRequest, LandmarkApiModel> {
        Endpoint(path: "landmarks/\(id)", method: .put, body: body)
    }
}

The UpdateLandmarkRequest is a small, purpose-built type that carries only the fields the server needs for an update:

struct UpdateLandmarkRequest: Encodable, Sendable {
    let name: String
    let location: String
    let description: String
    let imageName: String
    let isFeatured: Bool
    let category: String
}

Notice this is not the full LandmarkApiModel. API models represent what the server returns, not what you send. Request types are often a subset of fields, and keeping them separate means the compiler tells you exactly what the server expects. Every endpoint is defined right here. The request and response types are encoded in the function signatures, so if you accidentally try to send the wrong type, the compiler catches it. If the server adds a new endpoint, you add one static method and you're done.

This is the same pattern you'd use for any feature. A reservations API would get its own ReservationEndpoints enum with the same structure. The NetworkClient doesn't need to change at all.

Updating the Landmark Service

Now we connect everything. The live LandmarkService combines the network client (this post), API model mapping (Post 2), the observable store (Post 3), and caching (Post 4):

extension LandmarkService {
    static func live(
        client: NetworkClient,
        baseURL: URL,
        cache: TieredCache
    ) -> LandmarkService {
        let store = LandmarkStore()

        return LandmarkService(
            store: store,
            fetchLandmarks: { policy in
                switch policy {
                case .cacheThenFetch:
                    if let cached = await cache.getValue([Landmark].self, id: [Landmark].cacheIdentifier) {
                        await store.setLandmarks(cached, source: .cache)
                    } else {
                        await store.setLoading()
                    }

                    let apiModels = try await client.execute(LandmarkEndpoints.list(), baseURL: baseURL)
                    let landmarks = apiModels.compactMap(\.domainModel)
                    await cache.set(landmarks)
                    await store.setLandmarks(landmarks, source: .network)

                case .cacheElseFetch:
                    if let cached = await cache.getValue([Landmark].self, id: [Landmark].cacheIdentifier) {
                        await store.setLandmarks(cached, source: .cache)
                        return
                    }
                    await store.setLoading()
                    let apiModels = try await client.execute(LandmarkEndpoints.list(), baseURL: baseURL)
                    let landmarks = apiModels.compactMap(\.domainModel)
                    await cache.set(landmarks)
                    await store.setLandmarks(landmarks, source: .network)

                case .networkOnly:
                    await store.setLoading()
                    let apiModels = try await client.execute(LandmarkEndpoints.list(), baseURL: baseURL)
                    let landmarks = apiModels.compactMap(\.domainModel)
                    await cache.set(landmarks)
                    await store.setLandmarks(landmarks, source: .network)

                case .cacheOnly:
                    if let cached = await cache.getValue([Landmark].self, id: [Landmark].cacheIdentifier) {
                        await store.setLandmarks(cached, source: .cache)
                    }

                case .networkElseCache:
                    await store.setLoading()
                    do {
                        let apiModels = try await client.execute(LandmarkEndpoints.list(), baseURL: baseURL)
                        let landmarks = apiModels.compactMap(\.domainModel)
                        await cache.set(landmarks)
                        await store.setLandmarks(landmarks, source: .network)
                    } catch {
                        if let cached = await cache.getValue([Landmark].self, id: [Landmark].cacheIdentifier) {
                            await store.setLandmarks(cached, source: .cache)
                        } else {
                            throw error
                        }
                    }
                }
            },
            fetchLandmark: { id in
                let apiModel = try await client.execute(LandmarkEndpoints.get(id: id), baseURL: baseURL)
                return apiModel.domainModel
            },
            fetchLandmarksByCategory: { category in
                let apiModels = try await client.execute(LandmarkEndpoints.byCategory(category), baseURL: baseURL)
                return apiModels.compactMap(\.domainModel)
            },
            clearCache: {
                await cache.clear()
            }
        )
    }
}

Look at what each post contributed:

  • Post 2 gives us LandmarkApiModel and the .domainModel mapping. The compactMap(\.domainModel) call filters invalid records.
  • Post 3 gives us the closure-based service pattern and the LandmarkStore. The store is @Observable, so views update automatically.
  • Post 4 gives us TieredCache and CachePolicy. Each policy controls when we hit the cache vs the network.
  • Post 5 gives us the backend that returns LandmarkResponse objects, which our LandmarkApiModel decodes.
  • This post gives us Endpoint and LandmarkEndpoints. Every network call reads as client.execute(LandmarkEndpoints.list(), baseURL: baseURL) instead of scattered URL strings and method guessing.

Every piece connects cleanly because they were designed with clear boundaries from the start.

Favorites Flow: End to End

Let's trace a single user action through every layer. The user taps the heart icon on a landmark to toggle its favorite status.

First, the view calls the service:

struct LandmarkDetailView: View {
    let landmark: Landmark
    @Environment(\.landmarkService) private var landmarkService

    var body: some View {
        ScrollView {
            // ... landmark content

            Button {
                Task {
                    try? await landmarkService.toggleFavorite(landmark)
                }
            } label: {
                Image(systemName: landmark.isFeatured ? "heart.fill" : "heart")
            }
        }
        .navigationTitle(landmark.name)
    }
}

The service sends a PUT request to the server:

toggleFavorite: { landmark in
    let request = UpdateLandmarkRequest(
        name: landmark.name,
        location: landmark.location,
        description: landmark.description,
        imageName: landmark.imageName,
        isFeatured: !landmark.isFeatured,
        category: landmark.category.rawValue
    )

    let response = try await client.execute(
        LandmarkEndpoints.update(id: landmark.id, body: request),
        baseURL: baseURL
    )

    // Refresh the full list with networkOnly to bypass cache
    try await store.service?.fetchLandmarks(.networkOnly)
}

The server receives the PUT, updates the database, and returns the updated response. Back on iOS, we re-fetch with .networkOnly to ensure the store and cache have the latest data. The store updates, the @Observable machinery fires, and the view re-renders with the filled heart.

Every layer did its job: 1. View detected the user action and called the service 2. Service coordinated the network call and store update 3. Network client handled the HTTP request 4. API models handled encoding/decoding 5. Server updated the database and returned the response 6. Store updated the observable state 7. View re-rendered automatically

Category Browsing with Navigation

Remember the Screen enum from Post 1? Category browsing uses it to navigate from a category list to a filtered landmarks view:

struct CategoryListView: View {
    @Environment(\.landmarkService) private var landmarkService
    private var store: LandmarkStore { landmarkService.store }

    var body: some View {
        List(Category.allCases, id: \.self) { category in
            NavigationLink(screen: .landmarks(.category(category))) {
                HStack {
                    Image(systemName: category.systemImage)
                    Text(category.rawValue)
                    Spacer()
                    Text("\(store.landmarks(for: category).count)")
                        .foregroundStyle(.secondary)
                }
            }
        }
        .navigationTitle("Categories")
    }
}

The CategoryView destination loads landmarks from the backend filtered by category:

struct CategoryView: View {
    let category: Category
    var path: Binding<[Screen]>?
    @Environment(\.landmarkService) private var landmarkService
    @State private var landmarks: [Landmark] = []

    var body: some View {
        List(landmarks) { landmark in
            NavigationLink(screen: .landmarks(.detail(landmark))) {
                LandmarkRow(landmark: landmark)
            }
        }
        .navigationTitle(category.rawValue)
        .task {
            landmarks = (try? await landmarkService.fetchLandmarksByCategory(category)) ?? []
        }
    }
}

The navigation is type-safe (Post 1), the service is injected via the environment (Post 3), and the category filter hits the backend's /landmarks/category/:category endpoint (Post 5). Each post's contribution is visible.

Offline Mode

Offline support falls out naturally from the caching architecture in Post 4. When the user has no network connection, switch to .cacheOnly:

struct LandmarkListView: View {
    @Environment(\.landmarkService) private var landmarkService
    private var store: LandmarkStore { landmarkService.store }

    var body: some View {
        List(store.landmarks) { landmark in
            NavigationLink(screen: .landmarks(.detail(landmark))) {
                LandmarkRow(landmark: landmark)
            }
        }
        .overlay {
            if store.isShowingCachedData {
                VStack {
                    Spacer()
                    Label("Showing cached data", systemImage: "arrow.triangle.2.circlepath")
                        .font(.caption)
                        .padding(8)
                        .background(.ultraThinMaterial)
                        .clipShape(Capsule())
                        .padding(.bottom, 8)
                }
            }
        }
        .task {
            do {
                try await landmarkService.fetchLandmarks(.cacheThenFetch)
            } catch {
                // Network failed - fall back to cache only
                try? await landmarkService.fetchLandmarks(.cacheOnly)
            }
        }
    }
}

The .cacheThenFetch policy shows cached data immediately and tries to refresh. If the refresh fails (no network), the user still sees the cached data. The isShowingCachedData property from the store lets us show a subtle indicator that the data might be stale.

For a more robust approach, you could use .networkElseCache, which tries the network first and falls back to cache on failure. The choice depends on your UX priorities: show something fast (cacheThenFetch) or prioritize freshness (networkElseCache).

Preview and Mock Configurations

Here's something satisfying: the preview and mock configurations from Post 3 still work without any changes.

#Preview {
    NavigationStack {
        LandmarkListView()
    }
    .services(.preview)
}

The .preview services pre-populate the store with mock data. No network calls, no cache setup, instant rendering. The views don't know or care whether data comes from a Vapor server, a mock, or a preview configuration. They just observe the store.

This is the payoff of clean boundaries. We added a real backend, a network client, and caching without touching a single preview. The .mock configuration still works too, complete with simulated delays for testing loading states.

Wrapping Up

Let's recap what each post contributed to the final application:

Post 1: Navigation gave us the Screen enum and DestinationContent factory. Every navigation action in the app is type-safe, centralized, and reusable.

Post 2: API vs Domain Models gave us the separation between LandmarkApiModel and Landmark, with the .domainModel mapping that validates and transforms data at the boundary.

Post 3: Dependency Injection gave us closure-based services, observable stores, and environment injection. The LandmarkService struct with swappable closures is the backbone of data flow.

Post 4: Caching gave us TieredCache and CachePolicy. Offline mode, stale-while-revalidate, and persistent storage all came from this layer.

Post 5: Vapor Backend gave us a real server with Fluent models, response types, and RESTful endpoints that mirror the iOS patterns.

Every pattern composes cleanly because every layer has clear boundaries. The network layer doesn't know about caching. The cache doesn't know about views. The views don't know about HTTP. Each piece does one thing well and connects through simple interfaces.

The complete application is a good foundation for any Swift app that talks to a server. In the next post, we'll deploy the Vapor backend to AWS with Docker, ECS, and a GitHub Actions CI/CD pipeline.