API Design, Versioning and DTOs

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Vapor 4.122.2 on Swift 6.4 (the code builds in the Swift 6 language mode with the upcoming features of the official template switched on; Vapor 4.122.2 itself needs tools version 6.0, the template declares 6.3), Fluent 4.13.0 and fluent-sqlite-driver 4.9.0, checked on 2026-10-17. Every Swift listing on this page comes from one SwiftPM package that built with zero warnings, and every curl output was captured from that package running on macOS against an in-memory SQLite database seeded with a few Bookshelf books. Vapor 5 is at 5.0.0-beta.4 and is not released yet; the version notes at the end say what is known. The page assembles the pieces of the previous ones — routing, controllers, Content, validation, error handling and Fluent queries — into one public API contract, and the contract is the subject: what the URLs mean, which status codes come back, which shapes cross the wire and how those shapes change over time. The same API is designed on the axum JSON REST APIs page of this site, so the decisions can be compared. A schema-first alternative, where an OpenAPI document is the contract and the Swift types are generated from it, is on the OpenAPI with swift-openapi-generator page, and the Testing page shows how to test these endpoints with VaporTesting.

Where it sits in the request

An API contract does not add a stage to the request lifecycle; it is decided by what each stage does. The middleware chain adds cross-cutting headers (the deprecation headers below), the router selects the versioned route group, the handler decodes and validates a DTO (data transfer object), talks to Fluent with a model, and encodes another DTO as the response. The Fluent model never leaves the handler.

flowchart LR C["client"] --> M["DeprecationMiddleware
(global, at: .beginning)"] M --> E["ErrorMiddleware"] E --> R["router: /api/v1 or /api/v2 group"] R --> H["handler"] H --> D1["decode and validate
BookCreate / BookPatch"] D1 --> F["Fluent model: Book"] F --> D2["map to BookResponse
or BookResponseV2"] D2 --> X["JSON response"]

Resource modelling

A REST API exposes resources (things with an identity) at URLs and uses the HTTP method to say what to do with them. Three rules cover most of the design:

  • URLs name nouns in the plural and never verbs: /api/v1/books is the collection, /api/v1/books/{id} one book. The method is the verb (GET reads, POST adds to a collection, PUT replaces, PATCH changes part of, DELETE removes), as defined by RFC 9110.

  • A resource that only exists inside another is nested one level: /api/v1/books/{id}/reviews is "the reviews of this book". Deeper nesting (/authors/1/books/2/reviews/3) makes URLs long and couples clients to the hierarchy; once a resource has its own identity, give it a top-level URL too and use the nested one for the list and the create.

  • Things that are not CRUD still fit: an action becomes a sub-resource that is created (POST /loans borrows a book), or a state field that is patched. Avoid POST /books/{id}/publishBook.

In Vapor, each rule is a route group. The controller registers books and, inside it, :bookID (a dynamic path parameter read with req.parameters.get("bookID")), with reviews nested under the book. The full controller of this page, BooksV1Controller, is below; first the vocabulary it relies on.

Request Meaning Success response

GET /api/v1/books

List books, filtered, sorted and paginated

200 with a Page<BookResponse>

POST /api/v1/books

Create a book

201 Created with Location and the new book

GET /api/v1/books/{id}

Read one book

200, or 404

PUT /api/v1/books/{id}

Replace the book with the complete body

200 with the book

PATCH /api/v1/books/{id}

Change only the fields sent

200 with the book

DELETE /api/v1/books/{id}

Remove the book

204 No Content

GET /api/v1/books/{id}/reviews

List the reviews of a book

200 with a page

POST /api/v1/books/{id}/reviews

Add a review to the book

201 Created with Location

Status codes

Vapor turns a thrown Abort (and any AbortError) into a response with that status and a body such as {"reason":"Not Found","error":true}, through the ErrorMiddleware that is installed by default. The codes below are the ones a CRUD API uses; the right-hand column is what the code of this page produces.

Code Use it when In this page

200 OK

A read or an update succeeded and there is a body

GET, PUT, PATCH

201 Created

A resource was created; send Location

POST /books, POST /books/{id}/reviews

204 No Content

Success with no body

DELETE (a handler returning HTTPStatus.noContent)

400 Bad Request

The request cannot be decoded or fails validation

Validatable failures, a bad sort value, a body missing a field

404 Not Found

The identified resource does not exist

throw Abort(.notFound) in the lookup

409 Conflict

The request clashes with the current state

duplicate ISBN

422 Unprocessable Entity

The body is well formed, but refers to something that does not exist

an authorID or userID that matches no row

500

A bug or an infrastructure failure; never intentional

an unchecked foreign key (see the pitfalls)

Vapor’s own validation failures are 400, not 422; 422 is a choice of this page for semantic errors that the Validatable rules cannot see because they need the database. Both are defensible, and what matters is using one scheme everywhere and documenting it. Other codes worth planning for are 401 and 403 (see the authentication and authorization pages), 412 Precondition Failed for optimistic concurrency with ETag and If-Match, and 429 Too Many Requests.

Creating a resource: 201 and Location

A successful POST that creates a resource answers 201 Created, includes the new resource in the body, and names its canonical URL in the Location header, so a client never has to build the URL itself. A handler that returns a Content value gets 200; to change the status or add a header, return a Response. Every Content type has encodeResponse(status:headers:for:) for that, and HTTPHeaders accepts a dictionary literal keyed by String:

@Sendable
func create(req: Request) async throws -> Response {
    try BookCreate.validate(content: req)
    let input = try req.content.decode(BookCreate.self)
    guard try await Author.find(input.authorID, on: req.db) != nil else {
        throw Abort(.unprocessableEntity, reason: "authorID does not reference an existing author")
    }
    guard try await Book.query(on: req.db).filter(\.$isbn == input.isbn).count() == 0 else {
        throw Abort(.conflict, reason: "A book with this ISBN already exists")
    }
    let book = input.makeModel()
    try await book.save(on: req.db)
    let body = try BookResponse(book)
    return try await body.encodeResponse(
        status: .created,
        headers: ["Location": "/api/v1/books/\(body.id)"],
        for: req
    )
}

The count() == 0 check turns the common conflict into a clean 409. It is not race-free (two requests can pass the check together), so the unique(on: "isbn") constraint of the migration stays as the real guarantee; a request that loses the race gets a database error, which should also become a 409 in the error handling layer of the application.

$ curl -si -X POST http://127.0.0.1:8097/api/v1/books -H 'Content-Type: application/json' \
    -d '{"title":"Always Coming Home","isbn":"9780520227354","publishedYear":1985,"priceCents":1799,"authorID":"EC0836FF-0A5D-4D3C-8089-FC97F9B4222B"}'
HTTP/1.1 201 Created
content-type: application/json; charset=utf-8
Location: /api/v1/books/2B8A88CF-26D5-42A2-A17D-67118C59848B

{"id":"2B8A88CF-26D5-42A2-A17D-67118C59848B","publishedYear":1985,"authorID":"EC0836FF-0A5D-4D3C-8089-FC97F9B4222B","status":"draft","priceCents":1799,"title":"Always Coming Home","isbn":"9780520227354"}

(The keys of the JSON object come out in no fixed order; the service sends one line. The Deprecation, Sunset and link headers added to every /api/v1 response by the middleware of the versioning section are left out of the captures on this page.)

PUT, PATCH and absent against null

PUT replaces the resource, so the body carries every field the client may set, and a missing field is an error. PATCH changes only what is sent, so every field is optional and a missing key means "leave as it is". In Swift the two are two structs; reusing one for both makes either PUT too lax or PATCH too strict.

/// PUT: the client sends the whole representation, so every field is required.
struct BookReplace: Content, Validatable {
    var title: String
    var publishedYear: Int
    var priceCents: Int
    var status: BookStatus

    static func validations(_ validations: inout Validations) {
        validations.add("title", as: String.self, is: !.empty && .count(...200))
        validations.add("publishedYear", as: Int.self, is: .range(1450...2100))
        validations.add("priceCents", as: Int.self, is: .range(0...))
    }

    func apply(to book: Book) {
        book.title = title
        book.publishedYear = publishedYear
        book.priceCents = priceCents
        book.status = status
    }
}

/// PATCH: every field is optional, an absent key means "leave as it is".
struct BookPatch: Content, Validatable {
    var title: String?
    var publishedYear: Int?
    var priceCents: Int?
    var status: BookStatus?

    static func validations(_ validations: inout Validations) {
        validations.add("title", as: String?.self, is: .nil || (!.empty && .count(...200)), required: false)
        validations.add("publishedYear", as: Int?.self, is: .nil || .range(1450...2100), required: false)
        validations.add("priceCents", as: Int?.self, is: .nil || .range(0...), required: false)
    }

    func apply(to book: Book) {
        if let title { book.title = title }
        if let publishedYear { book.publishedYear = publishedYear }
        if let priceCents { book.priceCents = priceCents }
        if let status { book.status = status }
    }
}

For an optional property, Validations.add(_:as:is:required:) is declared with the optional type (Int?.self), the validator is combined with .nil ||, and required: false lets the key be absent. The validation messages say "not null and is less than minimum of 0" for such properties. Called with the same body that PUT accepts, PATCH works as a subset:

$ curl -s -X PATCH http://127.0.0.1:8097/api/v1/books/2B8A88CF-... -H 'Content-Type: application/json' -d '{"priceCents":1499,"status":"published"}'
{"status":"published","priceCents":1499,"title":"Always Coming Home","isbn":"9780520227354","authorID":"EC0836FF-...","id":"2B8A88CF-...","publishedYear":1985}
$ curl -s -X PATCH http://127.0.0.1:8097/api/v1/books/2B8A88CF-... -H 'Content-Type: application/json' -d '{"priceCents":-1}'
{"reason":"priceCents is not null and is less than minimum of 0","error":true}
$ curl -s -X PUT http://127.0.0.1:8097/api/v1/books/2B8A88CF-... -H 'Content-Type: application/json' -d '{"title":"Always Coming Home"}'
{"reason":"publishedYear is required, priceCents is required","error":true}

A plain String? cannot tell "the key is absent" from "the key is null`": the synthesised `Decodable uses decodeIfPresent, which returns nil for both. That matters for nullable columns. JSON Merge Patch (RFC 7396, media type application/merge-patch+json) defines absent as "do not touch" and null as "clear". The distinction needs a hand-written init(from:) that asks the container whether the key exists, with a double optional (String??) holding the three states:

// ---- JSON Merge Patch with explicit null ---------------------------------

struct AuthorPatch: Content {
    var name: String?
    /// `.none` = key absent, `.some(nil)` = explicit `null`, `.some(value)` = set.
    var bio: String??

    private enum CodingKeys: String, CodingKey { case name, bio }

    init(from decoder: any Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.name = try container.decodeIfPresent(String.self, forKey: .name)
        if container.contains(.bio) {
            self.bio = .some(try container.decodeIfPresent(String.self, forKey: .bio))
        } else {
            self.bio = .none
        }
    }

    func apply(to author: Author) {
        if let name { author.name = name }
        if let bio { author.bio = bio }
    }
}

The route that applies it is an ordinary PATCH handler (the full routes file is in the Backend-for-Frontend section); an author with no bio key keeps the bio, {"bio":null} sets it to nil and {"bio":"Novelist"} sets it.

$ curl -s -X PATCH http://127.0.0.1:8097/authors/EC0836FF-... -H 'Content-Type: application/json' -d '{"name":"Ursula Le Guin"}'
{"id":"EC0836FF-0A5D-4D3C-8089-FC97F9B4222B","name":"Ursula Le Guin"}

PATCH is neither safe nor, by definition, idempotent (an increment patch is not), so a client must not retry it blindly; PUT and DELETE are idempotent and may be retried.

DTOs: Create, Update, Public

A DTO is a plain struct that describes one shape that crosses the wire. Vapor makes it cheap: any Codable struct that conforms to Content is decoded from the request body (req.content.decode) and encoded into the response, using the JSON, URL-encoded form or multipart coder chosen by the Content-Type and Accept headers (see Content and Codable). The convention is one struct per direction and purpose:

DTO Purpose

BookCreate

Body of POST: everything required to create, nothing the server decides (id, status, timestamps)

BookReplace

Body of PUT: the complete set of client-writable fields

BookPatch

Body of PATCH: the same fields, all optional

BookResponse

What the API promises to send back, the "public" representation

struct BookCreate: Content, Validatable {
    var title: String
    var isbn: String
    var publishedYear: Int
    var priceCents: Int
    var authorID: UUID

    static func validations(_ validations: inout Validations) {
        validations.add("title", as: String.self, is: !.empty && .count(...200))
        validations.add("isbn", as: String.self, is: .count(10...17))
        validations.add("publishedYear", as: Int.self, is: .range(1450...2100))
        validations.add("priceCents", as: Int.self, is: .range(0...))
    }

    func makeModel() -> Book {
        Book(title: title, isbn: isbn, publishedYear: publishedYear, priceCents: priceCents,
             status: .draft, authorID: authorID)
    }
}


/// The public representation: exactly the fields the contract promises, in the names the contract uses.
struct BookResponse: Content {
    var id: UUID
    var title: String
    var isbn: String
    var publishedYear: Int
    var priceCents: Int
    var status: BookStatus
    var authorID: UUID

    init(_ book: Book) throws {
        self.id = try book.requireID()
        self.title = book.title
        self.isbn = book.isbn
        self.publishedYear = book.publishedYear
        self.priceCents = book.priceCents
        self.status = book.status
        self.authorID = book.$author.id
    }
}

Source: Vapor docs: Fluent models (the DTO pattern of the template’s TodoDTO)

Why not return the model

A Fluent model can conform to Content and be returned from a handler, and for a prototype it works. It is a trap for a public API for four reasons:

  • Leaks. The model encodes every @Field, so a User returned directly sends passwordHash (the Bookshelf User has exactly that field), and a Book sends createdAt, updatedAt and a "author":{"id":...} object whose shape is Fluent’s, not yours. Each new column becomes public the day it is added.

  • Mass assignment. A model decoded from the body lets the client set id, status or role, whatever the model has. A Create DTO whitelists the fields; the makeModel() function sets the ones the server decides.

  • Coupling. The JSON shape follows the schema. Renaming a column or splitting a table then breaks every client, which is the opposite of what a versioned contract is for. With DTOs the schema can change under an unchanged BookResponse.

  • Validation. A model has no Validatable conformance of its own to put on the input; a DTO carries the rules for that one request shape.

The mapping is written by hand in an init of the response DTO and in a makeModel() or apply(to:) of the input DTO. A 100-line mapping layer is a cost, but it is the only place that knows both shapes, and the compiler flags every mismatch. book.$author.id reads the foreign key without loading the author, so mapping the list does not trigger extra queries; a DTO that embeds the author (version 2 below) needs .with(\.$author) first.

Anything that must never be sent has its own public type. The user is the classic case:

/// Never put a Fluent model with a password hash in a response: this is the public view of a User.
struct UserPublic: Content {
    var id: UUID
    var email: String
    var role: UserRole

    init(_ user: User) throws {
        self.id = try user.requireID()
        self.email = user.email
        self.role = user.role
    }
}

Swift 6 notes belong here: the DTOs are value types made of Sendable properties, so Swift infers Sendable for them, Content already refines Sendable, and they can cross actors and tasks freely. The models are @unchecked Sendable classes (see Fluent models), which is a further reason to convert at the edge of the handler and to hand DTOs, not models, to other tasks.

Filtering, sorting and pagination

A collection endpoint without limits is a denial-of-service waiting to happen, and one with ad hoc parameter names makes every client learn a new dialect. The conventions that work:

  • Filters are plain query parameters named after the field: status=published, authorID=..., yearFrom=1960, yearTo=1980; free text search is q.

  • Sorting is a single sort parameter that takes a whitelisted name, with a leading - for descending order (sort=-year). Never pass the value to sort(DatabaseQuery.Field...): a client must not choose columns.

  • Pagination uses page (1-based) and per, which is exactly what Fluent’s paginate(for:) reads from req.query; the response carries metadata (page, per, total) next to items.

  • The order must be total (end every sort with the primary key) or two pages can overlap or skip a row.

The query struct decodes the filters with req.query.decode, validates them, and applies them to a Fluent QueryBuilder:

import Fluent
import Vapor

enum BookSort: String, Codable, Sendable {
    case title, year, price
    case titleDesc = "-title", yearDesc = "-year", priceDesc = "-price"
}

/// Query string: /books?status=published&q=earth&yearFrom=1960&sort=-year&page=1&per=10
struct BookListQuery: Content, Validatable {
    var q: String?
    var status: BookStatus?
    var authorID: UUID?
    var yearFrom: Int?
    var yearTo: Int?
    var sort: BookSort?

    static func validations(_ validations: inout Validations) {
        validations.add("q", as: String?.self, is: .nil || .count(...100), required: false)
    }

    func apply(to query: QueryBuilder<Book>) {
        if let q { query.filter(\.$title ~~ q) }
        if let status { query.filter(\.$status == status) }
        if let authorID { query.filter(\.$author.$id == authorID) }
        if let yearFrom { query.filter(\.$publishedYear >= yearFrom) }
        if let yearTo { query.filter(\.$publishedYear <= yearTo) }
        switch sort ?? .title {
        case .title: query.sort(\.$title, .ascending)
        case .titleDesc: query.sort(\.$title, .descending)
        case .year: query.sort(\.$publishedYear, .ascending)
        case .yearDesc: query.sort(\.$publishedYear, .descending)
        case .price: query.sort(\.$priceCents, .ascending)
        case .priceDesc: query.sort(\.$priceCents, .descending)
        }
        // a total order: ties are broken by the primary key so that pages never overlap
        query.sort(\.$id, .ascending)
    }
}

The decoder rejects a sort value outside the enum, so the whitelist is the type. The handler is three lines (it is index in the controller listing below):

$ curl -si 'http://127.0.0.1:8097/api/v1/books?status=published&sort=-year&per=2'
HTTP/1.1 200 OK
content-type: application/json; charset=utf-8

{"metadata":{"page":1,"per":2,"total":3},"items":[{"publishedYear":1974,"status":"published","id":"8ED6DD5C-...","priceCents":1299,"isbn":"9780061054884","title":"The Dispossessed","authorID":"EC0836FF-..."},{"publishedYear":1969,"status":"published","id":"5081577C-...","priceCents":999,"isbn":"9780441478125","title":"The Left Hand of Darkness","authorID":"EC0836FF-..."}]}

$ curl -s 'http://127.0.0.1:8097/api/v1/books?sort=bogus'
{"reason":"Data corrupted at path 'sort'. Cannot initialize BookSort from invalid String value bogus.","error":true}

Page<T>.map converts the models of a page to DTOs and keeps the metadata, which is why the handler returns Page<BookResponse>. The application sets app.fluent.pagination.pageSizeLimit = 50, so ?per=100000 is cut to 50; without that limit the client chooses the page size. The costs of offset pagination, total counts and the keyset alternative for large tables are covered on Fluent queries and pagination and, generally, on Pagination: Offset vs. Keyset.

Two details. A Page response is a JSON object, so a client that expects a bare array breaks when a version adds metadata; decide the envelope at version 1 (this page uses items and metadata in both versions). And a list endpoint that does not filter by status returns drafts too; list endpoints deserve the same authorisation filter as the single-resource ones.

The controller

All the pieces together: the version 1 controller of the Bookshelf books and their nested reviews. The handlers are @Sendable methods of a struct, as on the controllers page, and find is the single place that turns a missing row into 404.

import Fluent
import Vapor

struct BooksV1Controller: RouteCollection {
    func boot(routes: any RoutesBuilder) throws {
        let books = routes.grouped("books")
        books.get(use: self.index)
        books.post(use: self.create)
        books.group(":bookID") { book in
            book.get(use: self.show)
            book.put(use: self.replace)
            book.patch(use: self.patch)
            book.delete(use: self.delete)
            book.get("reviews", use: self.reviews)
            book.post("reviews", use: self.addReview)
        }
    }

    @Sendable
    func index(req: Request) async throws -> Page<BookResponse> {
        try BookListQuery.validate(query: req)
        let filters = try req.query.decode(BookListQuery.self)
        let query = Book.query(on: req.db)
        filters.apply(to: query)
        return try await query.paginate(for: req).map { try BookResponse($0) }
    }

    @Sendable
    func show(req: Request) async throws -> BookResponse {
        try BookResponse(try await find(req))
    }

    @Sendable
    func create(req: Request) async throws -> Response {
        try BookCreate.validate(content: req)
        let input = try req.content.decode(BookCreate.self)
        guard try await Author.find(input.authorID, on: req.db) != nil else {
            throw Abort(.unprocessableEntity, reason: "authorID does not reference an existing author")
        }
        guard try await Book.query(on: req.db).filter(\.$isbn == input.isbn).count() == 0 else {
            throw Abort(.conflict, reason: "A book with this ISBN already exists")
        }
        let book = input.makeModel()
        try await book.save(on: req.db)
        let body = try BookResponse(book)
        return try await body.encodeResponse(
            status: .created,
            headers: ["Location": "/api/v1/books/\(body.id)"],
            for: req
        )
    }

    @Sendable
    func replace(req: Request) async throws -> BookResponse {
        try BookReplace.validate(content: req)
        let book = try await find(req)
        try req.content.decode(BookReplace.self).apply(to: book)
        try await book.update(on: req.db)
        return try BookResponse(book)
    }

    @Sendable
    func patch(req: Request) async throws -> BookResponse {
        try BookPatch.validate(content: req)
        let book = try await find(req)
        try req.content.decode(BookPatch.self).apply(to: book)
        try await book.update(on: req.db)
        return try BookResponse(book)
    }

    @Sendable
    func delete(req: Request) async throws -> HTTPStatus {
        try await find(req).delete(on: req.db)
        return .noContent
    }

    @Sendable
    func reviews(req: Request) async throws -> Page<ReviewResponse> {
        let book = try await find(req)
        return try await book.$reviews.query(on: req.db)
            .sort(\.$createdAt, .descending)
            .sort(\.$id, .ascending)
            .paginate(for: req)
            .map { try ReviewResponse($0) }
    }

    @Sendable
    func addReview(req: Request) async throws -> Response {
        try ReviewCreate.validate(content: req)
        let book = try await find(req)
        let input = try req.content.decode(ReviewCreate.self)
        guard try await User.find(input.userID, on: req.db) != nil else {
            throw Abort(.unprocessableEntity, reason: "userID does not reference an existing user")
        }
        let review = Review(rating: input.rating, body: input.body,
                            bookID: try book.requireID(), userID: input.userID)
        try await review.save(on: req.db)
        let body = try ReviewResponse(review)
        return try await body.encodeResponse(
            status: .created,
            headers: ["Location": "/api/v1/books/\(try book.requireID())/reviews/\(body.id)"],
            for: req
        )
    }

    private func find(_ req: Request) async throws -> Book {
        guard let book = try await Book.find(req.parameters.get("bookID"), on: req.db) else {
            throw Abort(.notFound)
        }
        return book
    }
}

Nothing in this controller checks who is calling. POST /books, PUT and PATCH /books/:bookID, DELETE /books/:bookID and POST /books/:bookID/reviews are open to anyone who can reach the server, which is the books' unauthenticated DELETE erratum. The page leaves authentication out to stay on API design; do not deploy the controller as it is. In a real application the GET routes stay public and the others go into a group behind a token authenticator and its guard (Fluent Authentication and API Tokens or JSON Web Tokens (JWT)), with a librarian role check for the catalogue changes (Authorization: Roles, Ownership and Policies).

Nested resources hang off the same :bookID group, so a review of a book that does not exist is a 404 before any review code runs. The review’s userID is in the body only to keep the example self-contained; in a real application it is the authenticated user (req.auth.require(User.self)), never client input. And it is checked, because an unchecked foreign key does not become a client error:

$ curl -si -X POST http://127.0.0.1:8097/api/v1/books/8ED6DD5C-.../reviews -H 'Content-Type: application/json' \
    -d '{"rating":5,"body":"Great","userID":"00000000-0000-0000-0000-000000000001"}'
HTTP/1.1 422 Unprocessable Entity

{"reason":"userID does not reference an existing user","error":true}

Without the User.find guard the same request answered 500 with the reason constraintForeignKeyFailed: FOREIGN KEY constraint failed, which is both the wrong status and an internal message in the body.

Versioning

Once a client you do not control depends on a response shape, changing it is a breaking change. Versioning is how a change ships without breaking the clients that have not moved yet. What counts as breaking: removing or renaming a field, changing a type or a unit, making an optional request field required, tightening validation, changing a status code. What does not: adding an optional request field, adding a response field (when clients ignore unknown keys, which is a rule to publish), or adding an endpoint.

Scheme Example Trade-offs

URL path

/api/v1/books, /api/v2/books

Visible, cacheable, routable by a proxy, easy to try with curl; the version is part of every URL

Header

Accept: application/vnd.bookshelf.v2+json

Clean URLs; invisible in logs, harder to test, needs Vary: Accept

Query parameter

/books?version=2

Easy to forget; pollutes cache keys; rarely used

URL-path versioning is the default for public APIs and the one this page uses; the cost is that every version needs its own route group. In Vapor that is two grouped calls on the same application, each registering a collection:

let api = app.grouped("api")
try api.grouped("v1").register(collection: BooksV1Controller())
try api.grouped("v2").register(collection: BooksV2Controller())

Version 2 of the books resource changes three things that version 1 cannot change without breaking clients: the price becomes an object with a currency, publishedYear is renamed year, and the response embeds the author instead of carrying its id. All three are new DTOs; the model and the table are untouched.

struct Money: Content {
    var amountCents: Int
    var currency: String
}

struct AuthorSummary: Content {
    var id: UUID
    var name: String
}

struct BookResponseV2: Content {
    var id: UUID
    var title: String
    var isbn: String
    var year: Int
    var price: Money
    var status: BookStatus
    var author: AuthorSummary

    /// The author must have been eager loaded with `.with(\.$author)`.
    init(_ book: Book) throws {
        self.id = try book.requireID()
        self.title = book.title
        self.isbn = book.isbn
        self.year = book.publishedYear
        self.price = Money(amountCents: book.priceCents, currency: "EUR")
        self.status = book.status
        self.author = AuthorSummary(id: try book.author.requireID(), name: book.author.name)
    }
}

import Fluent
import Vapor

struct BooksV2Controller: RouteCollection {
    func boot(routes: any RoutesBuilder) throws {
        let books = routes.grouped("books")
        books.get(use: self.index)
        books.get(":bookID", use: self.show)
    }

    @Sendable
    func index(req: Request) async throws -> Page<BookResponseV2> {
        try BookListQuery.validate(query: req)
        let filters = try req.query.decode(BookListQuery.self)
        let query = Book.query(on: req.db).with(\.$author)
        filters.apply(to: query)
        return try await query.paginate(for: req).map { try BookResponseV2($0) }
    }

    @Sendable
    func show(req: Request) async throws -> BookResponseV2 {
        guard let book = try await Book.query(on: req.db)
            .filter(\.$id == req.parameters.get("bookID", as: UUID.self) ?? UUID())
            .with(\.$author)
            .first()
        else { throw Abort(.notFound) }
        return try BookResponseV2(book)
    }
}
$ curl -s 'http://127.0.0.1:8097/api/v2/books?q=earth'
{"items":[{"year":1968,"price":{"amountCents":1099,"currency":"EUR"},"author":{"name":"Ursula K. Le Guin","id":"EC0836FF-..."},"status":"published","id":"B605A166-...","title":"A Wizard of Earthsea","isbn":"9780547773742"}],"metadata":{"per":10,"total":1,"page":1}}

Both versions share the model, the BookListQuery and the migrations, which is the point of DTOs. What is not shared is the controller: copying a controller per version is a few lines of boilerplate, while branching on the version inside one handler spreads version logic over the whole codebase. Keep the old version’s code frozen (bug fixes and security fixes only) and let it die. A schema change that version 1 cannot support (a dropped column) is an expand-and-contract migration: add the new column, backfill, move readers to version 2, remove the old column only after version 1 is gone.

Deprecation and sunset headers

Retiring a version is announced, not sprung. Two response headers carry the schedule in machine-readable form, so client libraries and gateways can warn without a human reading a changelog:

  • Deprecation (RFC 9745) says the resource is deprecated, either true or, as here, the date from which it is, written as @ and a Unix timestamp: Deprecation: @1790812800 is 2026-10-01T00:00:00Z.

  • Sunset (RFC 8594) is the HTTP date after which the resource may stop working: Sunset: Wed, 30 Jun 2027 23:59:59 GMT.

  • Link with rel="successor-version" points at the replacement (the RFC 9745 and RFC 8594 documents also suggest rel="deprecation" and rel="sunset" links to human-readable policy pages).

The middleware adds them to every response whose path starts with the old prefix, errors included. For that to work it must be installed outside the ErrorMiddleware: a middleware inside it never sees the response built from a thrown Abort, because the throw passes through. app.middleware.use(_, at: .beginning) puts it first:

import Vapor

/// Adds the deprecation headers to every response of a path prefix, errors included.
struct DeprecationMiddleware: AsyncMiddleware {
    let pathPrefix: String
    let deprecation: String   // RFC 9745: "@" + Unix timestamp of the deprecation date
    let sunset: String        // RFC 8594: an HTTP-date
    let successor: String     // URI of the replacement

    static let v1 = DeprecationMiddleware(
        pathPrefix: "/api/v1",
        deprecation: "@1790812800",                    // 2026-10-01T00:00:00Z
        sunset: "Wed, 30 Jun 2027 23:59:59 GMT",
        successor: "/api/v2"
    )

    func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
        let response = try await next.respond(to: request)
        if request.url.path.hasPrefix(pathPrefix) {
            response.headers.replaceOrAdd(name: "Deprecation", value: deprecation)
            response.headers.replaceOrAdd(name: "Sunset", value: sunset)
            response.headers.add(name: .link, value: "<\(successor)>; rel=\"successor-version\"")
        }
        return response
    }
}
app.middleware.use(DeprecationMiddleware.v1, at: .beginning)
$ curl -si http://127.0.0.1:8097/api/v1/books/00000000-0000-0000-0000-000000000000
HTTP/1.1 404 Not Found
content-type: application/json; charset=utf-8
Deprecation: @1790812800
Sunset: Wed, 30 Jun 2027 23:59:59 GMT
link: </api/v2>; rel="successor-version"

{"reason":"Not Found","error":true}

The headers are advice. After the sunset date the version is removed or answers 410 Gone; between the two, log which clients still call it (the User-Agent and an API key identify them) so that the last ones can be contacted. The middleware instance is a value type with let properties, so it is Sendable and needs no locking, and a global middleware like this one costs a string comparison per request. The same technique, on a route group instead of globally, applies when one endpoint rather than a whole version is retired.

Backends for Frontends

A Backend for Frontend (BFF) is an API layer written for one kind of client. A phone on a poor network wants its home screen in one round trip with exactly the fields it draws; a web admin console wants wide tables; a partner wants a stable, documented, versioned contract. Serving all three from one set of resource endpoints produces either chatty clients (many calls per screen) or bloated responses. A BFF owns the composition: it calls the domain services (in the same process here, or other services over HTTP), merges the results into a client-shaped DTO and hides everything else. It is the three-tier idea applied per client, and it sits in front of, or beside, the resource API rather than replacing it. When the services behind it are separate processes it becomes an API gateway, as on the Microservices and an API Gateway page; the HTTP client page covers req.client for those calls.

In Vapor the BFF is a route group and a DTO that mirrors the screen. Independent reads run concurrently with async let:

import Fluent
import Vapor

struct MobileHome: Content {
    var newest: [BookResponseV2]
    var bookCount: Int
    var reviewCount: Int
}

func routes(_ app: Application) throws {
    let api = app.grouped("api")
    try api.grouped("v1").register(collection: BooksV1Controller())
    try api.grouped("v2").register(collection: BooksV2Controller())

    // a backend for the mobile front end: one call, one shape
    app.get("bff", "mobile", "home") { req async throws -> MobileHome in
        async let newest = Book.query(on: req.db).filter(\.$status == .published)
            .with(\.$author).sort(\.$createdAt, .descending).sort(\.$id, .ascending).limit(5).all()
        async let books = Book.query(on: req.db).count()
        async let reviews = Review.query(on: req.db).count()
        return try await MobileHome(
            newest: newest.map { try BookResponseV2($0) },
            bookCount: books,
            reviewCount: reviews
        )
    }

    app.patch("authors", ":authorID") { req async throws -> AuthorSummary in
        guard let author = try await Author.find(req.parameters.get("authorID"), on: req.db) else {
            throw Abort(.notFound)
        }
        try req.content.decode(AuthorPatch.self).apply(to: author)
        try await author.update(on: req.db)
        return AuthorSummary(id: try author.requireID(), name: author.name)
    }
}
$ curl -s http://127.0.0.1:8097/bff/mobile/home
{"newest":[{"title":"Always Coming Home","author":{"name":"Ursula Le Guin","id":"EC0836FF-..."},"price":{"amountCents":1599,"currency":"EUR"},"year":1985,"isbn":"9780520227354","id":"2B8A88CF-...","status":"published"}, ...],"bookCount":5,"reviewCount":0}

Three queries share one req.db. Whether they really overlap depends on the driver (a pooled PostgreSQL or MySQL connection can use separate connections; the in-memory SQLite database of the example runs them one at a time). async let children are structured: they are cancelled and awaited when the handler throws, so none outlives the request. Nothing in the BFF is special to Vapor beyond that: the discipline is that it has its own DTOs, its own tests and its own deprecation policy, per client.

Swift 6 and concurrency

  • Handlers are @Sendable functions of Sendable structs: the controllers hold no mutable state, and anything they read (the database, the logger) comes from req.

  • DTOs are Content (hence Sendable) structs of Sendable values; Page<T> is Sendable when T is. Fluent models are final class with @unchecked Sendable, so keep them inside the handler and do not capture them in async let children or Task closures that outlive it; convert to DTOs first.

  • async let in a handler runs the child tasks under the request’s structured scope and requires that what they capture is Sendable; req is, and req.db is a Sendable existential.

  • Middleware conforming to AsyncMiddleware with only immutable stored properties compiles without annotations. A middleware that counts requests needs an actor or an atomic, not a stored var.

Common errors and pitfalls

  • The model is returned as Content. Passwords hashes, timestamps and Fluent’s nested @Parent shape appear in the JSON. Return DTOs; review any extension Model: Content.

  • Missing optional validation parameters. validations.add("x", as: Int?.self, is: .range(1...5)) without required: false fails when the key is absent; and as: Int.self fails with the nullable type.

  • PATCH cannot clear a field. The synthesised decoder collapses absent and null; use container.contains as in AuthorPatch.

  • Foreign keys as 500. Check the parent exists and answer 422; the database error text must not reach the client.

  • Location with a relative guess. Location should be derived from the route (or req.url), not rebuilt by hand in three places; a reverse proxy adds a path prefix the application does not know.

  • ErrorMiddleware and headers. Headers set by a middleware inside the ErrorMiddleware are lost on thrown errors; register it at: .beginning as above.

  • Unbounded lists. A list handler that calls all() instead of paginate(for:) is an outage with enough rows.

  • Silent breaking changes. Renaming a Codable property renames the JSON key. A DTO shared by two versions is a trap: copy it when the shape changes.

  • Query value decoding. req.query.decode throws a DecodingError that Vapor reports as 400 with the Foundation message in the reason, as seen for sort=bogus; catch it if the message should be friendlier.

Version notes

  • The pattern of this page (Content structs, Validatable, Page.map, encodeResponse(status:headers:for:)) is the same in all Vapor 4 releases since 4.0; Vapor 4.122 adds nothing specific to it. The generated template’s TodoController and TodoDTO use the same DTO pattern (a toModel() function on the DTO).

  • Page and PageRequest (the page and per parameters) come from Fluent and are unchanged in Fluent 4.13.0.

  • RFC 9745 (the Deprecation header) was published in 2025; articles about API deprecation written before it use only Sunset or custom headers.

  • Vapor 5 (5.0.0-beta.4, not released) keeps Content; the beta moves further towards Sendable-first signatures. Review the Vapor 5 release notes before upgrading controllers and middleware that implement AsyncMiddleware.

Where the books are outdated

  • Condon et al. (Kodeco, the TIL application, written for Vapor 4.0 on Swift 5.2) cover REST routes, DTOs and API versioning (a second route group for a new version of the API) with EventLoopFuture return types and .flatMap chains; the DTO idea and the versioning pattern above are unchanged, and Vapor 4.122 handlers are async throws functions. The books' early-access chapters predate Swift 6 Sendable checking, so their Content structs and controllers need @Sendable handlers and Sendable stored properties to build in the Swift 6 language mode.

  • Yeung (Packt, written for Vapor 3.0) discusses REST resource modelling, pagination query parameters, Backends for Frontends and the API gateway with Vapor 3 Router and Future types and Fluent 3 model protocols; those types do not exist in Vapor 4. The architecture argument still holds, the code must be rewritten.

  • Patel (Packt, written for Vapor 2) shows REST verbs, nested and wildcard routes, resource controllers and PATCH on Vapor 2’s Droplet, ResourceRepresentable and JSON nodes. None of these exist in Vapor 4; use RouteCollection and the Create, Replace and Patch DTOs above.

  • Neither the Vapor 2/3 books nor Vapor 4’s documentation mention the Deprecation header (RFC 9745 is from 2025) or the Swift 6 concurrency rules for handlers and DTOs.