| name | vapor |
| description | Vapor framework guardrails, patterns, and best practices for AI-assisted development.
Use when working with Vapor projects, or when the user mentions Vapor.
Provides Fluent ORM, async Swift, routing, middleware, and server-side Swift guidelines.
|
| license | MIT |
| metadata | {"author":"samuel","version":"1.0","category":"framework","language":"swift","extensions":".swift"} |
Vapor Framework Guide
Applies to: Vapor 4.x, Swift 5.9+, Fluent ORM, Leaf Templates, Server-Side Swift
Language Guide: @.claude/skills/swift-guide/SKILL.md
Overview
Vapor is a server-side Swift framework for building web applications, REST APIs, and backend services. It provides type-safe routing, Fluent ORM, async/await concurrency, JWT authentication, and Leaf templating.
Use Vapor when:
- Building Swift-native backend services
- Sharing code between iOS/macOS clients and the server
- You need type-safe, compile-time-checked API development
- You want async/await patterns throughout the stack
Consider alternatives when:
- Team lacks Swift experience
- You need a massive middleware ecosystem (consider Express, Rails)
- Maximum raw performance is critical (consider Rust/Actix-web)
Guardrails
Vapor-Specific Rules
- Use the
@main entry point pattern with Application.make
- Group routes by resource with
RouteCollection controllers
- Use Fluent property wrappers (
@ID, @Field, @Parent, @Children) for models
- Use
Content protocol for all request/response DTOs
- Use
Validatable protocol for input validation on every endpoint
- Use
AsyncMiddleware for cross-cutting concerns (auth, logging, CORS)
- Use
AsyncMigration with both prepare and revert methods
- Configure databases from environment variables (never hardcode credentials)
- Use DTOs to separate API contracts from database models
- Implement pagination for all list endpoints
- Use
@Sendable on all route handler closures
- Mark model classes as
@unchecked Sendable (Fluent requirement)
Anti-Patterns
- Do not expose Fluent models directly as API responses (use DTOs)
- Do not put business logic in controllers (use a service layer)
- Do not use
autoMigrate in production (run migrations explicitly)
- Do not skip
revert in migrations (always provide rollback)
- Do not use
try! or fatalError in request handlers
- Do not store request-scoped state in global variables
Project Structure
MyVaporApp/
โโโ Package.swift
โโโ Sources/
โ โโโ App/
โ โโโ Controllers/ # RouteCollection implementations
โ โ โโโ UserController.swift
โ โ โโโ AuthController.swift
โ โโโ Models/ # Fluent models
โ โ โโโ User.swift
โ โ โโโ Post.swift
โ โโโ DTOs/ # Request/response types (Content + Validatable)
โ โ โโโ UserDTO.swift
โ โ โโโ CreateUserRequest.swift
โ โโโ Migrations/ # AsyncMigration implementations
โ โ โโโ CreateUser.swift
โ โ โโโ CreatePost.swift
โ โโโ Middleware/ # AsyncMiddleware implementations
โ โ โโโ JWTAuthMiddleware.swift
โ โ โโโ AppErrorMiddleware.swift
โ โโโ Services/ # Business logic (protocol + implementation)
โ โ โโโ UserService.swift
โ โ โโโ EmailService.swift
โ โโโ Extensions/
โ โ โโโ Request+Extensions.swift
โ โโโ configure.swift # Database, middleware, JWT, Leaf setup
โ โโโ routes.swift # Top-level route registration
โ โโโ entrypoint.swift # @main entry point
โโโ Tests/
โ โโโ AppTests/
โ โโโ UserControllerTests.swift
โ โโโ AuthControllerTests.swift
โโโ Resources/
โ โโโ Views/ # Leaf templates
โ โโโ index.leaf
โโโ Public/ # Static files
โ โโโ css/
โ โโโ js/
โโโ docker-compose.yml
Layer responsibilities:
Controllers/ -- HTTP routing only: parse request, call service, write response
Services/ -- Business logic, orchestration, domain rules
Models/ -- Fluent database models with property wrappers
DTOs/ -- Request/response types with validation (Content + Validatable)
Migrations/ -- Schema changes with prepare and revert
Middleware/ -- Cross-cutting: auth, error handling, CORS, logging
Application Setup
Entry Point and Configuration
import Vapor
import Logging
@main
enum Entrypoint {
static func main() async throws {
var env = try Environment.detect()
try LoggingSystem.bootstrap(from: &env)
let app = try await Application.make(env)
do {
try await configure(app)
try await app.execute()
} catch {
app.logger.report(error: error)
try? await app.asyncShutdown()
throw error
}
}
}
func configure(_ app: Application) async throws {
app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
app.middleware.use(AppErrorMiddleware())
app.middleware.use(CORSMiddleware())
if let databaseURL = Environment.get() {
app.databases.use(.postgres(url: databaseURL), as: .psql)
} {
app.databases.use(.postgres(
hostname: .get() ,
port: .get().flatMap(.) ,
username: .get() ,
password: .get() ,
database: .get()
), as: .psql)
}
app.migrations.add(())
app.environment .development { app.autoMigrate() }
routes(app)
}
( : ) {
app.get() { -> .ok }
api app.grouped(, )
api.register(collection: ())
protected api.grouped(())
protected.register(collection: ())
}
Routing with Controllers
struct UserController: RouteCollection {
func boot(routes: RoutesBuilder) throws {
let users = routes.grouped("users")
users.get(use: index)
users.get(":userID", use: show)
users.put(":userID", use: update)
users.delete(":userID", use: delete)
}
@Sendable
func index(req: Request) async throws -> PaginatedResponse<UserResponse> {
let page = try req.query.decode(PageRequest.self)
let result = try await User.query(on: req.db)
.filter(\.$isActive == true)
.sort(\.$createdAt, .descending)
.paginate(PageRequest(page: page.page, per: page.per))
let items = try result.items.map { try UserResponse(user: $0) }
return PaginatedResponse(items: items, metadata: PageMetadata(
page: page.page, perPage: page.per,
total: result.metadata.total, totalPages: result.metadata.pageCount
))
}
(: ) -> {
user .find(req.parameters.get(), on: req.db) {
(.notFound, reason: )
}
(user: user)
}
}
Conventions: Implement RouteCollection per resource. Use @Sendable on all handlers. Validate before processing. Return DTOs, not Fluent models.
Fluent Models
Model with Property Wrappers
import Fluent
import Vapor
final class User: Model, Content, @unchecked Sendable {
static let schema = "users"
@ID(key: .id)
var id: UUID?
@Field(key: "email")
var email: String
@Field(key: "password_hash")
var passwordHash: String
@Enum(key: "role")
var role: Role
@Timestamp(key: "created_at", on: .create)
var createdAt: Date?
@Timestamp(key: "updated_at", on: .update)
var updatedAt: Date?
@Children(for: \.$user)
var posts: [Post]
init() {}
init(id: UUID? = nil, email: String, passwordHash: String, role: Role = .user) {
self.id id
.email email
.passwordHash passwordHash
.role role
}
: , , {
admin, user, guest
}
}
Model conventions:
- Always mark as
final class conforming to Model, Content, @unchecked Sendable
- Use
@ID(key: .id) for UUID primary keys
- Use
@Timestamp for created_at and updated_at
- Use
@Parent/@Children/@Siblings for relationships
- Provide an empty
init() (Fluent requirement)
Migrations
import Fluent
struct CreateUser: AsyncMigration {
func prepare(on database: Database) async throws {
let role = try await database.enum("user_role")
.case("admin").case("user").case("guest")
.create()
try await database.schema("users")
.id()
.field("email", .string, .required)
.field("password_hash", .string, .required)
.field("role", role, .required)
.field("created_at", .datetime)
.field("updated_at", .datetime)
.unique(on: "email")
.create()
}
func revert(on database: Database) async throws {
try await database.schema("users").delete()
try await database.enum("user_role").delete()
}
}
Migration rules:
- Always implement both
prepare and revert
- Create enums before referencing them in schema
- Delete enums in
revert after deleting the table
- Use
.references() for foreign keys with onDelete behavior
- Add indexes for frequently queried columns
DTOs and Validation
Request/Response DTOs
import Vapor
struct CreateUserRequest: Content, Validatable {
let email: String
let password: String
let name: String
static func validations(_ validations: inout Validations) {
validations.add("email", as: String.self, is: .email)
validations.add("password", as: String.self, is: .count(8...))
validations.add("name", as: String.self, is: !.empty)
}
}
struct UserResponse: Content {
let id: UUID
let email: String
let name: String
let role: User.Role
let createdAt: Date?
init(user: User) throws {
self.id = try user.requireID()
self.email user.email
.name user.name
.role user.role
.createdAt user.createdAt
}
}
DTO conventions:
- Request types conform to
Content + Validatable
- Response types conform to
Content only
- Always validate in the controller before processing:
try CreateUserRequest.validate(content: req)
- Use
Validatable rules: .email, .count(range), !.empty, .url, .alphanumeric
Middleware
Custom AsyncMiddleware
import Vapor
import JWT
struct JWTAuthMiddleware: AsyncMiddleware {
func respond(
to request: Request,
chainingTo next: any AsyncResponder
) async throws -> Response {
guard let token = request.headers.bearerAuthorization?.token else {
throw Abort(.unauthorized, reason: "Missing authorization token")
}
let payload = try await request.jwt.verify(token, as: UserPayload.self)
guard let userID = UUID(payload.subject.value),
let user = try await User.find(userID, on: request.db),
user.isActive else {
throw Abort(.unauthorized, reason: "User not found or inactive")
}
request.auth.login(user)
return try await next.respond(to: request)
}
}
Error Middleware
import Vapor
struct AppErrorMiddleware: AsyncMiddleware {
func respond(
to request: Request,
chainingTo next: any AsyncResponder
) async throws -> Response {
do {
return try await next.respond(to: request)
} catch let abort as AbortError {
let body = ErrorResponse(error: true, reason: abort.reason, code: abort.status.code)
return try await body.encodeResponse(status: abort.status, for: request)
} catch {
request.logger.error("Unexpected error: \(error)")
let reason = request.application.environment.isRelease
? "An internal error occurred" : error.localizedDescription
let body = ErrorResponse(error: true, reason: reason, code: 500)
return try await body.encodeResponse(status: .internalServerError, for: request)
}
}
}
struct : {
error:
reason:
code:
}
Content Negotiation
Vapor's Content protocol handles JSON automatically. Configure custom encoding in configure.swift:
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
encoder.dateEncodingStrategy = .iso8601
ContentConfiguration.global.use(encoder: encoder, for: .json)
Authentication (JWT)
guard let jwtSecret = Environment.get("JWT_SECRET") else {
fatalError("JWT_SECRET environment variable not set")
}
await app.jwt.keys.add(hmac: HMACKey(from: jwtSecret), digestAlgorithm: .sha256)
struct UserPayload: JWTPayload {
var subject: SubjectClaim
var expiration: ExpirationClaim
var isAdmin: Bool?
func verify(using algorithm: some JWTAlgorithm) throws {
try expiration.verifyNotExpired()
}
}
let payload = UserPayload(
subject: .init(value: try user.requireID().uuidString),
expiration: .init(value: Date().addingTimeInterval(3600))
)
let token = try await req.jwt.sign(payload)
Commands Reference
swift package init --type executable --name MyVaporApp
swift package resolve
swift build
swift run App serve --hostname 0.0.0.0 --port 8080
swift test
swift test --filter AppTests
swift run App migrate
swift run App migrate --revert
docker build -t my-vapor-app .
docker compose up -d
Dependencies
| Package | Purpose |
|---|
vapor/vapor | Core web framework |
vapor/fluent | ORM abstraction |
vapor/fluent-postgres-driver | PostgreSQL support |
vapor/fluent-sqlite-driver | SQLite (development/testing) |
vapor/redis | Redis caching and sessions |
vapor/jwt | JWT authentication |
vapor/leaf | Template engine |
XCTVapor | Testing utilities (included with Vapor) |
Advanced Topics
For detailed patterns, WebSocket integration, Leaf templates, queues, testing, and deployment, see:
- references/patterns.md -- Fluent query patterns, relationships, eager loading, WebSocket, Leaf templates, background queues, comprehensive testing, Docker deployment
External References