Treat Vapor as a typed and concurrent Swift backend. Absolute preference: Vapor 4 + async/await + async Fluent. Do not write new code with EventLoopFuture except for interoperability with legacy libs.
Stack and scope
| Ask | Role |
|---|---|
| Vapor | HTTP, routing, middleware, content, errors |
| Fluent | ORM, models, migrations, queries |
| Fluent drivers | Postgres (preferred), MySQL, SQLite |
| JWT / sessions | Auth API or web |
| Leaf | HTML templates when the app serves pages |
| XCTVapor | HTTP integration tests |
Preserve the structure and libs already adopted by the project. In new apps, organize by domain.
Recommended organization
Sources/App/
configure.swift # DB, middleware global, migrations, services
routes.swift # apenas app.register(collection:)
Controllers/ # RouteCollection por recurso/domínio
Models/ # Fluent models + migrations
DTOs/ # request/response Codable (não exponha Model cru)
Middleware/
Services/ # regras de negócio / integrações
Utilities/
Tests/AppTests/
- Controllers implement
RouteCollectionand have a prefix, middleware, and handlers. routes.swiftstays declarative: it only registers collections.- Global middleware (CORS, logging, correlation id, error mapping) in
configure.swift. - Auth/authorization middleware stays in the route group.
Non-negotiable rules
- Handlers:
func ...(req: Request) async throws -> TwhereT: AsyncResponseEncodable. - Decode with
req.content.decode/req.query.decode; never trust a raw body without a type. - Validate input at the edge; fail with
Abortand correct HTTP statuses. - Authn ≠ Authz: authenticating does not authorize ownership/role.
- Do not expose
ModelFluent directly if there are internal fields; use DTOs. - Versioned migrations; never change a migration already applied in production — create another one.
- A typed
Application: configure secrets via env, do not hardcode. - Linux is the production target; mentally test portability (paths, Foundation, C libs).
Routing and controllers
struct TodosController: RouteCollection {
func boot(routes: any RoutesBuilder) throws {
let todos = routes.grouped("api", "v1", "todos")
todos.get(use: index)
todos.post(use: create)
let protected = todos.grouped(UserToken.authenticator(), User.guardMiddleware())
protected.group(":id") { todo in
todo.get(use: show)
todo.patch(use: update)
todo.delete(use: delete)
}
}
}
- Path params:
req.parameters.get("id", as: UUID.self). - Query: decode into a dedicated struct
Content(filters, pagination). - Group by version (
api/v1) when the API is public or multi-client. - Access layers: public → authenticated → admin, each adding middleware.
Content, validation, and errors
- Request/response: explicit structs
Content/Codable. - Validation:
Validatable+validations(_:)or clear manual validation before persisting. - Errors:
Abort(.badRequest)/.unauthorized/.forbidden/.notFound/.conflict- Safe messages for the client; internal details only in logs
- Custom error middleware if the product's JSON contract requires
{ code, message }
- Do not leak stack traces in production (
app.environment).
Fluent
- Models:
final class/@unchecked Sendableaccording to the project's pattern; fields@Field,@ID, relations@Parent/@Children/@Siblings. - Always
try awaitin async queries. - Avoid N+1:
with(\.$relation)/ conscious eager loading. - Indexes and uniques in migrations for hot paths and real constraints.
- Transactions:
req.db.transaction { db in ... }for composite writes. - Soft delete only with a consistent filter in all reads.
- Multi-tenant: scope always by
tenantId/userIdin the query; never trust the client alone.
guard let todo = try await Todo.find(id, on: req.db) else {
throw Abort(.notFound)
}
Authentication and authorization
- API: Bearer JWT or opaque token in
Authorization. - Web: cookies/sessions with secure flags when serving a browser.
- Chain idempotent authenticators (cookie + bearer) and end with
guardMiddleware(). - Ownership policies in the handler/service:
guard todo.$user.id == user.id else { throw Abort(.forbidden) }. - Password hashing with Bcrypt (or Vapor's current API); never store plaintext.
- Rate limit on login and expensive endpoints when the risk exists.
Middleware
Implement AsyncMiddleware:
struct CorrelationIDMiddleware: AsyncMiddleware {
func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
let id = request.headers.first(name: "X-Request-Id") ?? UUID().uuidString
request.logger[metadataKey: "request-id"] = .string(id)
let response = try await next.respond(to: request)
response.headers.replaceOrAdd(name: "X-Request-Id", value: id)
return response
}
}
- CORS at the start of the chain when there is a separate frontend.
- FileMiddleware only if serving statics on purpose.
- Do not do heavy business I/O in global middleware.
Configuration and environment
In configure.swift:
- Decode typed env (DB URL, JWT secret, port).
- Register databases and migrations.
- Global middleware.
- Lifecycle / services in
app.storageor viaApplicationextensions. autoMigrate()only in development/test — production migrates in the deploy pipeline.
Tests
- Use
XCTVapor/testingproject APIs for HTTP end-to-end. - Isolated test DB (in-memory SQLite or CI Postgres).
- Cover: happy path, 401/403, 400 validation, 404, ownership.
- Avoid tests that depend on global order without resetting state.
try await app.test(.GET, "api/v1/todos") { res async in
XCTAssertEqual(res.status, .ok)
}
Performance and concurrency
- Do not block the event loop with heavy synchronous work; use
asyncand non-blocking APIs. - Types crossing concurrency boundaries must be
Sendablewhen required. - Driver connection pool: adjust only with evidence.
- Pagination with a maximum limit; reject
limitabuse. - Structured logs with request id; no PII/secrets.
Deploy
- Multi-stage Docker image (macOS/CI build → Linux slim runtime).
- Health route (
GET /health) for orchestrators. - Graceful shutdown; migrations before releasing traffic.
- Secrets via env/secret manager.
- Watch build time and image size; SPM cache in CI.
WebSockets and real time
- Use when the domain requires real push; not for simple CRUD.
- Authenticate on connection; revalidate authorization per message when necessary.
- Backpressure and conscious timeouts; test reconnection on the client.
Integration with the rest of the toolkit
- HTTP contracts: align with
api-design. - Deep authz:
authentication-authorization. - MVVM on the SwiftUI/iOS client:
mvvm-architecture+swiftui-development. - Go-live:
production-readiness.
Anti-patterns
- Entire business logic in
routes.swift EventLoopFuturein new code without necessity- Return Fluent Model with password hash / tokens
- Migration edited after deploy
- Auth only on the iOS/web client
try!/ force unwrap in request paths- SQLite in multi-instance production without a plan
- Ignore decode failures with dangerously silent defaults
Flow when implementing a feature
- Inspect
Package.swift,configure.swiftand existing controllers. - Model DTO + (if needed) Fluent model/migration.
- Service with the business rule.
- Controller
RouteCollectionwith correct authz and status. - XCTVapor tests for risk cases.
- Update migrations/env/docs only for what changed.
Completion criteria
- Routes registered via
RouteCollection, typed and versioned when applicable - Handlers 100% async/await in new code
- Input validated; correct HTTP errors
- Authn/authz on the server with ownership covered
- DTOs without leaking internal fields
- Migration added (not rewritten) if the schema changed
- Tests of critical flows passing
- Config via env; ready for Linux/Docker if the scope includes deploy