vapor

SKILL.md

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

// entrypoint.swift
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
        }
    }
}

// configure.swift -- database, middleware, migrations, routes
func configure(_ app: Application) async throws {
    app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
    app.middleware.use(AppErrorMiddleware())
    app.middleware.use(CORSMiddleware())

    // Database (always from environment variables)
    if let databaseURL = Environment.get("DATABASE_URL") {
        try app.databases.use(.postgres(url: databaseURL), as: .psql)
    } else {
        app.databases.use(.postgres(
            hostname: Environment.get("DB_HOST") ?? "localhost",
            port: Environment.get("DB_PORT").flatMap(Int.init) ?? 5432,
            username: Environment.get("DB_USER") ?? "vapor",
            password: Environment.get("DB_PASSWORD") ?? "vapor",
            database: Environment.get("DB_NAME") ?? "vapor_dev"
        ), as: .psql)
    }

    app.migrations.add(CreateUser())
    if app.environment == .development { try await app.autoMigrate() }
    try routes(app)
}

// routes.swift -- group public vs protected
func routes(_ app: Application) throws {
    app.get("health") { _ -> HTTPStatus in .ok }
    let api = app.grouped("api", "v1")
    try api.register(collection: AuthController())

    let protected = api.grouped(JWTAuthMiddleware())
    try protected.register(collection: UserController())
}

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
        ))
    }

    @Sendable
    func show(req: Request) async throws -> UserResponse {
        guard let user = try await User.find(req.parameters.get("userID"), on: req.db) else {
            throw Abort(.notFound, reason: "User not found")
        }
        return try UserResponse(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
        self.email = email
        self.passwordHash = passwordHash
        self.role = role
    }

    enum Role: String, Codable, CaseIterable {
        case 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
        self.name = user.name
        self.role = user.role
        self.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 ErrorResponse: Content {
    let error: Bool
    let reason: String
    let code: UInt
}

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)

// configure.swift -- register signing key
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)

// Payload definition
struct UserPayload: JWTPayload {
    var subject: SubjectClaim
    var expiration: ExpirationClaim
    var isAdmin: Bool?

    func verify(using algorithm: some JWTAlgorithm) throws {
        try expiration.verifyNotExpired()
    }
}

// Token generation (in login handler)
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

# Initialize project
swift package init --type executable --name MyVaporApp

# Resolve dependencies
swift package resolve

# Build and run
swift build
swift run App serve --hostname 0.0.0.0 --port 8080

# Run tests
swift test
swift test --filter AppTests

# Run database migrations manually
swift run App migrate
swift run App migrate --revert

# Docker build
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

Weekly Installs
8
Repository
ar4mirez/samuel
GitHub Stars
3
First Seen
Mar 1, 2026
Installed on
opencode8
gemini-cli8
github-copilot8
codex8
kimi-cli8
amp8