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.
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
}
}
Source: Vapor docs: Controllers
The registration, in routes.swift:
import Vapor
func routes(_ app: Application) throws {
try app.register(collection: HealthController())
}
Source: Vapor docs: Controllers
Points to notice:
-
boot(routes:)is called once, at registration, and canthrow; an error here stopsroutes(app)and therefore the start-up. It does not run per request. -
routes.grouped("health")returns a newRoutesBuilderwhose routes are prefixed with/health. The first route usesuse: self.indexwith no path, so it isGET /health; the second isGET /health/ready. -
The handlers are passed as method references,
self.index, not called. Because the closure parameter ofget(_:use:)is@Sendable, the referenced method must be@Sendabletoo (see Swift 6 concurrency notes). The handler signature is(Request) async throws -> Responsefor any return type that Vapor can encode. -
Any value can be a collection, including an
enum-lessstructwith no properties, as here, or one with dependencies, as in the next sections.Applicationitself also hasregister(collection:), soapp.register(collection:)androutes.register(collection:)are the same call. -
A collection can register other collections, which is how a large API is split into modules: a
V1Routescollection callstry 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 |
|---|---|---|
|
|
health checks and quick examples |
a |
|
a single resource |
|
|
collection endpoints |
|
that status with an empty body |
|
|
whatever you build, status and headers included |
|
Source: Vapor docs: Routing
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 |
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
}
Source: Vapor docs: Content
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
}
}
Source: Vapor docs: Controllers
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
}
}
Source: Vapor docs: Controllers
Things worth understanding in this listing:
-
books.group(":bookID") { book in ... }registers four routes under/books/:bookIDwithout repeating the parameter. The closure passed togroupis not escaping and runs immediately, so it may useselffreely. -
req.parameters.require("bookID", as: UUID.self)returns the typed value. If the segment is not a validUUID,requirethrowsAbort(.unprocessableEntity)(422); if the route has no parameter of that name it throwsAbort(.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 a400 Bad Requestwith a description of the missing or mistyped key when the body does not match. -
createreturnsResponsebecause it needs two things a plain model cannot carry, a201status and aLocationheader.encodeResponse(status:for:)is theContentmethod that encodes with the request’s content negotiation settings and applies the status. -
deletereturnsHTTPStatusfor both outcomes. That is accepted byResponseEncodable, so the success path is a204with an empty body and the missing-book path is a404with no JSON body; usethrow Abort(.notFound)instead when clients should get the standard JSON error body, asshowdoes.
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}
Source: Vapor docs: Routing
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)
}
}
Source: Vapor docs: Controllers
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 |
+--------+------------------------+
Source: Vapor docs: Routing
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}
Source: Vapor docs: Routing
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 }
}
Source: Vapor docs: Services
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]
}
}
Source: Vapor docs: Services
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())
}
Source: vapor/template: routes.swift
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
Source: Vapor docs: Middleware
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@Sendableclosures, 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 thatself.indexis visibly a@Sendablefunction value. On a struct controller whose properties areSendable, 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,
RouteCollectionis not declaredSendable, butself.indexcapturesself, so the controller value is captured by a@Sendableclosure. Astructwhose stored properties are allSendable(hereany BookRepository, which refinesSendable) is implicitlySendableand compiles cleanly. Afinal classcontroller with a mutablevaris notSendable: marking its handler@Sendableis 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 afinal classwith onlyletproperties ofSendabletypes,@unchecked Sendablewith your own locking, or, better, state moved to anactorand the controller kept a struct. -
State belongs in an actor or a database. A controller must not hold a
varcounter or a dictionary that handlers mutate; two simultaneous requests would race. TheInMemoryBookRepositoryactor is the model to copy for caches and in-memory fixtures; production state lives in the database or an external store. -
anyis explicit. With the template’sExistentialAnyupcoming feature enabled, protocol-typed properties and parameters are writtenany BookRepositoryandany RoutesBuilder; without the feature,RoutesBuilderandBookRepositoryalone also compile. -
async letand 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 beSendable, andContenttypes 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 |
the controller is a class (or holds a non- |
error "instance method of non-Sendable type … cannot be marked as '@Sendable'" |
|
|
|
|
the handler asks for a name the route does not declare, for example |
a route is silently missing from |
|
nested path never matches |
the path was registered as one string with slashes; pass each component separately: |
|
a more general route or group intercepts it; run |
handler works for |
the body was streamed or its content type is missing; send |
|
the service was read before |
a |
|
Version notes and outdated material
-
The
RouteCollectionprotocol andboot(routes:)were introduced with Vapor 3 asboot(router: Router)and becameboot(routes: RoutesBuilder)in Vapor 4; Vapor 3 books and tutorials that writefunc boot(router: Router) throwsandrouter.register(collection:)need these renames. The builder methodsrouter.get("books", Book.parameter)with theParameterprotocol andparameters.next(Book.self)are gone: Vapor 4 uses:namepath components andreq.parameters.get(_:). -
Vapor 2 resource controllers (
ResourceRepresentableanddrop.resource("books", BookController())) and theDroplethave no Vapor 4 equivalent; the REST routes are registered explicitly, asBookController.bootdoes. -
In Vapor 3 and in the books written for it, handlers returned
Future<Book>(and laterEventLoopFuture<Book>) and used.flatMapchains. Vapor 4 still acceptsEventLoopFuturereturn values, but new code returns the value directly from anasyncfunction, as above. Fluent-based books often passreq.dbaround withBook.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 generatedTodoControllerof the template uses it, and it is the form that keeps a controller correct under Swift 6 if it later gains a non-Sendableproperty. -
The template’s
TodoController(generated when you answer yes to Fluent) uses the same pattern with Fluent models and aTodoDTO; the Condon book builds itsUsersControllerandAcronymsControllerthis way, and Yeung’s book builds an in-memory CRUD controller onContentstructs. The books' code needs the renames above and theasync/awaitconversion 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
ApplicationandRequestAPIs move further toSendable-first signatures. TheRouteCollectionprotocol is expected to remain the way to group routes, but check the Vapor 5 release notes before moving, as signatures such asboot(routes:)may change.
References
-
vapor/template, including the generated
TodoController