Microservices and an API Gateway

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 package builds in the Swift 6 language mode with tools version 6.3, as the official template does; Vapor 4.122.2 itself needs tools version 6.0), with JWT 5.1.2, Fluent 4.13.0, AsyncHTTPClient 1.36.2 and swift-nio 2.104.0, checked on 2026-10-17. Every Swift listing on this page was compiled in the Swift 6 language mode with zero warnings in one scratch package; the gateway, users and catalog services were started as three processes and exercised with curl, the service authentication was covered by a VaporTesting suite, and the output shown is captured from those runs. The mutual TLS listing and the Compose and Docker files were only compiled or validated (docker compose config); no certificates were issued and no image was built for this page. Vapor 5 is at 5.0.0-beta.4 and is not released yet; the design below uses only Application, middleware, Client and JWT, which have counterparts there, but re-check the signatures on upgrade. The page extends the Bookshelf service of the earlier pages: it splits Bookshelf into a users service and a catalogue service behind a gateway. Swift itself is covered by the Swift Reference, and async functions, Sendable and actors by Async/Await and Tasks and Actors, Isolation and Sendable. The same topic in Rust is in the Axum section; the language-neutral reasoning behind the split is in Choosing Monolith or Microservices and API Gateway and Backends for Frontends.

Monolith first

A Vapor application is one process with one Application, one connection pool per database and one deployable. For most teams and most products that is the right size for a long time: a call between two parts of it is a function call, a transaction can span any two tables, one swift test exercises everything, and one image is deployed. Splitting it costs a network between the parts, a partial-failure mode for every call that crosses it, data that can no longer be joined, a second and third thing to deploy, monitor and version, and distributed transactions you can no longer have (see Why Not Distributed Transactions). The usual advice, which this page follows, is to start as a well-structured monolith and to split along a seam that has proved itself:

  • Different scaling or availability needs. The catalogue is read thousands of times per write and can be cached aggressively; the users service handles logins and must stay up when the catalogue is being redeployed.

  • Different change rates or owners. Two teams that keep blocking each other’s releases are a better reason than a diagram.

  • A different security boundary. Password hashes and tokens in a small service with no public routes of its own shrinks what a bug in the catalogue can reach.

  • A different runtime. A service that needs another language, library or hardware.

Reasons that do not justify a split: "microservices are modern", a folder structure that feels too large (use SwiftPM targets instead, see Project Structure and SwiftPM), or hoping that a boundary will force discipline that a module boundary would give you for free. A modular monolith (one deployable, strict module boundaries, no cross-module database access) keeps the option open; the monolith to microservices migration path describes how to cut one out later.

Splitting Bookshelf

The split used on this page has three deployables and two databases. Clients talk only to the gateway; the services are not reachable from outside the private network.

flowchart LR C["Clients: iOS app, web"] -->|"HTTPS, user token"| G["gateway"] G -->|"introspect user token
service token (aud users)"| U["users service"] G -->|"relay and compose
service token (aud catalog)"| K["catalog service"] K -->|"reviewer names
service token (aud users)"| U U --> UD[("users database")] K --> KD[("catalog database")]

The responsibilities are drawn so that every piece of data has exactly one owner:

Service Owns Does not own

gateway

Nothing persistent. The public routes, the user-token check, the mapping of public paths to services, and response composition.

Any business rule. Rules in the gateway cannot be tested without it and tie every service to it.

users

Accounts, password hashes, the signing key for user tokens, display names.

Books and reviews.

catalog

Books and reviews. A review stores the author’s userID as a plain value.

User records: it asks the users service for a display name.

The scratch package for this page builds one executable whose role comes from the SERVICE_NAME environment variable. That keeps the page’s listings in one place and makes a three-process demo possible with a single binary. In a real repository the shared pieces (service-token claims, the upstream client, DTOs) go in a library target and each service is its own executable target with its own configure, so a service cannot import another’s models by accident; see Project Structure and SwiftPM. The selection is made in configure:

import Vapor

/// One binary, three roles. The role and the peer URLs come from the environment, as they would from Compose.
func configure(_ app: Application) async throws {
    let role = Environment.get("SERVICE_NAME") ?? "gateway"
    let usersURL = Environment.get("USERS_URL") ?? "http://127.0.0.1:8081"
    let catalogURL = Environment.get("CATALOG_URL") ?? "http://127.0.0.1:8082"
    try await addServiceKey(to: app)

    switch role {
    case "users":
        app.serviceIdentity = ServiceIdentity(name: "users", allowedCallers: ["gateway", "catalog"])
        try await usersRoutes(app)
    case "catalog":
        app.serviceIdentity = ServiceIdentity(name: "catalog", allowedCallers: ["gateway"])
        // The catalog may retry its read of the users service, but only briefly: the gateway above it waits 2 seconds.
        catalogRoutes(app, users: UpstreamClient(name: "users", baseURL: usersURL, timeout: .milliseconds(400), attempts: 2))
    default:
        app.serviceIdentity = ServiceIdentity(name: "gateway", allowedCallers: [])
        // One layer retries (the one next to the failing dependency); the gateway does not retry on top of it.
        gatewayRoutes(app, users: UpstreamClient(name: "users", baseURL: usersURL, timeout: .seconds(1)),
                      catalog: UpstreamClient(name: "catalog", baseURL: catalogURL, timeout: .seconds(2)))
    }
    app.get("healthz") { _ in "ok" }
}

The peer URLs and the secret are configuration, read as described in Configuration and Environment. Everything below is that one binary.

One database per service

The rule that makes a microservice a microservice is that no other service reads or writes its tables. If the catalog could run SELECT against the users table, the two would deploy and migrate together and the split would only be a network hop in the middle of a monolith. So each service has its own database (a separate database with separate credentials is enough; a separate server is better), its own models and its own migrations.

import Fluent
import FluentSQLiteDriver
import Vapor

/// The users service's table. Nothing outside the users service opens this database.
final class UserRow: Model, @unchecked Sendable {
    static let schema = "users"

    @ID(key: .id) var id: UUID?
    @Field(key: "email") var email: String
    @Field(key: "display_name") var displayName: String
    @Field(key: "password_hash") var passwordHash: String

    init() {}
}

/// The catalog service's review table. `user_id` is a plain value, not `@Parent`: the users table is in another
/// database, so there is no foreign key and no join. The catalog asks the users service when it needs a name.
final class ReviewRow: Model, @unchecked Sendable {
    static let schema = "reviews"

    @ID(key: .id) var id: UUID?
    @Field(key: "isbn") var isbn: String
    @Field(key: "user_id") var userID: UUID
    @Field(key: "stars") var stars: Int

    init() {}
}

struct CreateUsers: AsyncMigration {
    func prepare(on database: any Database) async throws {
        try await database.schema(UserRow.schema)
            .id()
            .field("email", .string, .required)
            .field("display_name", .string, .required)
            .field("password_hash", .string, .required)
            .unique(on: "email")
            .create()
    }
    func revert(on database: any Database) async throws { try await database.schema(UserRow.schema).delete() }
}

/// Each service connects to its own database, named by its own environment variable. In Compose these point at
/// different containers (or different databases on one server with different credentials).
func configureUsersDatabase(_ app: Application) async throws {
    app.databases.use(.sqlite(.file(Environment.get("USERS_DB_FILE") ?? "users.sqlite")), as: .sqlite)
    app.migrations.add(CreateUsers())
    try await app.autoMigrate()
}

Three consequences are worth stating plainly:

  • No foreign key and no join across services. ReviewRow.userID is a UUID field, not a @Parent, because there is no users table in the catalog database to point at. To show a reviewer’s name the catalog calls the users service (the reviews route below); the cost is a call per distinct user (or one batch call), and the answer can be cached for a short time.

  • No transaction across services. "Create the account and the welcome review" is two writes in two databases. Either make it idempotent and retry, or model it as a saga and publish the second step reliably, as described in Sagas and Transactional Outbox. Messaging between the services (queues rather than calls) is covered in Messaging Patterns in Practice and, for Vapor’s own jobs, in Queues and Background Jobs.

  • Each service migrates its own schema with autoMigrate or swift run App migrate --yes (see Migrations and Schema) at its own pace. Two services that must be deployed in lockstep are one service in disguise. Make changes between services backwards compatible (add a field before you require it) so any order of deployment works.

The runnable demo keeps its records in an actor (UserStore), so the three processes need no database server; the Fluent models above compile against the same package and show what replaces the actor.

Service-to-service authentication

Inside the private network a request still needs to prove who sent it. Without that, any process that can reach the catalog (a compromised container, a misconfigured proxy, a developer’s laptop on a flat network) can call its internal routes. The options, from cheapest to strongest:

Option What it gives Costs

Network isolation only

Services are reachable only from the private network (Compose networks, Kubernetes network policies).

A single breach inside the network reaches everything. Fine as an extra layer, not as the only one.

A shared API key

Trivial to add.

One secret for everything, no caller identity, no expiry, hard to rotate.

Service tokens (signed JWTs)

Each call carries a short-lived token naming the caller (sub) and the callee (aud), verified by the callee without a network call.

A signing key to distribute and rotate; an HMAC key shared by all services means any service can mint a token for any other, so use an asymmetric key per caller when that matters (see the JWT page for ES256 keys and JWKS).

Mutual TLS

The transport itself authenticates both sides with certificates from an internal CA (RFC 8705 describes binding tokens to such certificates).

A certificate authority, certificate issuance and renewal, a service mesh or tooling to automate it.

The page uses service tokens because they are easy to run and to test, and shows mutual TLS after them. The claims are deliberately few: who issued the token, who is calling, who it is for, and a lifetime of one minute. The middleware refuses a token that is invalid, expired, addressed to another service (the aud check) or issued for a caller not on the service’s allow-list.

import JWT
import Vapor

/// The claims of a service-to-service token: who is calling (`iss`, `sub`), which service it is meant for (`aud`),
/// and a lifetime of one minute.
struct ServiceClaims: JWTPayload {
    enum CodingKeys: String, CodingKey {
        case issuer = "iss", subject = "sub", audience = "aud", expiration = "exp"
    }

    var issuer: IssuerClaim
    var subject: SubjectClaim
    var audience: AudienceClaim
    var expiration: ExpirationClaim

    init(from caller: String, to callee: String, now: Date = .now) {
        issuer = IssuerClaim(value: "bookshelf-services")
        subject = SubjectClaim(value: caller)
        audience = AudienceClaim(value: [callee])
        expiration = ExpirationClaim(value: now.addingTimeInterval(60))
    }

    func verify(using algorithm: some JWTAlgorithm) async throws {
        try expiration.verifyNotExpired()
        guard issuer.value == "bookshelf-services" else {
            throw JWTError.claimVerificationFailure(failedClaim: issuer, reason: "unexpected issuer")
        }
    }
}

/// Which service this process is. It is configuration, never something a request can change.
struct ServiceIdentity: Sendable {
    let name: String
    /// The only callers allowed to reach this service's `/internal` routes.
    let allowedCallers: Set<String>
}

extension Application {
    private struct IdentityKey: StorageKey { typealias Value = ServiceIdentity }

    var serviceIdentity: ServiceIdentity {
        get { storage[IdentityKey.self]! }
        set { storage[IdentityKey.self] = newValue }
    }
}

let serviceKeyID: JWKIdentifier = "service-tokens"

extension Request {
    /// Mints a short-lived token addressed to one callee. Signing an HMAC takes microseconds, so there is no
    /// caching: a stolen token is useful for at most a minute and only against the service it names.
    func serviceToken(for callee: String) async throws -> String {
        try await jwt.sign(ServiceClaims(from: application.serviceIdentity.name, to: callee), kid: serviceKeyID)
    }
}

/// Rejects every request that does not carry a valid service token addressed to this service from an allowed caller.
struct ServiceAuthMiddleware: AsyncMiddleware {
    func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
        guard let bearer = request.headers.bearerAuthorization else { throw Abort(.unauthorized) }
        let identity = request.application.serviceIdentity
        let claims: ServiceClaims
        do {
            claims = try await request.jwt.verify(bearer.token, as: ServiceClaims.self)
            try claims.audience.verifyIntendedAudience(includes: identity.name)
        } catch {
            request.logger.warning("service token rejected", metadata: ["reason": "\(error)"])
            throw Abort(.unauthorized)
        }
        guard identity.allowedCallers.contains(claims.subject.value) else { throw Abort(.forbidden) }
        request.logger[metadataKey: "caller"] = "\(claims.subject.value)"
        return try await next.respond(to: request)
    }
}

/// Reads the service signing secret. Production must provide it; development falls back to an obvious placeholder.
func addServiceKey(to app: Application) async throws {
    let secret: String
    if let fromEnvironment = Environment.get("SERVICE_JWT_SECRET") {
        secret = fromEnvironment
    } else if app.environment == .production {
        throw Abort(.internalServerError, reason: "SERVICE_JWT_SECRET is required")
    } else {
        secret = "change-me-change-me-change-me-32"
    }
    guard secret.utf8.count >= 32 else { throw Abort(.internalServerError, reason: "SERVICE_JWT_SECRET is too short") }
    await app.jwt.keys.add(hmac: HMACKey(from: secret), digestAlgorithm: .sha256, kid: serviceKeyID)
}

Design notes:

  • Audience-bound. A token minted for users is useless against catalog, so a compromised catalog cannot replay the tokens it receives from the gateway against the users service.

  • Minted per call. An HMAC signature takes microseconds, so there is nothing to cache, and a leaked token is good for at most a minute against one service. The jwt page has the details of signing, kid and key rotation; here the service key has a kid of service-tokens so the users service can hold a second, separate key for user tokens in the same app.jwt.keys.

  • Two kinds of token, two keys. User tokens are signed with a key only the users service holds. The gateway never sees it and cannot forge a user token; it asks the users service (introspection, below).

  • The secret is configuration. SERVICE_JWT_SECRET is required in production, at least 32 bytes, and has an obvious placeholder in development. The value belongs in a secret store, never in the repository or the image.

Verified behaviour

The suite (set up as described on Testing) builds the catalog service in-process and sends it tokens a gateway, a stranger and an expired clock would produce:

import Testing
import VaporTesting
import JWT
@testable import App

@Suite("Service-to-service authentication")
struct ServiceAuthTests {
    /// A catalog service, as `configure` builds it, for requests made in-process.
    func withCatalog(_ body: (Application) async throws -> Void) async throws {
        let app = try await Application.make(.testing)
        do {
            await app.jwt.keys.add(hmac: HMACKey(from: "change-me-change-me-change-me-32"), digestAlgorithm: .sha256, kid: serviceKeyID)
            app.serviceIdentity = ServiceIdentity(name: "catalog", allowedCallers: ["gateway"])
            catalogRoutes(app, users: UpstreamClient(name: "users", baseURL: "http://127.0.0.1:1"))
            try await body(app)
        } catch {
            try await app.asyncShutdown()
            throw error
        }
        try await app.asyncShutdown()
    }

    func token(app: Application, from caller: String, to callee: String, ttl: TimeInterval = 60) async throws -> String {
        var claims = ServiceClaims(from: caller, to: callee)
        claims.expiration = ExpirationClaim(value: .now.addingTimeInterval(ttl))
        return try await app.jwt.keys.sign(claims, kid: serviceKeyID)
    }

    @Test("a token from an allowed caller addressed to this service is accepted")
    func accepted() async throws {
        try await withCatalog { app in
            let token = try await token(app: app, from: "gateway", to: "catalog")
            try await app.test(.GET, "internal/books/9780441013593", headers: ["Authorization": "Bearer \(token)"]) { res async in
                #expect(res.status == .ok)
            }
        }
    }

    @Test("no token, a token for another service, an expired token and a stranger are all refused")
    func refused() async throws {
        try await withCatalog { app in
            let forUsers = try await token(app: app, from: "gateway", to: "users")
            let expired = try await token(app: app, from: "gateway", to: "catalog", ttl: -5)
            let stranger = try await token(app: app, from: "mailer", to: "catalog")
            try await app.test(.GET, "internal/books/9780441013593") { res async in #expect(res.status == .unauthorized) }
            try await app.test(.GET, "internal/books/9780441013593", headers: ["Authorization": "Bearer \(forUsers)"]) { res async in
                #expect(res.status == .unauthorized)   // wrong audience
            }
            try await app.test(.GET, "internal/books/9780441013593", headers: ["Authorization": "Bearer \(expired)"]) { res async in
                #expect(res.status == .unauthorized)
            }
            try await app.test(.GET, "internal/books/9780441013593", headers: ["Authorization": "Bearer \(stranger)"]) { res async in
                #expect(res.status == .forbidden)      // valid token, caller not on the allow-list
            }
        }
    }
}
✔ Test "a token from an allowed caller addressed to this service is accepted" passed
✔ Test "no token, a token for another service, an expired token and a stranger are all refused" passed
✔ Test run with 2 tests in 1 suite passed

With the three processes running, a user token presented straight to the catalog is refused as well, because it is not a service token:

$ curl -si localhost:8082/internal/books/9780441013593 -H "authorization: Bearer $USER_TOKEN" | head -1
HTTP/1.1 401 Unauthorized
$ curl -si localhost:8082/internal/books/9780441013593 | head -1
HTTP/1.1 401 Unauthorized

Mutual TLS

With mutual TLS the connection itself is authenticated, so the application needs no token middleware to answer "who is calling". Vapor’s server and AsyncHTTPClient both take a TLSConfiguration from NIOSSL, and the pieces are the same on both ends: a certificate chain, a private key, the internal CA as the only trust root, and full verification so a peer without a certificate from that CA is rejected during the handshake.

import AsyncHTTPClient
import NIOSSL
import Vapor

/// Mutual TLS between services: this service presents its certificate and demands one from every caller, and its own
/// outgoing client presents the same certificate and trusts only the private CA. The three paths are placeholders for
/// files an orchestrator mounts (for example Docker or Kubernetes secrets); no key material belongs in the repository.
func configureMutualTLS(_ app: Application) throws {
    let certificate = "/run/secrets/service.crt"
    let key = "/run/secrets/service.key"
    let authority = "/run/secrets/internal-ca.crt"
    let privateKey = NIOSSLPrivateKeySource.privateKey(try NIOSSLPrivateKey(file: key, format: .pem))
    let chain = try NIOSSLCertificate.fromPEMFile(certificate).map { NIOSSLCertificateSource.certificate($0) }

    // Server side: refuse any caller whose certificate does not chain to the internal CA.
    var server = TLSConfiguration.makeServerConfiguration(certificateChain: chain, privateKey: privateKey)
    server.trustRoots = .file(authority)
    server.certificateVerification = .fullVerification
    app.http.server.configuration.tlsConfiguration = server

    // Client side: present the certificate to the other services and trust only the internal CA.
    var client = TLSConfiguration.makeClientConfiguration()
    client.certificateChain = chain
    client.privateKey = privateKey
    client.trustRoots = .file(authority)
    app.http.client.configuration.tlsConfiguration = client
}

This listing was compiled but not run: no CA or certificates were issued for this page. The usual way to get certificates without writing the issuance yourself is a service mesh or an internal CA service that mounts short-lived certificates into each container. Mutual TLS identifies the machine or workload; you still need to put the caller’s identity in the application (the certificate’s subject) if you want per-caller authorization, which is why many systems use both.

The API gateway

The gateway is the only thing clients can reach. It exists so that clients know one address and one authentication scheme, so that services can change shape behind it, and so that cross-cutting rules (authentication, rate limits, request ids, CORS) are applied once. What it should do is small:

  1. authenticate the user and turn the answer into a trusted identity for the services;

  2. route a public path to the right service, passing a conservative set of headers;

  3. optionally compose a response from several services so a client makes one request (the backend-for-frontend idea: one gateway per kind of client, each shaped for it; the shape of those responses is the subject of API Design, Versioning and DTOs and, when the contract is written first, of OpenAPI with swift-openapi-generator);

  4. add what only the edge knows: request id, rate limiting (see Middleware, Redis & Caching and Security Hardening), CORS, and logging.

What it should not do is own business rules or a database. A gateway that grows rules becomes the monolith you split.

The other half of the pipeline is Vapor’s Client, covered on the HTTP Client page: one shared HTTPClient, req.client as a cheap per-request view of it, timeouts per request, and a mapping from transport failures to status codes. The gateway’s calls go through one small wrapper that adds the service token, the request id, a per-call timeout, that mapping and a bounded retry for reads:

import AsyncHTTPClient
import NIOCore
import Vapor

/// A call to another Bookshelf service: a service token for the callee, a timeout, the request id, a mapping from
/// transport failures to 502/504 and a bounded retry for idempotent reads.
struct UpstreamClient: Sendable {
    let name: String
    let baseURL: String
    /// The budget for one attempt. It must be shorter than the timeout of whoever is calling *this* service.
    var timeout: TimeAmount = .seconds(2)
    /// 1 means "never retry". Retries apply to idempotent methods only.
    var attempts = 1

    func send(
        _ method: HTTPMethod, _ path: String, query: String? = nil,
        headers extra: HTTPHeaders = [:], body: ByteBuffer? = nil,
        on req: Request
    ) async throws -> ClientResponse {
        let idempotent = method == .GET || method == .HEAD
        return try await withRetries(attempts: idempotent ? attempts : 1) {
            try await sendOnce(method, path, query: query, headers: extra, body: body, on: req)
        }
    }

    private func sendOnce(
        _ method: HTTPMethod, _ path: String, query: String?, headers extra: HTTPHeaders, body: ByteBuffer?,
        on req: Request
    ) async throws -> ClientResponse {
        var headers = extra
        headers.replaceOrAdd(name: .authorization, value: "Bearer \(try await req.serviceToken(for: name))")
        headers.replaceOrAdd(name: "X-Request-ID", value: req.id)
        var url = URI(string: baseURL + path)
        url.query = query
        var outgoing = ClientRequest(method: method, url: url, headers: headers, body: body)
        outgoing.timeout = timeout
        do {
            let response = try await req.client.send(outgoing)
            if response.status.code >= 500 { throw Abort(.badGateway, reason: "\(name) answered \(response.status.code)") }
            return response
        } catch let error as HTTPClientError where error == .deadlineExceeded || error == .readTimeout || error == .connectTimeout {
            throw Abort(.gatewayTimeout, reason: "\(name) did not answer in time")
        } catch let error as any AbortError {
            throw error
        } catch {
            throw Abort(.badGateway, reason: "\(name) is unreachable")
        }
    }
}

/// Retries an idempotent operation with exponential backoff and jitter, only for failures that may be transient.
func withRetries<Result: Sendable>(
    attempts: Int, initialDelay: Duration = .milliseconds(100), _ operation: @Sendable () async throws -> Result
) async throws -> Result {
    var delay = initialDelay
    for attempt in 1...attempts {
        do {
            return try await operation()
        } catch let error as Abort where attempt < attempts && [.badGateway, .gatewayTimeout].contains(error.status) {
            try await Task.sleep(for: delay * Double.random(in: 0.5...1.5))
            delay *= 2
        }
    }
    preconditionFailure("unreachable: the last attempt either returns or throws")
}

The gateway itself has three parts. Authentication is a middleware that takes the bearer token and asks the users service, which owns the key, whether it is good (token introspection). The answer is cached for 30 seconds in an actor, so a client making ten requests a second causes a call to the users service every 30 seconds rather than ten a second; the price is that a revoked token keeps working for up to that long, which is the knob to turn. The alternative that needs no call at all is to verify the token locally with the users service’s public key, published as a JWKS document (see JWT); it is faster and has no runtime dependency, but revocation then waits for the token to expire. Relaying copies a conservative set of headers, drops the user’s Authorization header (the service gets a service token instead), adds the identity the gateway established as X-User-Id and X-User-Role, and never forwards an X-User-Id the client sent. Composition calls two services concurrently with async let and merges the answers.

import NIOCore
import Vapor

/// A user the gateway has authenticated. The gateway, not the browser, decides who this is.
struct GatewayUser: Sendable { let id: String; let role: String }

/// Remembers recent introspection answers so a burst of requests with one token asks the users service once.
/// The cache is short on purpose: it is also how long a revoked token keeps working.
actor IntrospectionCache {
    private var entries: [String: (user: GatewayUser, expires: Date)] = [:]
    func get(_ token: String) -> GatewayUser? {
        guard let entry = entries[token], entry.expires > .now else { return nil }
        return entry.user
    }
    func put(_ token: String, _ user: GatewayUser, ttl: TimeInterval = 30) {
        if entries.count > 10_000 { entries.removeAll() }   // crude bound; production uses an LRU
        entries[token] = (user, .now.addingTimeInterval(ttl))
    }
}

struct UserAuthMiddleware: AsyncMiddleware {
    let users: UpstreamClient
    let cache: IntrospectionCache

    func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
        guard let token = request.headers.bearerAuthorization?.token else { throw Abort(.unauthorized) }
        if let cached = await cache.get(token) {
            request.storage[GatewayUserKey.self] = cached
            return try await next.respond(to: request)
        }
        let body = ByteBuffer(string: String(decoding: try JSONEncoder().encode(IntrospectRequest(token: token)), as: UTF8.self))
        let response = try await users.send(
            .POST, "/internal/introspect", headers: ["Content-Type": "application/json"], body: body, on: request)
        let result = try response.content.decode(Introspection.self)
        guard result.active, let id = result.userID, let role = result.role else { throw Abort(.unauthorized) }
        let user = GatewayUser(id: id, role: role)
        await cache.put(token, user)
        request.storage[GatewayUserKey.self] = user
        return try await next.respond(to: request)
    }
}

struct GatewayUserKey: StorageKey { typealias Value = GatewayUser }

extension Request {
    var gatewayUser: GatewayUser? { storage[GatewayUserKey.self] }
}

/// Request headers passed to a service. The user's `Authorization` header is deliberately absent: the service
/// receives a service token instead, plus the identity the gateway established.
private let passedRequestHeaders: [HTTPHeaders.Name] = [
    .accept, .acceptLanguage, .contentType, .ifNoneMatch, HTTPHeaders.Name("traceparent"), HTTPHeaders.Name("tracestate"),
]
private let hopByHop: Set<String> = [
    "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding",
    "upgrade", "content-length", "date",
]

/// Relays `/api/<prefix>/**` to `<service>/internal/<prefix>/**`.
func relay(_ routes: some RoutesBuilder, prefix: String, to service: UpstreamClient) {
    for method in [HTTPMethod.GET, .POST, .PUT, .DELETE] {
        routes.on(method, "api", PathComponent(stringLiteral: prefix), "**", body: .collect(maxSize: "256kb")) { req async throws -> Response in
            let segments = req.parameters.getCatchall()
            guard !segments.contains(where: { $0 == ".." || $0 == "." || $0.contains("/") }) else { throw Abort(.badRequest) }
            let path = segments.compactMap { $0.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) }.joined(separator: "/")

            var headers = HTTPHeaders()
            for name in passedRequestHeaders { for value in req.headers[name] { headers.add(name: name, value: value) } }
            if let user = req.gatewayUser {
                headers.replaceOrAdd(name: "X-User-Id", value: user.id)
                headers.replaceOrAdd(name: "X-User-Role", value: user.role)
            }
            let upstream = try await service.send(
                req.method, "/internal/\(prefix)/\(path)", query: req.url.query,
                headers: headers, body: req.body.data, on: req)

            var responseHeaders = HTTPHeaders()
            for (name, value) in upstream.headers where !hopByHop.contains(name.lowercased()) {
                responseHeaders.add(name: name, value: value)
            }
            return Response(status: upstream.status, headers: responseHeaders,
                            body: upstream.body.map { Response.Body(buffer: $0) } ?? .empty)
        }
    }
}

/// One screen, two services: the gateway composes the answer so the mobile app makes one request.
struct BookOverview: Content { let book: Book; let reviews: [ReviewView]; let viewer: String }

func gatewayRoutes(_ app: Application, users: UpstreamClient, catalog: UpstreamClient) {
    // Public: logging in is the one route that needs no user token. Only /auth/login is exposed, not all of /auth.
    app.post("api", "auth", "login") { req async throws -> Response in
        let upstream = try await users.send(
            .POST, "/auth/login", headers: ["Content-Type": "application/json"], body: req.body.data, on: req)
        return Response(status: upstream.status, headers: ["Content-Type": "application/json"],
                        body: upstream.body.map { Response.Body(buffer: $0) } ?? .empty)
    }

    let protected = app.grouped(UserAuthMiddleware(users: users, cache: IntrospectionCache()))

    protected.get("api", "books", ":isbn", "overview") { req async throws -> BookOverview in
        let isbn = try req.parameters.require("isbn")
        let headers: HTTPHeaders = ["X-User-Id": req.gatewayUser?.id ?? ""]
        async let bookResponse = catalog.send(.GET, "/internal/books/\(isbn)", headers: headers, on: req)
        async let reviewsResponse = catalog.send(.GET, "/internal/books/\(isbn)/reviews", headers: headers, on: req)
        let (bookReply, reviewsReply) = try await (bookResponse, reviewsResponse)
        guard bookReply.status == .ok else { throw Abort(bookReply.status) }
        return BookOverview(
            book: try bookReply.content.decode(Book.self),
            reviews: try reviewsReply.content.decode([ReviewView].self),
            viewer: req.gatewayUser?.id ?? "")
    }

    relay(protected, prefix: "books", to: catalog)
}

Points that matter in this listing:

  • The services trust X-User-Id only because the caller is authenticated as the gateway. A service must never accept those headers from anything but a verified service token; here every /internal route sits behind ServiceAuthMiddleware.

  • Only /api/auth/login is public. The route is registered by name rather than by prefix, so adding an /internal/introspect route to the users service never exposes it. Every other route in the gateway is behind UserAuthMiddleware.

  • The path stays inside the service. The relay decodes the catch-all segments, refuses . and .., and re-encodes the rest before joining them to a fixed /internal/books/ prefix; the base URL is configuration, never taken from the request. The same pattern, and the reasoning about hop-by-hop headers, forwarding headers and server-side request forgery, is explained on the HTTP Client page.

  • Composition fails as a whole, degradation happens below it. The overview needs the book and the reviews, so a failure of either is a failure of the screen; showing a placeholder reviewer name when the users service is slow is a decision the catalog makes about its own data (next section).

The users and catalog services, whose routes the gateway calls:

import JWT
import Vapor

let userKeyID: JWKIdentifier = "user-tokens"

/// What the users service puts in the access tokens it issues. Only the users service holds this key.
struct UserToken: JWTPayload {
    enum CodingKeys: String, CodingKey { case subject = "sub", audience = "aud", expiration = "exp", role }

    var subject: SubjectClaim
    var audience: AudienceClaim
    var expiration: ExpirationClaim
    var role: String

    func verify(using algorithm: some JWTAlgorithm) async throws {
        try expiration.verifyNotExpired()
        try audience.verifyIntendedAudience(includes: "bookshelf-api")
    }
}

struct LoginRequest: Content { let email: String; let password: String }
struct LoginResponse: Content { let accessToken: String; let expiresIn: Int }
struct IntrospectRequest: Content { let token: String }
struct Introspection: Content { let active: Bool; let userID: String?; let role: String? }
struct PublicUser: Content { let id: String; let displayName: String }

/// The users service owns its data. Here the "database" is an actor; the section on per-service databases shows
/// the Fluent version. No other service reads these records: they ask over HTTP.
actor UserStore {
    struct Record { let id: String; let email: String; let passwordHash: String; let displayName: String; let role: String }
    private var byEmail: [String: Record] = [:]
    private var byID: [String: Record] = [:]

    init() throws {
        // A cheap cost factor keeps the demo fast; production uses the default cost.
        let records = [
            Record(id: "u-1", email: "reader@example.test", passwordHash: try Bcrypt.hash("change-me", cost: 4),
                   displayName: "Ada Reader", role: "member"),
            Record(id: "u-2", email: "editor@example.test", passwordHash: try Bcrypt.hash("change-me", cost: 4),
                   displayName: "Ed Editor", role: "editor"),
        ]
        for record in records { byEmail[record.email] = record; byID[record.id] = record }
    }

    func authenticate(email: String, password: String) -> Record? {
        guard let record = byEmail[email], (try? Bcrypt.verify(password, created: record.passwordHash)) == true else {
            return nil
        }
        return record
    }

    func user(id: String) -> Record? { byID[id] }
}

extension Application {
    private struct UsersKey: StorageKey { typealias Value = UserStore }

    var users: UserStore {
        get { storage[UsersKey.self]! }
        set { storage[UsersKey.self] = newValue }
    }
}

struct MissingSecret: Error {
    let variable: String
}

func usersRoutes(_ app: Application) async throws {
    // Production must provide USER_JWT_SECRET; development and tests use an obvious 32-byte placeholder.
    let secret: String
    if let fromEnvironment = Environment.get("USER_JWT_SECRET") {
        secret = fromEnvironment
    } else if app.environment == .production {
        throw MissingSecret(variable: "USER_JWT_SECRET")
    } else {
        secret = "change-me-too-change-me-too-32by"
    }
    await app.jwt.keys.add(hmac: HMACKey(from: secret), digestAlgorithm: .sha256, kid: userKeyID)
    app.users = try UserStore()

    // Public through the gateway: log in and receive a user access token.
    app.post("auth", "login") { req async throws -> LoginResponse in
        let login = try req.content.decode(LoginRequest.self)
        guard let user = await req.application.users.authenticate(email: login.email, password: login.password) else {
            throw Abort(.unauthorized, reason: "Invalid credentials.")
        }
        let claims = UserToken(
            subject: .init(value: user.id), audience: .init(value: ["bookshelf-api"]),
            expiration: .init(value: .now.addingTimeInterval(15 * 60)), role: user.role)
        return LoginResponse(accessToken: try await req.jwt.sign(claims, kid: userKeyID), expiresIn: 900)
    }

    // Internal: only other services, with a service token, reach these routes.
    let internalRoutes = app.grouped("internal").grouped(ServiceAuthMiddleware())

    // Token introspection: the gateway asks the owner of the keys whether a user token is good.
    internalRoutes.post("introspect") { req async throws -> Introspection in
        let body = try req.content.decode(IntrospectRequest.self)
        guard let claims = try? await req.jwt.verify(body.token, as: UserToken.self) else {
            return Introspection(active: false, userID: nil, role: nil)
        }
        return Introspection(active: true, userID: claims.subject.value, role: claims.role)
    }

    internalRoutes.get("users", ":id") { req async throws -> PublicUser in
        let id = try req.parameters.require("id")
        guard let user = await req.application.users.user(id: id) else { throw Abort(.notFound) }
        return PublicUser(id: user.id, displayName: user.displayName)
    }
}
import Vapor

struct Book: Content, Sendable { let isbn: String; let title: String; let authors: [String] }
struct ReviewView: Content, Sendable { let userID: String; let reviewer: String; let stars: Int; let text: String }

/// The catalog service's own data (books and reviews). Reviews hold only a `userID`: the user record is not
/// here, it belongs to the users service.
struct CatalogData: Sendable {
    let books: [String: Book]
    let reviews: [String: [(userID: String, stars: Int, text: String)]]

    static let seed = CatalogData(
        books: [
            "9780441013593": Book(isbn: "9780441013593", title: "Dune", authors: ["Frank Herbert"]),
            "9780261103573": Book(isbn: "9780261103573", title: "The Fellowship of the Ring", authors: ["J. R. R. Tolkien"]),
        ],
        reviews: [
            "9780441013593": [("u-1", 5, "A classic."), ("u-2", 4, "Dense but rewarding.")],
        ])
}

extension Application {
    private struct CatalogKey: StorageKey { typealias Value = CatalogData }

    var catalogData: CatalogData {
        get { storage[CatalogKey.self] ?? .seed }
        set { storage[CatalogKey.self] = newValue }
    }
}

func catalogRoutes(_ app: Application, users: UpstreamClient) {
    let internalRoutes = app.grouped("internal").grouped(ServiceAuthMiddleware())

    internalRoutes.get("books", ":isbn") { req async throws -> Book in
        let isbn = try req.parameters.require("isbn")
        guard let book = req.application.catalogData.books[isbn] else { throw Abort(.notFound) }
        return book
    }

    // The catalog resolves reviewer names by asking the users service. The calls run concurrently; if one fails
    // the review is returned with a placeholder name instead of failing the whole page.
    internalRoutes.get("books", ":isbn", "reviews") { req async throws -> [ReviewView] in
        let isbn = try req.parameters.require("isbn")
        guard req.application.catalogData.books[isbn] != nil else { throw Abort(.notFound) }
        let rows = req.application.catalogData.reviews[isbn] ?? []
        return try await withThrowingTaskGroup(of: (Int, ReviewView).self) { group in
            for (index, row) in rows.enumerated() {
                group.addTask {
                    var name = "Unknown reader"
                    if let response = try? await users.send(.GET, "/internal/users/\(row.userID)", on: req),
                       response.status == .ok, let user = try? response.content.decode(PublicUser.self) {
                        name = user.displayName
                    }
                    return (index, ReviewView(userID: row.userID, reviewer: name, stars: row.stars, text: row.text))
                }
            }
            var results: [(Int, ReviewView)] = []
            for try await result in group { results.append(result) }
            return results.sorted { $0.0 < $1.0 }.map(\.1)
        }
    }
}

The catalog resolves reviewer names by asking the users service, concurrently, and falls back to a placeholder when that call fails. That is a deliberate choice about which failures should reach the user. The result of a login and of the screen, captured from the three running processes:

$ curl -s -X POST localhost:9080/api/auth/login -H 'content-type: application/json' \
    -d '{"email":"reader@example.test","password":"change-me"}'
{"expiresIn":900,"accessToken":"eyJhbGciOi..."}

$ curl -si localhost:9080/api/books/9780441013593/overview -H "authorization: Bearer $TOKEN"
HTTP/1.1 200 OK
content-type: application/json; charset=utf-8

{"viewer":"u-1","book":{"isbn":"9780441013593","authors":["Frank Herbert"],"title":"Dune"},"reviews":[{"stars":5,"userID":"u-1","text":"A classic.","reviewer":"Ada Reader"},{"stars":4,"userID":"u-2","text":"Dense but rewarding.","reviewer":"Ed Editor"}]}

$ curl -si localhost:9080/api/books/9780441013593 -H "authorization: Bearer $TOKEN"     # relayed
HTTP/1.1 200 OK
{"authors":["Frank Herbert"],"isbn":"9780441013593","title":"Dune"}

$ curl -si localhost:9080/api/books/9780441013593/overview | head -1                      # no token
HTTP/1.1 401 Unauthorized
$ curl -si 'localhost:9080/api/books/a/%2E%2E/b' -H "authorization: Bearer $TOKEN" | head -1
HTTP/1.1 400 Bad Request
$ curl -si localhost:9080/api/books/0000/overview -H "authorization: Bearer $TOKEN" | head -1
HTTP/1.1 404 Not Found

The default password change-me and the example.test addresses are demo placeholders, seeded in code for the scratch package only.

Vapor as the gateway, or something else

Building the gateway in Vapor makes sense when it has logic only your code can express (user introspection, composition, a backend for one client) and the team already ships Swift. For plain routing, TLS termination, rate limiting and caching, a dedicated proxy does the same job with less code and more operational tooling:

Choice Strength Watch for

Vapor gateway (this page)

Same language, models and tests as the services; composition and per-client shaping are ordinary code.

You own timeouts, body streaming, header handling and upgrades (WebSockets); each is code to write and test.

nginx

Mature reverse proxy: TLS, buffering, caching, rate limiting, load balancing.

Authentication beyond a subrequest and composition need another layer.

Envoy and Traefik

Dynamic configuration for containers and clusters, retries, circuit breaking, tracing, mutual TLS.

A control plane to run and learn.

Kong or a cloud API gateway

Plugins for keys, quotas, OAuth and analytics; managed scaling.

Vendor behaviour and limits; lock-in.

A common arrangement is a proxy at the edge for TLS and coarse limits, and a Vapor backend-for-frontend behind it for the parts that are application code.

Timeouts, retries and failure budgets

Every call to another service is a place where latency and partial failure enter your server. Four rules, each visible in the listings above:

  • Every call has a timeout, and the callee’s must be shorter than the caller’s. The gateway waits 2 seconds for the catalog; the catalog waits 400 milliseconds, twice, for the users service. If it were the other way round, the gateway would give up while the catalog was still working and the work would be wasted.

  • A refused connection is not fast. The first version of this example used the default 2 second timeout and three attempts against a stopped users service, and the gateway answered after 6.4 seconds with a 504: the refused connection did not surface as an error until the request’s deadline passed, so a dead service cost the full timeout of every attempt (the cause was not traced further; the connection pool appears to keep retrying within the request’s deadline). Hence the per-attempt budget in UpstreamClient, and a small attempt count.

  • Retry in one layer only. If the gateway retried three times and the catalog retried three times, one slow user lookup would become nine. The catalog (next to the failing dependency) retries, with exponential backoff and jitter; the gateway does not. Only idempotent methods are retried (GET, HEAD); a retried POST needs an idempotency key the service honours.

  • Decide what degrades and what fails. A missing reviewer name is cosmetic, so the catalog returns the review with a placeholder; a missing book is the screen, so the gateway returns the error.

What the system did when parts were stopped, measured with curl -w against the three running processes (the 504 case suspended the catalog process with SIGSTOP, so it accepted the connection and never answered):

Situation Status Time Result

All services up

200

0.02 s

reviews with names

Users service stopped, token already cached

200

0.96 s

reviews with Unknown reader

Users service stopped, token not cached

502

1.0 s

gateway cannot introspect: users is unreachable

Catalog suspended

504

2.0 s

catalog did not answer in time

Catalog resumed

200

0.003 s

recovers on the next request, no restart

Vapor does not ship a circuit breaker. When a dependency is down for a while, stop calling it for a period instead of paying a timeout on every request; the patterns (circuit breaker, bulkhead, time limiter, retry with backoff) are described in the site’s Circuit Breaker, Retry, Time Limiter and Bulkhead pages, and a small breaker is an actor that counts consecutive failures and rejects calls until a cool-down passes. The AWS Builders' Library article in the references explains why backoff needs jitter.

Identifying one request across services

A user reports a slow screen; the evidence is spread over three logs. The wrapper adds X-Request-ID to every outgoing call from the caller’s request id, and the services' logs carry that id in their metadata (visible in the catalog’s log lines as request-id and, after authentication, caller). For distributed traces (see Tracing, Metrics & OpenTelemetry and Logging), forward traceparent and tracestate explicitly, as the allow-list in the relay does; the HTTP Client page explains why Vapor’s own Client does not inject them by itself in 4.122.2.

Local orchestration with Docker Compose

Compose runs the five containers (gateway, two services, two databases) on one private network and publishes only the gateway’s port. The services and databases have no ports: entry, so the host and the outside world cannot reach them; they find each other by service name.

name: bookshelf

x-service: &service
  build: .
  restart: unless-stopped
  environment: &service-env
    SERVICE_JWT_SECRET: ${SERVICE_JWT_SECRET:?set SERVICE_JWT_SECRET in .env}
    LOG_LEVEL: info

services:
  gateway:
    <<: *service
    environment:
      <<: *service-env
      SERVICE_NAME: gateway
      USERS_URL: http://users:8080
      CATALOG_URL: http://catalog:8080
    ports:
      - "9080:8080"
    depends_on:
      users: { condition: service_healthy }
      catalog: { condition: service_healthy }

  users:
    <<: *service
    environment:
      <<: *service-env
      SERVICE_NAME: users
      USER_JWT_SECRET: ${USER_JWT_SECRET:?set USER_JWT_SECRET in .env}
      DATABASE_URL: postgres://users:${USERS_DB_PASSWORD:?set USERS_DB_PASSWORD}@users-db:5432/users
    depends_on:
      users-db: { condition: service_healthy }
    healthcheck:
      test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/8080 && printf \"GET /healthz HTTP/1.0\\r\\n\\r\\n\" >&3 && grep -q ok <&3'"]
      interval: 5s
      retries: 10

  catalog:
    <<: *service
    environment:
      <<: *service-env
      SERVICE_NAME: catalog
      USERS_URL: http://users:8080
      DATABASE_URL: postgres://catalog:${CATALOG_DB_PASSWORD:?set CATALOG_DB_PASSWORD}@catalog-db:5432/catalog
    depends_on:
      catalog-db: { condition: service_healthy }
    healthcheck:
      test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/8080 && printf \"GET /healthz HTTP/1.0\\r\\n\\r\\n\" >&3 && grep -q ok <&3'"]
      interval: 5s
      retries: 10

  users-db:
    image: postgres:17
    environment:
      POSTGRES_USER: users
      POSTGRES_PASSWORD: ${USERS_DB_PASSWORD:?set USERS_DB_PASSWORD}
      POSTGRES_DB: users
    volumes: ["users-data:/var/lib/postgresql/data"]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U users"]
      interval: 5s
      retries: 10

  catalog-db:
    image: postgres:17
    environment:
      POSTGRES_USER: catalog
      POSTGRES_PASSWORD: ${CATALOG_DB_PASSWORD:?set CATALOG_DB_PASSWORD}
      POSTGRES_DB: catalog
    volumes: ["catalog-data:/var/lib/postgresql/data"]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U catalog"]
      interval: 5s
      retries: 10

volumes:
  users-data:
  catalog-data:

Notes on the file:

  • Secrets come from the environment. ${SERVICE_JWT_SECRET:?...} makes Compose refuse to start when the value is missing, instead of silently running with an empty secret. Put the real values in an untracked .env file (never commit it) or use Compose secrets:.

  • depends_on with service_healthy starts the gateway only after the services answer /healthz and the services only after their databases accept connections. The /healthz route is the last line of configure. A health check must not depend on the other services, or one outage turns every service unhealthy at once.

  • One image, a role per container. All three containers use the same image and differ in SERVICE_NAME. With one executable target per service they would use separate Dockerfiles.

  • Databases. The Compose file points the services at separate PostgreSQL servers through DATABASE_URL; the Fluent driver setup for PostgreSQL is on the Fluent Overview and Drivers page. The scratch package of this page uses SQLite and in-memory stores, so it does not read DATABASE_URL.

The Dockerfile follows the shape in the Vapor Docker documentation (a build stage on the Swift image, a runtime stage that runs as a non-root user with a statically linked Swift standard library); pin the swift image tag to the toolchain your package needs, and check on Docker Hub that it exists before building:

# syntax=docker/dockerfile:1
FROM swift:6.3-noble AS build
WORKDIR /build
COPY Package.swift Package.resolved ./
RUN swift package resolve
COPY Sources ./Sources
RUN swift build -c release --static-swift-stdlib && \
    mkdir -p /staging && cp "$(swift build -c release --show-bin-path)/App" /staging/App

FROM ubuntu:noble
RUN useradd --system --create-home --shell /usr/sbin/nologin vapor
WORKDIR /app
COPY --from=build --chown=vapor:vapor /staging/App ./App
USER vapor
EXPOSE 8080
ENTRYPOINT ["./App"]
CMD ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"]

docker compose config accepted the Compose file; the image was not built here. Deployment with Docker and Deployment Targets cover production images, reverse proxies and orchestrators in depth; the gateway’s ports: mapping is where a TLS-terminating proxy attaches in front.

Without Docker, the same system runs as three processes, which is how the output above was produced:

$ SERVICE_NAME=users   .build/debug/App serve --port 8081 &
$ SERVICE_NAME=catalog .build/debug/App serve --port 8082 &
$ SERVICE_NAME=gateway .build/debug/App serve --port 9080 &

Swift 6 and concurrency notes

  • UpstreamClient is a Sendable struct of two strings, a TimeAmount and an Int; it is created once in configure and captured by route closures, which Swift 6 requires to be Sendable.

  • Shared mutable state lives in actors: UserStore and IntrospectionCache. A class with a lock would also work, but an actor makes the compiler enforce that every access is `await`ed.

  • async let in the gateway runs the two catalog calls concurrently and cancels the unfinished call when the other one throws; the catalog’s withThrowingTaskGroup does the same for the reviewer lookups and keeps the result order by sorting on the index, because tasks finish in any order.

  • Child tasks capture the Request. In 4.122.2 that compiles in the Swift 6 language mode with no warning; the child tasks only read request state and use req.client, which is the supported use.

  • Environment.get is read once during configure, not per request.

Common errors and pitfalls

  • Shared database. "Just this one join" between services ends the independence of both. Call the other service or keep a copy of the few fields you need, updated from events.

  • Trusting a header from the network. X-User-Id is only meaningful behind a verified service token (or mutual TLS). The gateway must not forward the client’s own copy.

  • The same secret for user tokens and service tokens. A service that can mint service tokens must not be able to mint user tokens. Keep them under different keys, and check aud.

  • No timeout. The default client timeouts are generous; a call without its own timeout holds a connection for as long as the upstream cares to take. Set it on every call and keep the chain of timeouts decreasing.

  • Retries at every layer. Multiplied attempts turn a brownout into an outage; retry in one place, with backoff and jitter, and only idempotent calls.

  • A health check that calls dependencies. It makes a single failure cascade into every service being restarted.

  • Fan-out per item. One call per review row is an N+1 across the network; batch the lookup or cache the names.

  • Compose .env committed. Treat it like any secret file.

  • Splitting too early. If you cannot say which database each table belongs to, you are not ready to split.

Version notes

  • The Vapor, JWT, Fluent and AsyncHTTPClient versions are those in the first paragraph. Fluent 4.13.0 and JWT 5.1.2 are the releases this page was compiled against; the JWTKeyCollection API is the one of JWT 5 (the JWTSigners of JWT 4 are gone).

  • Vapor 5 is at 5.0.0-beta.4 and not released. The shape of this design (middleware, a client, JWT verification, async let) carries over; verify signatures against the beta before porting.

  • The Swift tools version of the scratch package is 6.3, matching the official template. Containers need a Swift image at least that new.

Where the books are outdated

  • Condon et al. (Server-Side Swift with Vapor, Kodeco), chapters 35 and 36, describe splitting an application into services with a database per service, validating a token by sending it to a users service, an API gateway that relays with Client, and orchestrating the containers with Compose. The architecture is still sound and this page keeps all four ideas. What changed is the code: Client and routes are async throws in 4.122 rather than futures chained with flatMap; the book’s relay copies headers wholesale, which this page replaces with an allow-list; the token check is cached and can be replaced by local verification against a JWKS; and every call here has a timeout and a deliberate retry policy, where the book’s version trusts the network.

  • Yeung (Packt), chapter 10, presents the API gateway and backends-for-frontends in Vapor 3 and passes service-to-service calls over plain HTTP between trusted hosts. That is no longer a safe default: authenticate each service call (service tokens or mutual TLS) and set timeouts.

  • Neither book covers Swift 6 data-race safety: shared state such as the introspection cache must be an actor or otherwise Sendable.