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.
(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/booksis the collection,/api/v1/books/{id}one book. The method is the verb (GETreads,POSTadds to a collection,PUTreplaces,PATCHchanges part of,DELETEremoves), as defined by RFC 9110. -
A resource that only exists inside another is nested one level:
/api/v1/books/{id}/reviewsis "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 /loansborrows a book), or a state field that is patched. AvoidPOST /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 |
|---|---|---|
|
List books, filtered, sorted and paginated |
|
|
Create a book |
|
|
Read one book |
|
|
Replace the book with the complete body |
|
|
Change only the fields sent |
|
|
Remove the book |
|
|
List the reviews of a book |
|
|
Add a review to the book |
|
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 |
|---|---|---|
|
A read or an update succeeded and there is a body |
|
|
A resource was created; send |
|
|
Success with no body |
|
|
The request cannot be decoded or fails validation |
|
|
The identified resource does not exist |
|
|
The request clashes with the current state |
duplicate ISBN |
|
The body is well formed, but refers to something that does not exist |
an |
|
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
)
}
Source: Vapor docs: Content and Vapor docs: Errors
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 }
}
}
Source: Vapor docs: Validation
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 |
|---|---|
|
Body of |
|
Body of |
|
Body of |
|
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 aUserreturned directly sendspasswordHash(the BookshelfUserhas exactly that field), and aBooksendscreatedAt,updatedAtand 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,statusorrole, whatever the model has. ACreateDTO whitelists the fields; themakeModel()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
Validatableconformance 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
}
}
Source: Vapor docs: Fluent models
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 isq. -
Sorting is a single
sortparameter that takes a whitelisted name, with a leading-for descending order (sort=-year). Never pass the value tosort(DatabaseQuery.Field...): a client must not choose columns. -
Pagination uses
page(1-based) andper, which is exactly what Fluent’spaginate(for:)reads fromreq.query; the response carriesmetadata(page,per,total) next toitems. -
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)
}
}
Source: Vapor docs: Content, query and Vapor docs: Fluent query
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
}
}
Source: Vapor docs: Controllers
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 |
|
Visible, cacheable, routable by a proxy, easy to try with |
Header |
|
Clean URLs; invisible in logs, harder to test, needs |
Query parameter |
|
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())
Source: Vapor docs: Routing, groups
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, eithertrueor, as here, the date from which it is, written as@and a Unix timestamp:Deprecation: @1790812800is 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. -
Linkwithrel="successor-version"points at the replacement (the RFC 9745 and RFC 8594 documents also suggestrel="deprecation"andrel="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
}
}
Source: Vapor docs: Middleware
app.middleware.use(DeprecationMiddleware.v1, at: .beginning)
Source: Vapor docs: Middleware
$ 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
@Sendablefunctions ofSendablestructs: the controllers hold no mutable state, and anything they read (the database, the logger) comes fromreq. -
DTOs are
Content(henceSendable) structs ofSendablevalues;Page<T>isSendablewhenTis. Fluent models arefinal classwith@unchecked Sendable, so keep them inside the handler and do not capture them inasync letchildren orTaskclosures that outlive it; convert to DTOs first. -
async letin a handler runs the child tasks under the request’s structured scope and requires that what they capture isSendable;reqis, andreq.dbis aSendableexistential. -
Middleware conforming to
AsyncMiddlewarewith only immutable stored properties compiles without annotations. A middleware that counts requests needs anactoror an atomic, not a storedvar.
Common errors and pitfalls
-
The model is returned as
Content. Passwords hashes, timestamps and Fluent’s nested@Parentshape appear in the JSON. Return DTOs; review anyextension Model: Content. -
Missing optional validation parameters.
validations.add("x", as: Int?.self, is: .range(1...5))withoutrequired: falsefails when the key is absent; andas: Int.selffails with the nullable type. -
PATCHcannot clear a field. The synthesised decoder collapses absent andnull; usecontainer.containsas inAuthorPatch. -
Foreign keys as
500. Check the parent exists and answer422; the database error text must not reach the client. -
Location with a relative guess.
Locationshould be derived from the route (orreq.url), not rebuilt by hand in three places; a reverse proxy adds a path prefix the application does not know. -
ErrorMiddlewareand headers. Headers set by a middleware inside theErrorMiddlewareare lost on thrown errors; register itat: .beginningas above. -
Unbounded lists. A list handler that calls
all()instead ofpaginate(for:)is an outage with enough rows. -
Silent breaking changes. Renaming a
Codableproperty renames the JSON key. A DTO shared by two versions is a trap: copy it when the shape changes. -
Query value decoding.
req.query.decodethrows aDecodingErrorthat Vapor reports as400with the Foundation message in thereason, as seen forsort=bogus; catch it if the message should be friendlier.
Version notes
-
The pattern of this page (
Contentstructs,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’sTodoControllerandTodoDTOuse the same DTO pattern (atoModel()function on the DTO). -
PageandPageRequest(thepageandperparameters) come from Fluent and are unchanged in Fluent 4.13.0. -
RFC 9745 (the
Deprecationheader) was published in 2025; articles about API deprecation written before it use onlySunsetor custom headers. -
Vapor 5 (5.0.0-beta.4, not released) keeps
Content; the beta moves further towardsSendable-first signatures. Review the Vapor 5 release notes before upgrading controllers and middleware that implementAsyncMiddleware.
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
EventLoopFuturereturn types and.flatMapchains; the DTO idea and the versioning pattern above are unchanged, and Vapor 4.122 handlers areasync throwsfunctions. The books' early-access chapters predate Swift 6Sendablechecking, so theirContentstructs and controllers need@Sendablehandlers andSendablestored 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
RouterandFuturetypes 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,ResourceRepresentableand JSON nodes. None of these exist in Vapor 4; useRouteCollectionand theCreate,ReplaceandPatchDTOs above. -
Neither the Vapor 2/3 books nor Vapor 4’s documentation mention the
Deprecationheader (RFC 9745 is from 2025) or the Swift 6 concurrency rules for handlers and DTOs.
References
-
vapor/template, including
TodoControllerandTodoDTO -
RFC 9110: HTTP Semantics, RFC 7396: JSON Merge Patch, RFC 8594: The Sunset HTTP Header Field and RFC 9745: The Deprecation HTTP Response Header Field