Project Structure and Swift Package Manager

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 (swift-tools-version 6.3, the version used by the official vapor/template; Vapor 4.122.2 itself declares tools version 6.0), checked on 2026-10-17. Vapor imposes very little on how a project is organised: a Vapor application is an ordinary Swift package whose executable calls configure(_:) and then runs the application. What the template gives you is a convention, and this page explains it folder by folder and line by line, then shows how to grow past it. Vapor 3 projects (and the early Vapor 4 template) looked different; those differences are called out where they matter. Vapor 5 is at the time of writing in beta (5.0.0-beta.4) and is not covered in detail; its project layout is expected to follow the same shape.

SwiftPM itself — the manifest API, version requirements, plugins, publishing — is documented in the Swift Reference page Swift Package Manager; this page only covers what a server project needs on top of it. The tools around it (swift build, swift test, formatting) are in Build and Tooling.

The folder structure generated by the template

vapor new bookshelf (the Toolbox) renders the vapor/template repository into a new directory. With Fluent and Leaf selected, the result looks like this:

bookshelf/
├── Package.swift              # the SwiftPM manifest
├── Package.resolved           # created by the first build, records exact dependency versions
├── Public/                    # static files served by FileMiddleware (CSS, JS, images)
├── Resources/
│   └── Views/                 # Leaf templates (only with Leaf)
├── Sources/
│   └── bookshelf/             # the executable target, named after the project
│       ├── Controllers/       # RouteCollection types: request-handling logic grouped by resource
│       ├── DTOs/              # request and response bodies, kept apart from database models
│       ├── Migrations/        # Fluent Migration / AsyncMigration types (only with Fluent)
│       ├── Models/            # Fluent Model types and plain Content structs
│       ├── configure.swift    # func configure(_ app: Application) async throws
│       ├── entrypoint.swift   # @main enum Entrypoint
│       └── routes.swift       # func routes(_ app: Application) throws
├── Tests/
│   └── bookshelfTests/        # Swift Testing tests using VaporTesting
├── Dockerfile
├── docker-compose.yml
├── .gitignore
└── .dockerignore

The official folder-structure page documents Public, Sources/App (with Controllers, Migrations, Models, configure.swift, entrypoint.swift and routes.swift), Tests and Package.swift. The table adds what the current template does beyond that page:

Path Purpose

Public/

Files served as-is (see Static Files and File I/O), but only if FileMiddleware is enabled in configure(_:); Vapor does not serve this folder by default. app.directory.publicDirectory is its absolute path.

Resources/Views/

Leaf templates (Leaf Templates); app.directory.viewsDirectory is its absolute path. Both folders are resolved relative to the working directory, so swift run must be started from the project root (or the working directory set explicitly, for example in Xcode’s scheme).

Sources/<name>/Controllers/

One type per resource that conforms to RouteCollection and registers its routes in boot(routes:).

Sources/<name>/DTOs/

Data transfer objects (the types that are encoded to and decoded from JSON). The official folder-structure page lists only Models; the current template also generates DTOs so that the wire format does not leak the database schema.

Sources/<name>/Migrations/

Schema migrations (Migrations & Schema), registered with app.migrations.add(...) and run by swift run <name> migrate.

Sources/<name>/Models/

Fluent models (Fluent Models), and Content structs for payloads that have no table.

Sources/<name>/configure.swift

Registers middleware, databases, migrations, view renderer and then calls routes(_:).

Sources/<name>/entrypoint.swift

The @main type that builds, configures, runs and shuts down the application; see the Application Lifecycle and the Entrypoint page for a walk-through.

Sources/<name>/routes.swift

routes(_:), the single place where route collections are registered (Routing).

Tests/<name>Tests/

Test target. The template uses Swift Testing with VaporTesting (the official page still says XCTest and XCTVapor; both products exist in the vapor package).

Dockerfile, docker-compose.yml

A multi-stage image build and a Compose file (Deployment with Docker); the Dockerfile moves Public and Resources into the runtime image beside the binary, which is why those folders are found through the working directory.

The .gitignore of the template excludes .build, xcuserdata, *.xcodeproj, DerivedData/, .DS_Store, db.sqlite, .swiftpm, .env (and .env.* except .env.example) and default.profraw. Never commit .env files that contain real credentials.

Where older material differs

Guides written for Vapor 3 and for the first Vapor 4 releases show a different tree, and copying from them produces projects that no longer build:

  • Vapor 3 had Sources/App/{app.swift,boot.swift,configure.swift,routes.swift}, Sources/Run/main.swift and a Services container configured in configure.swift. Vapor 4 removed the services container (dependencies are properties and storage on Application and Request), and the template no longer has app.swift or boot.swift.

  • For several years the Vapor 4 template still split the project in two targets: an App library with all the code and a tiny Run executable with main.swift. The current template merges them into one executable target with an entrypoint.swift marked @main. The split is still a good idea for larger projects, and the section Splitting into modules builds it again on purpose.

  • Early Vapor 4 text uses .target(name: "App") plus .target(name: "Run", dependencies: ["App"]); since Swift 5.4 an executable is declared with .executableTarget.

  • Folder names in the books (Sources/App/Models, Sources/App/Controllers) still match, but DTOs/ and a test target based on Swift Testing are new.

Package.swift line by line

The manifest of the template, with Fluent (PostgreSQL) and Leaf, is shown below. The {{...}} placeholders of the Toolbox are already replaced by the project name bookshelf, and the Vapor requirement is raised from the template’s floor, from: "4.121.4", to the 4.122.2 release this section is written against:

// swift-tools-version:6.3
import PackageDescription

let package = Package(
    name: "bookshelf",
    platforms: [
        .macOS(.v13)
    ],
    dependencies: [
        // A server-side Swift web framework.
        .package(url: "https://github.com/vapor/vapor.git", from: "4.122.2"),
        // An ORM for SQL and NoSQL databases.
        .package(url: "https://github.com/vapor/fluent.git", from: "4.13.0"),
        // Fluent driver for PostgreSQL.
        .package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.12.0"),
        // An expressive, performant, and extensible templating language built for Swift.
        .package(url: "https://github.com/vapor/leaf.git", from: "4.5.1"),
        // Non-blocking, event-driven networking for Swift. Used for custom executors
        .package(url: "https://github.com/apple/swift-nio.git", from: "2.101.0"),
    ],
    targets: [
        .executableTarget(
            name: "bookshelf",
            dependencies: [
                .product(name: "Fluent", package: "fluent"),
                .product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"),
                .product(name: "Leaf", package: "leaf"),
                .product(name: "Vapor", package: "vapor"),
                .product(name: "NIOCore", package: "swift-nio"),
                .product(name: "NIOPosix", package: "swift-nio"),
            ],
            swiftSettings: swiftSettings
        ),
        .testTarget(
            name: "bookshelfTests",
            dependencies: [
                .target(name: "bookshelf"),
                .product(name: "VaporTesting", package: "vapor"),
            ],
            swiftSettings: swiftSettings
        )
    ]
)

var swiftSettings: [SwiftSetting] { [
    .enableUpcomingFeature("ExistentialAny"),
    .enableUpcomingFeature("InternalImportsByDefault"),
    .enableUpcomingFeature("MemberImportVisibility"),
    .enableUpcomingFeature("InferIsolatedConformances"),
    .enableUpcomingFeature("ImmutableWeakCaptures"),
] }

The output shown below comes from a package that pins exact: "4.122.2" for Vapor so that it is reproducible; a real application normally uses from:.

The tools version

The first line, // swift-tools-version:6.3, is not a comment for humans: SwiftPM reads it to decide which PackageDescription API the manifest may use, and it is the minimum Swift toolchain that can build the package. It also selects the default language mode: a tools version of 6.0 or later compiles every target in Swift 6 language mode (complete data-race checking as errors) unless a target sets swiftLanguageModes. A package with tools version 5.x stays in Swift 5 mode, where the same concurrency problems are only warnings. A project created from the current template therefore needs a Swift 6.3 or newer toolchain, while Vapor 4.122.2 itself only requires tools 6.0, so a library that depends on Vapor can declare a lower tools version than the application.

platforms

platforms: [.macOS(.v13)] sets the deployment target on Apple platforms. It matters only on macOS (for example swift run on a laptop or the Xcode build); on Linux the line is ignored. The template chooses macOS 13; if you lower it, any dependency or API that needs a newer macOS fails with an availability error. Xcode copies it to the project’s minimum deployment target. Do not add iOS to this list unless you really build the server code for iOS.

dependencies and the products they provide

Each .package(url:from:) adds a package; a package exposes products, and a target must name the products it uses with .product(name:package:) before it can import them:

Package Products used in a typical app Role

vapor/vapor

Vapor, VaporTesting (and the older XCTVapor)

The framework; VaporTesting is the test helper for Swift Testing

vapor/fluent and a driver

Fluent, FluentPostgresDriver (or FluentSQLiteDriver, FluentMySQLDriver)

ORM and database driver

vapor/leaf

Leaf

Server-side templates

apple/swift-nio

NIOCore, NIOPosix

Event loops and the optional global-executor takeover shown in the entrypoint

from: "4.122.2" means "up to the next major version" (.upToNextMajor): SwiftPM may resolve 4.122.2 or any later 4.x release, but never 5.0.0. That is why Vapor 5, a new major version, will not arrive through a plain swift package update; changing the requirement is a deliberate step. The template’s from: "4.121.4" resolves to the same latest 4.x release today; raising the floor only stops an older Package.resolved from keeping a release before 4.122.2.

Targets: .executableTarget and .testTarget

An .executableTarget produces a program. SwiftPM automatically creates an executable product of the same name, which is what swift run bookshelf serve starts. The current template names the target {{name}}, after the project; the rest of this section names it App by convention, the name the Vapor docs and the template repository itself use, which is why its commands read swift run App serve. Its sources live in Sources/<target name>/, and the file with the @main attribute is the entry point. An executable target is compiled as a module that can also be imported by tests, which is why the template’s test target can depend on it with .target(name: "bookshelf") and use @testable import bookshelf.

A .testTarget lives in Tests/<target name>/ and is run by swift test. It depends on the code under test and on VaporTesting, which provides app.testing() and the request-testing API.

swiftSettings and the upcoming features

The manifest ends with a computed property swiftSettings shared by both targets. It enables five upcoming features, language changes that become default in a later language mode but can be adopted earlier one by one:

Feature What it changes for a Vapor project

ExistentialAny

Protocol types used as types must be spelled any Protocol. You will write any RoutesBuilder, any Middleware and any Error; omitting any is an error. (SE-0335)

InternalImportsByDefault

A plain import Foundation is internal to the file, so it does not leak into the module’s public API. To use an imported type in a public signature, write public import Vapor. (SE-0409)

MemberImportVisibility

Members of a type (methods, properties, extensions) are visible only when the module that declares them is imported in that file, which removes accidental "it compiled because another file imported it" behaviour. (SE-0444)

InferIsolatedConformances

A conformance to a protocol is inferred as isolated to a global actor when the type is isolated to that actor, avoiding data-race errors in main-actor types. (SE-0470)

ImmutableWeakCaptures

[weak self] captures are immutable let bindings, which makes them Sendable-friendly. (SE-0481)

These flags are opt-in: a project that omits swiftSettings still builds. They are on in the template because they are the direction of the language, but a page of the Vapor documentation or a book written without them will show import Vapor in files that would need public import Vapor in a library, and RoutesBuilder instead of any RoutesBuilder. Both forms are accepted when the flags are off; with the flags on, the compiler tells you exactly what to add.

Package.resolved

The first swift build writes Package.resolved next to Package.swift. It lists every dependency in the graph (the scratch package of this page resolves 25 packages, among them swift-nio and its satellites, async-http-client, swift-log and swift-metrics) with the exact version or commit that was chosen. Later builds reuse those versions even when newer ones exist.

  • Commit it for an application. Everyone, including CI and the Docker build, then compiles the same dependency versions.

  • swift package update re-resolves within the manifest’s requirements and rewrites the file; swift package resolve fetches what the file says.

  • swift build --force-resolved-versions (alias --disable-automatic-resolution) fails if the file is out of date with the manifest, which is the right flag for CI.

  • A library usually does not commit it, because consumers resolve their own graph.

The template’s .gitignore does not ignore Package.resolved, matching this advice. Xcode (11 and later) reads and updates the same file; .swiftpm/ is local state and is ignored.

Inspecting the graph

swift package describe            # name, tools version, platforms, products and targets
swift package show-dependencies   # the resolved dependency tree
swift build -c release            # optimised build, the one the Dockerfile runs
swift test                        # runs Tests/

For the scratch package of this page swift package describe prints the manifest name BookshelfServer, tools version 6.3, the macOS 13 platform, the two products and the four targets of Splitting into modules, and show-dependencies lists vapor at 4.122.2 with async-http-client 1.36.2 and swift-nio 2.104.0 among its transitive dependencies.

Splitting into modules

One executable target is perfect for a service of a few thousand lines. Splitting becomes worthwhile when:

  • tests should link the application without the @main entry point,

  • a second executable (a migration tool, a worker, a CLI) should reuse the same code,

  • the request and response types should be shared with an iOS or macOS client without dragging Vapor and SwiftNIO into the app, or

  • compile times matter: SwiftPM rebuilds only the modules that changed.

The layout used for the rest of this page keeps the Bookshelf server in one package, BookshelfServer, with three source targets and one test target. The executable keeps the template’s name App, so swift run App serve and the other pages' commands stay the same:

BookshelfServer/
├── Package.swift
├── Public/
├── Resources/Views/
├── Sources/
│   ├── BookshelfShared/       # shared DTOs: Codable + Sendable, no Vapor import
│   │   └── BookDTO.swift
│   ├── BookshelfServerKit/    # the server library: everything except @main
│   │   ├── Controllers/BooksController.swift
│   │   ├── DTOs/BookDTO+Content.swift
│   │   ├── Services/BookService.swift
│   │   ├── configure.swift
│   │   └── routes.swift
│   └── App/                   # the thin executable
│       └── Entrypoint.swift
└── Tests/AppTests/BooksTests.swift
Diagram of the BookshelfServer package. The executable target App and the test target AppTests both depend on the library target BookshelfServerKit, which depends on the library target BookshelfShared. App, BookshelfServerKit and the tests use products of the vapor package (Vapor and VaporTesting) and App also uses NIOCore and NIOPosix from swift-nio. The App executable and BookshelfShared are exposed as products.

The manifest declares the split. The BookshelfShared library is also a product, so another package (an iOS client) can depend on it alone:

// swift-tools-version:6.3
import PackageDescription

let package = Package(
    name: "BookshelfServer",
    platforms: [
        .macOS(.v13)
    ],
    products: [
        // Importable by an iOS or macOS client without pulling in Vapor
        .library(name: "BookshelfShared", targets: ["BookshelfShared"]),
        .executable(name: "App", targets: ["App"]),
    ],
    dependencies: [
        .package(url: "https://github.com/vapor/vapor.git", from: "4.122.2"),
        .package(url: "https://github.com/apple/swift-nio.git", from: "2.101.0"),
    ],
    targets: [
        .target(
            name: "BookshelfShared",
            swiftSettings: swiftSettings
        ),
        .target(
            name: "BookshelfServerKit",
            dependencies: [
                "BookshelfShared",
                .product(name: "Vapor", package: "vapor"),
            ],
            swiftSettings: swiftSettings
        ),
        .executableTarget(
            name: "App",
            dependencies: [
                "BookshelfServerKit",
                .product(name: "Vapor", package: "vapor"),
                .product(name: "NIOCore", package: "swift-nio"),
                .product(name: "NIOPosix", package: "swift-nio"),
            ],
            swiftSettings: swiftSettings
        ),
        .testTarget(
            name: "AppTests",
            dependencies: [
                "BookshelfServerKit",
                .product(name: "VaporTesting", package: "vapor"),
            ],
            swiftSettings: swiftSettings
        ),
    ]
)

var swiftSettings: [SwiftSetting] { [
    .enableUpcomingFeature("ExistentialAny"),
    .enableUpcomingFeature("InternalImportsByDefault"),
    .enableUpcomingFeature("MemberImportVisibility"),
    .enableUpcomingFeature("InferIsolatedConformances"),
    .enableUpcomingFeature("ImmutableWeakCaptures"),
] }

A bare string in dependencies ("BookshelfShared") names another target of the same package; .product(name:package:) names a product of an external package. The library target cannot also be called App: target names, and so module names, are unique within a package, and App is taken by the executable, which keeps the template’s name. BookshelfServerKit follows the common Kit suffix for the library behind an executable, and is not a name a type or a variable is likely to use.

Here BookshelfShared is a target of the server package so that one manifest builds the whole example. In the full Bookshelf layout it is a package of its own, so that an iOS client does not even resolve Vapor; the server then depends on it with .package(path: "../BookshelfShared"), as Full-Stack Swift with iOS Clients shows.

The shared DTO module

BookshelfShared depends on nothing but the standard library. It is the wire format, and a client app can use the very same type:

/// Wire format shared by the server and any Swift client. No Vapor import here.
public struct BookDTO: Codable, Hashable, Sendable {
    public var isbn: String
    public var title: String
    public var author: String

    public init(isbn: String, title: String, author: String) {
        self.isbn = isbn
        self.title = title
        self.author = author
    }
}

Everything that crosses the module boundary is public, including the memberwise initialiser, which Swift never makes public automatically. Sendable is declared because the type is passed between tasks (see Actors, Isolation and Sendable). The server module adds the Vapor-specific conformance:

public import BookshelfShared
public import Vapor

// BookDTO lives in the BookshelfShared module (no Vapor), so the server adds the Content conformance.
// Both modules belong to this package, which is why no @retroactive is needed.
extension BookDTO: Content {}

Content is Vapor’s protocol for types that can be decoded from a request and encoded to a response (Codable plus the coders). Declaring it in an extension keeps BookshelfShared free of Vapor. The conformance is public because the BookDTO type is, which is why both imports are public import under InternalImportsByDefault. If a type and a protocol come from two other packages, Swift 6 warns about a retroactive conformance and expects @retroactive Content (SE-0364); inside one package the compiler rejects that attribute. Once BookshelfShared is a package of its own, the server’s conformance is retroactive and is written extension BookDTO: @retroactive Content {}.

The server library

BookshelfServerKit holds everything except the entry point. Its only public API is configure(_:), which both the executable and the tests call:

public import Vapor

/// The one public entry point of the library: the executable and the tests both call it.
public func configure(_ app: Application) async throws {
    // Serve everything under Public/ (disabled by default, so it must be added explicitly)
    app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
    app.logger.info("Resources", metadata: ["views": "\(app.directory.viewsDirectory)"])

    #if os(Linux)
    app.logger.debug("Running on Linux")
    #elseif os(macOS)
    app.logger.debug("Running on macOS")
    #endif

    try routes(app)
}
import Vapor

func routes(_ app: Application) throws {
    app.get { _ in "It works!" }

    let service = BookService(repository: InMemoryBookRepository())
    try app.register(collection: BooksController(service: service))
}

routes and everything else stay internal, so the library’s surface is one function and the compiler can enforce that nothing outside the module reaches into controllers (see Access Control). Tests that need internals use @testable import BookshelfServerKit; that works in Debug builds, where SwiftPM compiles with testability enabled.

The thin executable

import BookshelfServerKit
import Logging
import Vapor

@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
        }
        try await app.asyncShutdown()
    }
}

This is the template’s entrypoint without the optional global-executor comment, in a target named App. Because the target contains a file with @main, SwiftPM compiles it as a library-style file (-parse-as-library); an executable target that uses top-level code instead needs a file named main.swift, and the two styles cannot be mixed in one target. Start it with swift run App serve --port 8080. The lifecycle of these calls is covered in detail by the Application Lifecycle and the Entrypoint page.

Running it

$ swift run App routes
+-----+--------------+
| GET | /            |
+-----+--------------+
| GET | /books       |
+-----+--------------+
| GET | /books/:isbn |
+-----+--------------+

$ swift run App serve --port 8080 &
$ curl -s localhost:8080/books/9780441172719
{"title":"Dune","author":"Frank Herbert","isbn":"9780441172719"}

JSON key order is not guaranteed, since the encoder serialises a dictionary-like representation.

Testing the library

Because the code lives in a library, the test target depends on BookshelfServerKit and calls configure without starting the entry point (see Testing):

import BookshelfShared
import BookshelfServerKit
import Testing
import VaporTesting

@Suite("Books API")
struct BooksTests {
    private func withApp(_ test: (Application) async throws -> Void) async throws {
        let app = try await Application.make(.testing)
        do {
            try await configure(app)
            try await test(app)
        } catch {
            try await app.asyncShutdown()
            throw error
        }
        try await app.asyncShutdown()
    }

    @Test("GET /books/:isbn returns the seeded book")
    func showBook() async throws {
        try await withApp { app in
            try await app.testing().test(.GET, "books/9780441172719") { res async throws in
                #expect(res.status == .ok)
                let book = try res.content.decode(BookDTO.self)
                #expect(book.title == "Dune")
            }
        }
    }

    @Test("GET /books/:isbn answers 404 for an unknown ISBN")
    func unknownBook() async throws {
        try await withApp { app in
            try await app.testing().test(.GET, "books/0000000000000") { res async throws in
                #expect(res.status == .notFound)
            }
        }
    }
}

Both tests passed with swift test on the scratch package. The 404 case logs a warning for the Abort, which is Vapor’s normal request log, not a failure. Always call asyncShutdown(): when an Application is deallocated without being shut down, its deinit logs the error "Application.shutdown() was not called before Application deinitialized." and falls back to the blocking shutdown().

Platform conditionals: #if os(Linux) and canImport

Vapor applications are developed on macOS and deployed on Linux (usually in a container), so code sometimes has to differ. There are two compile-time tools:

  • #if os(Linux) (or os(macOS), os(iOS), os(Windows)) selects code by operating system.

  • #if canImport(Module) selects code by whether a module exists, which is more robust than testing the OS.

The classic case is URLSession: on Apple platforms it is part of Foundation, on Linux it was split into FoundationNetworking:

import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

/// URLSession lives in FoundationNetworking on Linux, in Foundation on Apple platforms.
func statusCode(of url: URL) async throws -> Int {
    let (_, response) = try await URLSession.shared.data(from: url)
    return (response as? HTTPURLResponse)?.statusCode ?? 0
}

#if os(Linux)
let platformName = "Linux"
#elseif os(macOS)
let platformName = "macOS"
#else
let platformName = "other"
#endif

In a server project you will mostly use Vapor’s own app.client (async-http-client) instead of URLSession, so these guards appear around Apple-only frameworks such as Security or os.log, around Glibc versus Musl C imports, and in tests that run on both systems. The manifest can do the same with a build condition:

.target(
    name: "BookshelfShared",
    swiftSettings: swiftSettings + [
        .define("BOOKSHELF_LINUX", .when(platforms: [.linux])),
    ]
)

#if BOOKSHELF_LINUX then works in that target. Prefer an os(...) check for a plain platform difference and a custom flag only for a real build option. Linux builds also expose a few things that Apple builds hide, so build for Linux regularly (a CI job or a swift:6.x container running swift build) rather than finding out in production; see Interoperability with C, Objective-C and C++ for C-level differences.

Layering: controller, service, repository

The folders say where things live; the dependencies say how they relate. A layout that has paid off in Bookshelf:

Layer Responsibility Depends on

Controller (RouteCollection)

Decode the request, call one service method, map the result or error to a response. No business rules, no database code.

Service, DTOs

Service

Business rules ("a member cannot borrow more than five books"), written against a protocol, free of Request.

Repository protocol

Repository

Persistence: the protocol is the port, the Fluent or in-memory type is the adapter.

Fluent or storage

DTOs / models

Wire format and persistence entities, kept separate so a schema change does not change the API.

Nothing

The code of the example package follows this arrangement (controllers in detail: Controllers and Route Collections; services: Services, Storage and Dependency Injection). The service depends on a protocol, and the controller only knows the service:

import BookshelfShared

/// The port the service depends on: a repository protocol, not a database.
protocol BookRepository: Sendable {
    func all() async -> [BookDTO]
    func find(isbn: String) async -> BookDTO?
}

/// In-memory adapter; a Fluent-backed one would implement the same protocol.
actor InMemoryBookRepository: BookRepository {
    private var books: [String: BookDTO] = [
        "9780441172719": BookDTO(isbn: "9780441172719", title: "Dune", author: "Frank Herbert"),
    ]

    func all() -> [BookDTO] { books.values.sorted { $0.title < $1.title } }
    func find(isbn: String) -> BookDTO? { books[isbn] }
}

/// Business rules live here, free of HTTP types.
struct BookService: Sendable {
    let repository: any BookRepository

    func list() async -> [BookDTO] { await repository.all() }
    func book(isbn: String) async -> BookDTO? { await repository.find(isbn: isbn) }
}
import BookshelfShared
import Vapor

struct BooksController: RouteCollection {
    let service: BookService

    func boot(routes: any RoutesBuilder) throws {
        let books = routes.grouped("books")
        books.get(use: index)
        books.get(":isbn", use: show)
    }

    @Sendable
    func index(req: Request) async throws -> [BookDTO] {
        await service.list()
    }

    @Sendable
    func show(req: Request) async throws -> BookDTO {
        guard let isbn = req.parameters.get("isbn"),
              let book = await service.book(isbn: isbn) else {
            throw Abort(.notFound)
        }
        return book
    }
}

Three Swift 6 details matter here. The repository is Sendable because the service, and so the controller, is captured by route closures that run on any event loop thread; an actor is the simplest way to make a mutable store safe. The route methods are marked @Sendable so they can be passed as use: handlers. And any BookRepository follows the ExistentialAny rule. Note that handlers are async throws and return Content; Vapor turns the returned value, or the thrown Abort, into the response.

How far to take layering, and when it becomes a separate deployment unit, is not a Vapor question. The general discussion is in Layering Backend and Frontend and Starting a New Project, and the same layering written for Rust is in axum: Project Structure. For a small CRUD service, controllers that talk to Fluent directly are perfectly acceptable; add the service and repository when a rule needs testing without a database or a second adapter appears.

Scripts, libraries and executables

Not every Swift program needs a package. A single file runs as a script with swift hello.swift, which is fine for a five-line experiment but cannot depend on Vapor. As soon as there is a dependency, the unit is the package:

  • a library target (.target) provides code to import and has no entry point;

  • an executable target (.executableTarget) has one, either a main.swift or an @main type;

  • a product is what the outside world sees: .library and .executable expose targets to other packages and to the command line.

Publishing a library is a Git tag with a semantic version (1.2.0) on a public repository, optionally also a registry entry; consumers write .package(url:from:). For an internal DTO package, a path dependency (.package(path: "../BookshelfShared")) avoids tagging during development. The details of version requirements, registries and swift package commands are in the Swift Reference page Swift Package Manager. A hand-written HTTP server over raw sockets, a common first exercise in server-side Swift books, is useful to understand what SwiftNIO and Vapor do for you, but nothing in a real project should depend on it.

Common errors

Symptom Cause and fix

package 'bookshelf' is using Swift tools version 6.3.0 but the installed version is ...

The installed toolchain is older than the manifest’s swift-tools-version. Install a newer Swift or lower the line (and drop features you do not need).

no such module 'Vapor' in one target

The product is not listed in that target’s dependencies. Each target, including the test target, lists what it imports.

cannot find type 'Application' in scope after turning on InternalImportsByDefault

Another file used to supply the import. Add import Vapor to the file that needs it, and public import Vapor if a public declaration uses Vapor types.

use of protocol 'RoutesBuilder' as a type must be written 'any RoutesBuilder'

ExistentialAny is on; add any.

'main' attribute cannot be used in a module that contains top-level code

The target has both a main.swift and a file with @main. Keep one style per executable target.

Package.resolved conflicts after a merge

Resolve the manifest first, then run swift package resolve to regenerate the file; never edit it by hand.

Static files return 404

FileMiddleware is not registered, or swift run is started outside the project root, so Public/ is resolved relative to the wrong working directory.

Works in Xcode, fails in swift run

Xcode’s scheme sets a custom working directory; SwiftPM uses the current one.

Summary

  • The template is one executable target named after the project, with Controllers, DTOs, Migrations, Models, configure.swift, entrypoint.swift, routes.swift, plus Public, Resources/Views and Tests.

  • Package.swift fixes the Swift tools version (6.3 in the template), the macOS deployment target, the Vapor dependency range, the targets and five upcoming-feature flags; Package.resolved pins the exact versions and is committed for applications.

  • A library (BookshelfServerKit), a thin executable (App) and a Vapor-free DTO module (BookshelfShared) make the code testable, reusable by a client and faster to rebuild; the cost is a few lines of public declarations.

  • Layering is a dependency rule (controller, service, repository), not a folder rule; keep the Vapor types in the outermost layer.