Setting Up a Backend Server for Our Landmarks App

We've spent the last four posts building a clean iOS architecture: type-safe navigation, separated API and domain models, closure-based services, and tiered caching. But the whole time, we've been working against mock data. It's time to build the real backend.

The good news? If you've followed along so far, most of the concepts will feel familiar. Vapor's architecture mirrors what we built on iOS: models that represent your data's truth, response types that define the API boundary, and mapping between the two. Same patterns, other side of the wire.

You can find the complete server project on GitHub.

Why Vapor?

There are plenty of backend frameworks out there. Django, Rails, Express, Go's standard library. But Vapor gives us something none of those can: Swift end-to-end.

This isn't just about language preference. When your server and client share the same language, you share the same mental models. Optionals work the same way. Enums with associated values work the same way. Codable works the same way. The patterns you internalized on iOS translate directly.

That said, Vapor isn't a toy. It's a production-grade framework with async/await support, a mature ORM (Fluent), middleware, authentication, WebSockets, and a strong community. If you're comfortable with Swift, you can be productive with Vapor quickly.

Project Setup

Start by installing the Vapor toolbox and creating a new project:

// Package.swift dependencies
.package(url: "https://github.com/vapor/vapor.git", from: "4.89.0"),
.package(url: "https://github.com/vapor/fluent.git", from: "4.9.0"),
.package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.6.0"),

We're using SQLite for local development because it requires zero setup. No database server to install, no connection strings to configure. Just build and run. We'll swap to Postgres for production later in the deployment post.

Your configure.swift sets up the database and registers migrations:

import Fluent
import FluentSQLiteDriver
import Vapor

public func configure(_ app: Application) async throws {
    app.databases.use(.sqlite(.file("db.sqlite")), as: .sqlite)

    // Register migrations
    app.migrations.add(CreateLandmark())
    app.migrations.add(SeedLandmarks())

    try await app.autoMigrate()

    try routes(app)
}

autoMigrate() runs pending migrations on startup. In development, this keeps your schema in sync without manual steps. In production, you'll want more control over when migrations run, but for local development, auto-migration is convenient.

Fluent Models: Your Database Truth

If domain models are your app's truth on iOS, Fluent models are your database's truth on the server. They define the schema, handle persistence, and enforce constraints.

import Fluent
import Vapor

final class LandmarkModel: Model, @unchecked Sendable {
    static let schema = "landmarks"

    @ID(key: .id)
    var id: UUID?

    @Field(key: "name")
    var name: String

    @Field(key: "location")
    var location: String

    @Field(key: "description")
    var description: String

    @Field(key: "image_name")
    var imageName: String

    @Field(key: "is_featured")
    var isFeatured: Bool

    @Enum(key: "category")
    var category: LandmarkCategory

    init() { }

    init(
        id: UUID? = nil,
        name: String,
        location: String,
        description: String,
        imageName: String,
        isFeatured: Bool,
        category: LandmarkCategory
    ) {
        self.id = id
        self.name = name
        self.location = location
        self.description = description
        self.imageName = imageName
        self.isFeatured = isFeatured
        self.category = category
    }
}

If you squint, this looks a lot like the Landmark domain model from Post 2. The properties are nearly identical. The difference is in the property wrappers: @ID, @Field, and @Enum tell Fluent how to map these properties to database columns.

The LandmarkCategory enum mirrors the Category enum from the iOS app:

enum LandmarkCategory: String, Codable, CaseIterable {
    case mountains
    case lakes
    case bridges
}

No unknown case here. On the server, we control the data. If someone sends an invalid category, we reject it at the API boundary rather than storing garbage.

The migration that creates the table:

struct CreateLandmark: AsyncMigration {
    func prepare(on database: Database) async throws {
        try await database.schema("landmarks")
            .id()
            .field("name", .string, .required)
            .field("location", .string, .required)
            .field("description", .string, .required)
            .field("image_name", .string, .required)
            .field("is_featured", .bool, .required, .custom("DEFAULT FALSE"))
            .field("category", .string, .required)
            .create()
    }

    func revert(on database: Database) async throws {
        try await database.schema("landmarks").delete()
    }
}

Every field is .required. On the server side, we enforce data integrity at the database level. The iOS app's domain models are non-optional because the mapping layer filters incomplete records. Here, the database itself prevents incomplete records from existing.

Response Types: The Server's API Models

Here's where the parallel to Post 2 becomes explicit. On iOS, we had API models (what the server sends) and domain models (what the app uses). On the server, we have Fluent models (what the database stores) and response types (what the API sends).

The separation serves the same purpose: your internal representation shouldn't leak to external consumers.

struct LandmarkResponse: Content {
    var id: UUID?
    var name: String
    var location: String
    var description: String
    var imageName: String
    var isFeatured: Bool
    var category: String

    init(from model: LandmarkModel) {
        self.id = model.id
        self.name = model.name
        self.location = model.location
        self.description = model.description
        self.imageName = model.imageName
        self.isFeatured = model.isFeatured
        self.category = model.category.rawValue
    }
}

The Content protocol is Vapor's equivalent of Codable for request/response bodies. It handles JSON encoding and decoding automatically.

Notice the category is a String in the response, not a LandmarkCategory enum. This is intentional. The API sends strings. The iOS app's CategoryApiModel receives strings. The enum parsing happens at each boundary, not in transit.

The toResponse() convenience on the Fluent model mirrors the domainModel computed property from Post 2:

extension LandmarkModel {
    func toResponse() -> LandmarkResponse {
        LandmarkResponse(from: self)
    }
}

On iOS: LandmarkApiModel -> .domainModel -> Landmark On Vapor: LandmarkModel -> .toResponse() -> LandmarkResponse

Same concept. Same direction of mapping. Different side of the wire.

For creating and updating landmarks, we need a separate request type that represents incoming data:

struct CreateLandmarkRequest: Content, Validatable {
    var name: String
    var location: String
    var description: String
    var imageName: String
    var isFeatured: Bool
    var category: String

    static func validations(_ validations: inout Validations) {
        validations.add("name", as: String.self, is: !.empty)
        validations.add("location", as: String.self, is: !.empty)
        validations.add("category", as: String.self, is: .in("mountains", "lakes", "bridges"))
    }

    func toModel() -> LandmarkModel {
        LandmarkModel(
            name: name,
            location: location,
            description: description,
            imageName: imageName,
            isFeatured: isFeatured,
            category: LandmarkCategory(rawValue: category) ?? .mountains
        )
    }
}

The Validatable protocol lets you define validation rules declaratively. Name can't be empty, category must be one of the known values. If validation fails, Vapor automatically returns a 400 response with details about what went wrong. No manual error handling needed.

Routes and Controllers

Routes define your API surface. We'll organize them in a controller that groups related endpoints:

struct LandmarkController: RouteCollection {
    func boot(routes: RoutesBuilder) throws {
        let landmarks = routes.grouped("landmarks")

        landmarks.get(use: index)
        landmarks.get(":id", use: show)
        landmarks.post(use: create)
        landmarks.put(":id", use: update)
        landmarks.delete(":id", use: delete)

        landmarks.get("featured", use: featured)
        landmarks.get("category", ":category", use: byCategory)
    }
}

Each handler is straightforward. Fetch the model, convert to a response, return:

@Sendable
func index(req: Request) async throws -> [LandmarkResponse] {
    let landmarks = try await LandmarkModel.query(on: req.db).all()
    return landmarks.map { $0.toResponse() }
}

@Sendable
func show(req: Request) async throws -> LandmarkResponse {
    guard let landmark = try await LandmarkModel.find(req.parameters.get("id"), on: req.db) else {
        throw Abort(.notFound, reason: "Landmark not found")
    }
    return landmark.toResponse()
}

@Sendable
func create(req: Request) async throws -> LandmarkResponse {
    try CreateLandmarkRequest.validate(content: req)
    let input = try req.content.decode(CreateLandmarkRequest.self)
    let model = input.toModel()
    try await model.save(on: req.db)
    return model.toResponse()
}

@Sendable
func update(req: Request) async throws -> LandmarkResponse {
    guard let landmark = try await LandmarkModel.find(req.parameters.get("id"), on: req.db) else {
        throw Abort(.notFound, reason: "Landmark not found")
    }

    let input = try req.content.decode(CreateLandmarkRequest.self)
    landmark.name = input.name
    landmark.location = input.location
    landmark.description = input.description
    landmark.imageName = input.imageName
    landmark.isFeatured = input.isFeatured
    landmark.category = LandmarkCategory(rawValue: input.category) ?? landmark.category

    try await landmark.save(on: req.db)
    return landmark.toResponse()
}

@Sendable
func delete(req: Request) async throws -> HTTPStatus {
    guard let landmark = try await LandmarkModel.find(req.parameters.get("id"), on: req.db) else {
        throw Abort(.notFound, reason: "Landmark not found")
    }
    try await landmark.delete(on: req.db)
    return .noContent
}

The featured and byCategory endpoints use Fluent's query builder for filtering:

@Sendable
func featured(req: Request) async throws -> [LandmarkResponse] {
    let landmarks = try await LandmarkModel.query(on: req.db)
        .filter(\.$isFeatured == true)
        .all()
    return landmarks.map { $0.toResponse() }
}

@Sendable
func byCategory(req: Request) async throws -> [LandmarkResponse] {
    guard let categoryString = req.parameters.get("category"),
          let category = LandmarkCategory(rawValue: categoryString) else {
        throw Abort(.badRequest, reason: "Invalid category")
    }

    let landmarks = try await LandmarkModel.query(on: req.db)
        .filter(\.$category == category)
        .all()
    return landmarks.map { $0.toResponse() }
}

Fluent's query builder uses Swift key paths for filtering. \.$isFeatured == true generates the appropriate SQL WHERE clause. It's type-safe, so you can't accidentally filter on a field that doesn't exist.

Seeding the Database

An empty database isn't very useful for development. Let's seed it with Apple's sample Landmarks data using a migration:

struct SeedLandmarks: AsyncMigration {
    func prepare(on database: Database) async throws {
        let landmarks = [
            LandmarkModel(
                name: "Turtle Rock",
                location: "Twentynine Palms, California",
                description: "Nestled in the heart of Joshua Tree National Park...",
                imageName: "turtlerock",
                isFeatured: true,
                category: .mountains
            ),
            LandmarkModel(
                name: "Silver Salmon Creek",
                location: "Lake Clark National Park, Alaska",
                description: "A pristine wilderness area...",
                imageName: "silversalmoncreek",
                isFeatured: false,
                category: .lakes
            ),
            LandmarkModel(
                name: "Chilkoot Trail",
                location: "Skagway, Alaska",
                description: "A historic trail following the path of gold rush miners...",
                imageName: "chilkoottrail",
                isFeatured: false,
                category: .mountains
            ),
            LandmarkModel(
                name: "St. Mary Lake",
                location: "Glacier National Park, Montana",
                description: "A stunning glacial lake surrounded by towering peaks...",
                imageName: "stmarylake",
                isFeatured: true,
                category: .lakes
            ),
            LandmarkModel(
                name: "Twin Lake",
                location: "Wrangell-St. Elias National Park, Alaska",
                description: "A remote pair of connected lakes...",
                imageName: "twinlake",
                isFeatured: false,
                category: .lakes
            ),
            LandmarkModel(
                name: "Lake McDonald",
                location: "Glacier National Park, Montana",
                description: "The largest lake in Glacier National Park...",
                imageName: "lakemcdonald",
                isFeatured: false,
                category: .lakes
            ),
            LandmarkModel(
                name: "Charley Rivers",
                location: "Yukon-Charley Rivers National Preserve, Alaska",
                description: "Wild rivers cutting through the Alaskan interior...",
                imageName: "charleyrivers",
                isFeatured: true,
                category: .mountains
            ),
        ]

        for landmark in landmarks {
            try await landmark.save(on: database)
        }
    }

    func revert(on database: Database) async throws {
        try await LandmarkModel.query(on: database).delete()
    }
}

This runs once as part of the migration system. Fluent tracks which migrations have been applied, so re-running the app won't duplicate your seed data.

Error Handling

Vapor's error handling is built around the Abort type. When you throw an Abort, Vapor automatically converts it to an appropriate HTTP response:

// Returns a 404 with a JSON body: {"error": true, "reason": "Landmark not found"}
throw Abort(.notFound, reason: "Landmark not found")

// Returns a 400 with a JSON body: {"error": true, "reason": "Invalid category"}
throw Abort(.badRequest, reason: "Invalid category")

For consistent error responses across your API, add a custom error middleware:

struct ErrorResponse: Content {
    let error: Bool
    let reason: String
    let code: UInt
}

struct CustomErrorMiddleware: AsyncMiddleware {
    func respond(to request: Request, chainingTo next: AsyncResponder) async throws -> Response {
        do {
            return try await next.respond(to: request)
        } catch let abort as Abort {
            let body = ErrorResponse(
                error: true,
                reason: abort.reason,
                code: abort.status.code
            )
            let response = Response(status: abort.status)
            try response.content.encode(body)
            return response
        } catch {
            let body = ErrorResponse(
                error: true,
                reason: "Internal server error",
                code: 500
            )
            let response = Response(status: .internalServerError)
            try response.content.encode(body)
            return response
        }
    }
}

This ensures every error response has the same JSON structure, which makes error handling on the iOS side predictable. Your iOS network client can always expect {"error": true, "reason": "..."} when something goes wrong.

Register it in configure.swift:

app.middleware = .init()  // Clear default error middleware
app.middleware.use(CustomErrorMiddleware())

Wrapping Up

If you've followed the iOS posts, this should feel like looking in a mirror. The concepts map directly:

Fluent Models are your database's domain models. They define the truth of your data's structure with proper types, required fields, and enum constraints. Just like iOS domain models define your app's truth.

Response types are the server's API models. They define the API boundary, controlling exactly what clients see. Just like LandmarkApiModel on iOS defines what the app receives.

toResponse() is the server's version of domainModel. Both are mapping functions at the boundary between internal and external representations. One maps database to API, the other maps API to app.

The Fluent model never leaks beyond the server. The response type is the public contract. The mapping happens at the boundary. It's the same architecture as Post 2, applied to the other side.

With the backend running, we have everything we need to connect our iOS app to a real server. In the next post, we'll wire the full stack together and see every pattern from this series compose into a working application.