Controllers and Route Collections

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; the official template declares swift-tools-version:6.3, Vapor 4.122.2 itself needs tools version 6.0), checked on 2026-10-17. Every Swift listing on this page was compiled in one SwiftPM package with Swift 6.4 in the Swift 6 language mode with zero warnings, and the curl output was captured from that package running on macOS. The Bookshelf controllers here keep their data in memory so that the page needs no database; the Fluent versions of the same controllers come with the Fluent Models and Fluent Relations pages. Vapor 5 is at 5.0.0-beta.4 and is not released yet; where it matters, differences are noted. Swift itself is covered by the Swift Reference; the Actors, Isolation and Sendable page explains the concurrency vocabulary used below.

Why controllers

A route is a path, an HTTP method and a closure. Writing every closure in routes.swift works for a handful of routes, but one file with forty closures is hard to read, test and share. A controller is just a type that owns the handlers of one resource (books, reviews, users), and a route collection is the Vapor protocol that lets such a type register its own routes. Vapor does not require controllers: they are a convention of the template and of the official documentation, and nothing in the framework treats a RouteCollection specially once its routes are registered.

Where it runs in the request

Controllers do not add a step to the request lifecycle. Registration happens once, while the application starts (routes(app), called from configure); at request time Vapor’s responder chain runs the middleware, the router matches the route, and the handler method that the controller registered runs. The controller type exists only to group the code.

flowchart LR A["configure(app)"] --> B["routes(app)"] B --> C["app.register(collection: BookController)"] C --> D["BookController.boot(routes:)"] D --> E["routes.grouped('books') + get/post/put/delete"] E --> F["router table"] R["HTTP request"] --> M["middleware"] M --> F F --> H["BookController.show(req:)"] H --> X["Content encoded as response"]

The RouteCollection protocol

RouteCollection has a single requirement, func boot(routes: any RoutesBuilder) throws. RoutesBuilder is the same protocol that Application and route groups conform to, so everything on the Routing page (get, post, on, grouped, group) is available on the routes parameter. A collection is registered with routes.register(collection:), which simply calls boot(routes:) on it with the receiver as the builder.

import Vapor

struct HealthController: RouteCollection {
    func boot(routes: any RoutesBuilder) throws {
        let health = routes.grouped("health")
        health.get(use: self.index)
        health.get("ready", use: self.ready)
    }

    @Sendable
    func index(req: Request) async throws -> String {
        "ok"
    }

    @Sendable
    func ready(req: Request) async throws -> HTTPStatus {
        .noContent
    }
}

The registration, in routes.swift:

import Vapor

func routes(_ app: Application) throws {
    try app.register(collection: HealthController())
}

Points to notice:

  • boot(routes:) is called once, at registration, and can throw; an error here stops routes(app) and therefore the start-up. It does not run per request.

  • routes.grouped("health") returns a new RoutesBuilder whose routes are prefixed with /health. The first route uses use: self.index with no path, so it is GET /health; the second is GET /health/ready.

  • The handlers are passed as method references, self.index, not called. Because the closure parameter of get(_:use:) is @Sendable, the referenced method must be @Sendable too (see Swift 6 concurrency notes). The handler signature is (Request) async throws -> Response for any return type that Vapor can encode.

  • Any value can be a collection, including an enum-less struct with no properties, as here, or one with dependencies, as in the next sections. Application itself also has register(collection:), so app.register(collection:) and routes.register(collection:) are the same call.

  • A collection can register other collections, which is how a large API is split into modules: a V1Routes collection calls try routes.grouped("v1").register(collection: BookController(...)).

The routes the collection produced can be listed with swift run App routes:

+--------+------------------------+
| GET    | /health                |
+--------+------------------------+
| GET    | /health/ready          |
+--------+------------------------+

Handler signatures and return types

A handler is any function from Request to a value that Vapor can turn into a Response. The route-building methods are generic over the return type, constrained to ResponseEncodable (the synchronous protocol) or AsyncResponseEncodable (its async counterpart); both have a single encodeResponse(for:) requirement, and Vapor calls it after the handler returns. The commonly used return types are:

Return type Result Typical use

String

200, text/plain

health checks and quick examples

a Content type (Book)

200, application/json by default

a single resource

[Book] and other Array of Content

200, JSON array

collection endpoints

HTTPStatus

that status with an empty body

.noContent after a delete, .accepted, .notFound

Response

whatever you build, status and headers included

201 Created with a Location header, streams, redirects

Two rules follow. First, a handler returns one type; a handler that is sometimes a model and sometimes a bare status returns Response and builds it with encodeResponse(status:for:), as the create handler below does. Second, an error is not a return value: the handler throws an Abort(.notFound) (or any AbortError) and the error middleware turns it into a JSON {"reason":...,"error":true} body with that status. The Requests and Responses, Content and Codable and Error Handling pages cover those types in depth.

A CRUD controller for books

CRUD stands for create, read, update and delete; mapped onto HTTP and a /books resource it gives five or six routes:

Method Path Handler Success status

GET

/books

index, the list

200

POST

/books

create

201 with a Location header

GET

/books/:bookID

show, one book

200

PUT

/books/:bookID

replace, the whole resource

200

PATCH

/books/:bookID

update, selected fields

200

DELETE

/books/:bookID

delete

204

Method names index, show, create, update and delete come from the resource-controller convention of Rails and Laravel; Vapor has no naming requirement, and the names above only avoid clashing with Swift keywords and with Vapor’s own members.

The data first. Book is what the API returns; BookInput is what clients may send. They are separate types so the server owns the id, and so a client cannot set fields it must not (the DTO pattern is covered on the Content and Codable page, and its use in a versioned public API on API Design, Versioning and DTOs). Every type is a Content, which makes it Codable and gives it a default application/json encoding:

import Vapor

struct Book: Content, Equatable {
    var id: UUID
    var title: String
    var author: String
    var year: Int
}

/// The body accepted by POST and PUT; the server owns the id.
struct BookInput: Content {
    var title: String
    var author: String
    var year: Int
}

struct BookPatch: Content {
    var title: String?
    var author: String?
    var year: Int?
}

struct Review: Content, Equatable {
    var id: UUID
    var bookID: UUID
    var rating: Int
    var comment: String
}

struct ReviewInput: Content {
    var rating: Int
    var comment: String
}

The controller will not touch storage directly. It talks to a BookRepository protocol, so the in-memory version below can be replaced by a Fluent-backed one without changing the controller. An actor is the simplest correct in-memory store under Swift 6: it is automatically Sendable, and it serialises access to its dictionaries so concurrent requests cannot race.

import Vapor

protocol BookRepository: Sendable {
    func all() async -> [Book]
    func find(_ id: UUID) async -> Book?
    func create(_ input: BookInput) async -> Book
    func replace(_ id: UUID, with input: BookInput) async -> Book?
    func update(_ id: UUID, with patch: BookPatch) async -> Book?
    func delete(_ id: UUID) async -> Bool
    func reviews(for bookID: UUID) async -> [Review]?
    func addReview(_ input: ReviewInput, to bookID: UUID) async -> Review?
}

/// An in-memory repository. An actor is Sendable and serialises access to its state.
actor InMemoryBookRepository: BookRepository {
    private var books: [UUID: Book] = [:]
    private var reviewsByBook: [UUID: [Review]] = [:]

    func all() -> [Book] {
        books.values.sorted { $0.title < $1.title }
    }

    func find(_ id: UUID) -> Book? {
        books[id]
    }

    func create(_ input: BookInput) -> Book {
        let book = Book(id: UUID(), title: input.title, author: input.author, year: input.year)
        books[book.id] = book
        return book
    }

    func replace(_ id: UUID, with input: BookInput) -> Book? {
        guard books[id] != nil else { return nil }
        let book = Book(id: id, title: input.title, author: input.author, year: input.year)
        books[id] = book
        return book
    }

    func update(_ id: UUID, with patch: BookPatch) -> Book? {
        guard var book = books[id] else { return nil }
        if let title = patch.title { book.title = title }
        if let author = patch.author { book.author = author }
        if let year = patch.year { book.year = year }
        books[id] = book
        return book
    }

    func delete(_ id: UUID) -> Bool {
        reviewsByBook[id] = nil
        return books.removeValue(forKey: id) != nil
    }

    func reviews(for bookID: UUID) -> [Review]? {
        guard books[bookID] != nil else { return nil }
        return reviewsByBook[bookID, default: []]
    }

    func addReview(_ input: ReviewInput, to bookID: UUID) -> Review? {
        guard books[bookID] != nil else { return nil }
        let review = Review(id: UUID(), bookID: bookID, rating: input.rating, comment: input.comment)
        reviewsByBook[bookID, default: []].append(review)
        return review
    }
}

The actor’s methods are declared without async because they are isolated to the actor; callers outside it await them, which is why the protocol declares them async. The controller:

import Vapor

struct BookController: RouteCollection {
    let repository: any BookRepository

    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.update)
            book.delete(use: self.delete)
        }
    }

    @Sendable
    func index(req: Request) async throws -> [Book] {
        await repository.all()
    }

    @Sendable
    func create(req: Request) async throws -> Response {
        let input = try req.content.decode(BookInput.self)
        let book = await repository.create(input)
        let response = try await book.encodeResponse(status: .created, for: req)
        response.headers.replaceOrAdd(name: .location, value: "/books/\(book.id)")
        return response
    }

    @Sendable
    func show(req: Request) async throws -> Book {
        let id = try req.parameters.require("bookID", as: UUID.self)
        guard let book = await repository.find(id) else {
            throw Abort(.notFound, reason: "No book with id \(id)")
        }
        return book
    }

    @Sendable
    func replace(req: Request) async throws -> Book {
        let id = try req.parameters.require("bookID", as: UUID.self)
        let input = try req.content.decode(BookInput.self)
        guard let book = await repository.replace(id, with: input) else {
            throw Abort(.notFound)
        }
        return book
    }

    @Sendable
    func update(req: Request) async throws -> Book {
        let id = try req.parameters.require("bookID", as: UUID.self)
        let patch = try req.content.decode(BookPatch.self)
        guard let book = await repository.update(id, with: patch) else {
            throw Abort(.notFound)
        }
        return book
    }

    @Sendable
    func delete(req: Request) async throws -> HTTPStatus {
        let id = try req.parameters.require("bookID", as: UUID.self)
        return await repository.delete(id) ? .noContent : .notFound
    }
}

Things worth understanding in this listing:

  • books.group(":bookID") { book in ... } registers four routes under /books/:bookID without repeating the parameter. The closure passed to group is not escaping and runs immediately, so it may use self freely.

  • req.parameters.require("bookID", as: UUID.self) returns the typed value. If the segment is not a valid UUID, require throws Abort(.unprocessableEntity) (422); if the route has no parameter of that name it throws Abort(.internalServerError), which is a programming error. req.parameters.get("bookID", as: UUID.self) returns an optional instead, for handlers that want to choose their own error.

  • req.content.decode(BookInput.self) reads the JSON body and throws a 400 Bad Request with a description of the missing or mistyped key when the body does not match.

  • create returns Response because it needs two things a plain model cannot carry, a 201 status and a Location header. encodeResponse(status:for:) is the Content method that encodes with the request’s content negotiation settings and applies the status.

  • delete returns HTTPStatus for both outcomes. That is accepted by ResponseEncodable, so the success path is a 204 with an empty body and the missing-book path is a 404 with no JSON body; use throw Abort(.notFound) instead when clients should get the standard JSON error body, as show does.

Registered with try app.register(collection: BookController(repository: repository)), the routes behave like this (the id values are random UUIDs; the key order within a JSON object is not guaranteed):

$ curl -si -X POST http://127.0.0.1:8080/books -H 'Content-Type: application/json' \
    -d '{"title":"Dune","author":"Frank Herbert","year":1965}'
HTTP/1.1 201 Created
content-type: application/json; charset=utf-8
content-length: 97
location: /books/3B9DDE7A-9682-4FC4-B475-53A07B08D5F1

{"title":"Dune","author":"Frank Herbert","id":"3B9DDE7A-9682-4FC4-B475-53A07B08D5F1","year":1965}

$ curl -s -X PATCH http://127.0.0.1:8080/books/3B9DDE7A-9682-4FC4-B475-53A07B08D5F1 \
    -H 'Content-Type: application/json' -d '{"year":1966}'
{"id":"3B9DDE7A-9682-4FC4-B475-53A07B08D5F1","title":"Dune","author":"Frank Herbert","year":1966}

$ curl -s http://127.0.0.1:8080/books/not-a-uuid
{"reason":"The parameter value could not be converted to the required type","error":true}

$ curl -s http://127.0.0.1:8080/books/00000000-0000-0000-0000-000000000000
{"reason":"No book with id 00000000-0000-0000-0000-000000000000","error":true}

$ curl -si -X POST http://127.0.0.1:8080/books -H 'Content-Type: application/json' -d '{"title":"x"}'
HTTP/1.1 400 Bad Request
{"reason":"No such key 'author' at path ''. No value associated with key CodingKeys(stringValue: \"author\", intValue: nil) (\"author\").","error":true}

Nested resources

A review does not exist without its book, so the natural URL is /books/:bookID/reviews. There are two ways to model that. Either the review handlers live in BookController, or, as here, a second collection owns the nested path and the parent’s identifier arrives as a path parameter. Separate controllers keep each file about one resource and map one-to-one onto the Fluent models of the Fluent Relations page.

import Vapor

struct ReviewController: RouteCollection {
    let repository: any BookRepository

    func boot(routes: any RoutesBuilder) throws {
        let reviews = routes.grouped("books", ":bookID", "reviews")
        reviews.get(use: self.index)
        reviews.post(use: self.create)
    }

    @Sendable
    func index(req: Request) async throws -> [Review] {
        let bookID = try req.parameters.require("bookID", as: UUID.self)
        guard let reviews = await repository.reviews(for: bookID) else {
            throw Abort(.notFound, reason: "No book with id \(bookID)")
        }
        return reviews
    }

    @Sendable
    func create(req: Request) async throws -> Response {
        let bookID = try req.parameters.require("bookID", as: UUID.self)
        let input = try req.content.decode(ReviewInput.self)
        guard let review = await repository.addReview(input, to: bookID) else {
            throw Abort(.notFound, reason: "No book with id \(bookID)")
        }
        return try await review.encodeResponse(status: .created, for: req)
    }
}

routes.grouped("books", ":bookID", "reviews") takes the path components as separate variadic arguments (a single string "books/:bookID/reviews" would be one constant component containing slashes and never match). The parameter name is the part after the colon; the nested handlers read it exactly as the parent’s handlers do. The router merges both collections into one table, so the order of registration does not matter for these paths:

+--------+------------------------+
| GET    | /books                 |
+--------+------------------------+
| POST   | /books                 |
+--------+------------------------+
| GET    | /books/:bookID         |
+--------+------------------------+
| PUT    | /books/:bookID         |
+--------+------------------------+
| PATCH  | /books/:bookID         |
+--------+------------------------+
| DELETE | /books/:bookID         |
+--------+------------------------+
| GET    | /books/:bookID/reviews |
+--------+------------------------+
| POST   | /books/:bookID/reviews |
+--------+------------------------+

The checks the nested handlers make are part of the design: a review for a book that does not exist is a 404, not a dangling row, and GET /books/<unknown>/reviews is a 404 too instead of an empty array, so clients can tell "no reviews yet" from "no such book".

$ curl -si -X POST http://127.0.0.1:8080/books/3B9DDE7A-9682-4FC4-B475-53A07B08D5F1/reviews \
    -H 'Content-Type: application/json' -d '{"rating":5,"comment":"Classic"}'
HTTP/1.1 201 Created

{"id":"AF39831B-68E8-43BB-8C24-BBB868041814","bookID":"3B9DDE7A-9682-4FC4-B475-53A07B08D5F1","rating":5,"comment":"Classic"}

$ curl -s http://127.0.0.1:8080/books/00000000-0000-0000-0000-000000000000/reviews
{"reason":"No book with id 00000000-0000-0000-0000-000000000000","error":true}

Two nested-resource practices are worth keeping. Nest at most one level (/books/:bookID/reviews); a review has its own identifier, so /reviews/:reviewID is better for operations on a single review than /books/:bookID/reviews/:reviewID/.... And let the controller for the child enforce that the child belongs to the parent; routing alone only matches the shape of the URL.

Reaching services and repositories

A controller needs collaborators: a repository, a mail sender, a clock. There are three common ways to give them to it, and they differ in how visible the dependency is.

1. Constructor injection. BookController(repository:) and ReviewController(repository:) above. The dependency is explicit, the controller never reaches into global state, and a test builds the controller with a fake repository. It is the default choice.

2. Application storage plus a Request accessor. When a service must be shared by many controllers and middleware, register it once on the Application and read it from the request. Vapor’s own services (app.client, app.views, app.fluent) work this way. A StorageKey names the slot and an extension on Application gives it a typed property:

import Vapor

/// Storage key: an application-wide service kept in `app.storage`.
struct BookRepositoryKey: StorageKey {
    typealias Value = any BookRepository
}

extension Application {
    var books: any BookRepository {
        get {
            guard let repository = self.storage[BookRepositoryKey.self] else {
                fatalError("Call app.books = ... in configure(_:) before using the repository")
            }
            return repository
        }
        set {
            self.storage[BookRepositoryKey.self] = newValue
        }
    }
}

extension Request {
    var books: any BookRepository { self.application.books }
}
import Vapor

/// Reads the repository from the request instead of receiving it in the initialiser.
struct CatalogController: RouteCollection {
    func boot(routes: any RoutesBuilder) throws {
        routes.get("catalog", "count", use: self.count)
    }

    @Sendable
    func count(req: Request) async throws -> [String: Int] {
        ["books": await req.books.all().count]
    }
}

Here req.books reaches application.storage at request time, so the value must be set before the first request. The natural place is configure, before routes(app); the setter may be called again in tests to substitute a fake service. The service must be Sendable, which the BookRepository: Sendable requirement enforces. The fatalError in the getter turns a forgotten setup step into a start-up crash rather than a silent nil.

3. Constructing everything in routes(_:). The wiring file is the one place that knows all the pieces:

import Vapor

func configure(_ app: Application) async throws {
    app.books = InMemoryBookRepository()
    try routes(app)
}

func routes(_ app: Application) throws {
    let repository = app.books
    try app.register(collection: HealthController())
    try app.register(collection: BookController(repository: repository))
    try app.register(collection: ReviewController(repository: repository))
    try app.register(collection: CatalogController())
}

This mixes styles on purpose: two controllers receive the repository in their initialiser and the third reads it from the request. In a real project, choose one; constructor injection is the easiest to test and to follow, and the application storage is for cross-cutting services that middleware also needs. The controller does not need to know whether the repository is an actor, a Fluent wrapper or a remote client; the any BookRepository is all it sees.

Handlers can also reach the shared pieces of the framework directly from the request: req.logger (a per-request logger carrying the request id), req.application, req.db once Fluent is configured, req.client for outgoing HTTP calls and req.auth after authentication middleware, all covered on later pages.

Middleware per collection

A collection decides which middleware applies to its routes by grouping, not by a property of the collection type. grouped(_ middleware: Middleware...) (and its AsyncMiddleware overload) returns a builder whose routes run through the middleware, and grouped calls chain:

import Vapor

struct AdminController: RouteCollection {
    func boot(routes: any RoutesBuilder) throws {
        let admin = routes
            .grouped("admin")
            .grouped(AdminTokenMiddleware())
        admin.get("stats") { req async throws -> String in
            "stats for \(req.url.path)"
        }
    }
}

struct AdminTokenMiddleware: AsyncMiddleware {
    func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
        guard request.headers.first(name: "X-Admin") == "yes" else {
            throw Abort(.forbidden)
        }
        return try await next.respond(to: request)
    }
}

AdminTokenMiddleware is a deliberately naive placeholder (a real application authenticates with the mechanisms of the authentication pages). It shows the structure: the middleware wraps only the routes registered on admin, so GET /admin/stats without the header is a 403 and GET /books is unaffected.

$ curl -si http://127.0.0.1:8080/admin/stats
HTTP/1.1 403 Forbidden

{"reason":"Forbidden","error":true}

$ curl -s -H 'X-Admin: yes' http://127.0.0.1:8080/admin/stats
stats for /admin/stats

The same pattern puts a whole controller behind authentication (see Authentication): try app.grouped(MyAuthMiddleware()).register(collection: BookController(...)) registers every route of the collection through the middleware, with no change to the controller. Passing the protected builder to register(collection:) is the idiomatic way to share one middleware across a controller; the controller’s own boot can still add finer groups on top.

Swift 6 concurrency notes

  • Handler methods are @Sendable. The route methods take @Sendable closures, and Vapor may run a handler on any of its event-loop threads, so the closure must not capture mutable state. The official template marks the handler methods @Sendable (@Sendable func index(req: Request) async throws -> String) so that self.index is visibly a @Sendable function value. On a struct controller whose properties are Sendable, leaving the attribute off also compiles without a diagnostic in Vapor 4.122 (the route methods are declared @preconcurrency), so keep it as documentation and as protection if the controller later becomes a class.

  • The controller itself must be safe to share. In 4.122, RouteCollection is not declared Sendable, but self.index captures self, so the controller value is captured by a @Sendable closure. A struct whose stored properties are all Sendable (here any BookRepository, which refines Sendable) is implicitly Sendable and compiles cleanly. A final class controller with a mutable var is not Sendable: marking its handler @Sendable is an error ("instance method of non-Sendable type …​ cannot be marked as '@Sendable'"), and leaving the attribute off gives the warning "converting non-Sendable function value to '@Sendable (Request) async throws → String' may introduce data races". The fixes are a final class with only let properties of Sendable types, @unchecked Sendable with your own locking, or, better, state moved to an actor and the controller kept a struct.

  • State belongs in an actor or a database. A controller must not hold a var counter or a dictionary that handlers mutate; two simultaneous requests would race. The InMemoryBookRepository actor is the model to copy for caches and in-memory fixtures; production state lives in the database or an external store.

  • any is explicit. With the template’s ExistentialAny upcoming feature enabled, protocol-typed properties and parameters are written any BookRepository and any RoutesBuilder; without the feature, RoutesBuilder and BookRepository alone also compile.

  • async let and task groups. A handler may start child tasks (async let a = ...; async let b = ...) to fan out to several repositories; the values they return must be Sendable, and Content types made of value-type fields are.

Common errors and pitfalls

Symptom Cause and remedy

warning "converting non-Sendable function value to '@Sendable …​' function type may introduce data races" on use: self.index

the controller is a class (or holds a non-Sendable property); make it a struct of Sendable properties, or move the state into an actor

error "instance method of non-Sendable type …​ cannot be marked as '@Sendable'"

@Sendable on a method of a non-Sendable class; fix the type’s Sendable conformance rather than removing the attribute

422 on GET /books/abc

parameters.require(_:as:) could not convert the segment to UUID; the standard answer for a malformed id, or use get(_:as:) and choose your own status

500 "The parameter provided does not exist"

the handler asks for a name the route does not declare, for example "id" where the path says ":bookID"

a route is silently missing from swift run App routes

app.register(collection:) was never called; creating the controller does nothing

nested path never matches

the path was registered as one string with slashes; pass each component separately: grouped("books", ":bookID", "reviews")

405 Method Not Allowed or 404 on a nested path

a more general route or group intercepts it; run swift run App routes and compare

handler works for GET but the body is empty for PUT

the body was streamed or its content type is missing; send Content-Type: application/json, and see the routing page for body: .collect(maxSize:)

fatalError from app.books

the service was read before configure set it; assign it first, before routes(app)

a try is missing before register(collection:)

register(collection:) rethrows what boot(routes:) throws, so it needs try

Version notes and outdated material

  • The RouteCollection protocol and boot(routes:) were introduced with Vapor 3 as boot(router: Router) and became boot(routes: RoutesBuilder) in Vapor 4; Vapor 3 books and tutorials that write func boot(router: Router) throws and router.register(collection:) need these renames. The builder methods router.get("books", Book.parameter) with the Parameter protocol and parameters.next(Book.self) are gone: Vapor 4 uses :name path components and req.parameters.get(_:).

  • Vapor 2 resource controllers (ResourceRepresentable and drop.resource("books", BookController())) and the Droplet have no Vapor 4 equivalent; the REST routes are registered explicitly, as BookController.boot does.

  • In Vapor 3 and in the books written for it, handlers returned Future<Book> (and later EventLoopFuture<Book>) and used .flatMap chains. Vapor 4 still accepts EventLoopFuture return values, but new code returns the value directly from an async function, as above. Fluent-based books often pass req.db around with Book.query(on: req.db); the controller shape is unchanged.

  • Many tutorials show func index(req: Request) async throws -> [Book] without @Sendable. That still compiles for a struct controller, as noted above; the official controllers page shows them without it, while the generated TodoController of the template uses it, and it is the form that keeps a controller correct under Swift 6 if it later gains a non-Sendable property.

  • The template’s TodoController (generated when you answer yes to Fluent) uses the same pattern with Fluent models and a TodoDTO; the Condon book builds its UsersController and AcronymsController this way, and Yeung’s book builds an in-memory CRUD controller on Content structs. The books' code needs the renames above and the async/await conversion to run on Vapor 4.122.

  • Vapor 5 (5.0.0-beta.4 at the time of writing) is in beta, not released. Its routing preview adds macro-based route declarations, and the Application and Request APIs move further to Sendable-first signatures. The RouteCollection protocol is expected to remain the way to group routes, but check the Vapor 5 release notes before moving, as signatures such as boot(routes:) may change.