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 Server
↓ JSON 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
LandmarkApiModeland the.domainModelmapping. ThecompactMap(\.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
TieredCacheandCachePolicy. Each policy controls when we hit the cache vs the network. - Post 5 gives us the backend that returns
LandmarkResponseobjects, which ourLandmarkApiModeldecodes. - This post gives us
EndpointandLandmarkEndpoints. Every network call reads asclient.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.