Shared Swift Packages: Unifying Your Client and Server Models

At the end of Series 1, we had a fully deployed Landmarks app. The Vapor backend defines LandmarkResponse and the iOS app defines LandmarkApiModel, and they both describe the exact same JSON shape. We also have CategoryApiModel on the client and LandmarkCategory on the server, both representing the same set of values. CreateLandmarkRequest exists in both codebases too.

Every one of these types exists twice. They look the same, they encode the same, they decode the same. But the compiler doesn't know that. If you add a field to LandmarkResponse on the server and forget to update LandmarkApiModel on the client, you won't find out until runtime. Maybe a decode fails silently and compactMap filters out every landmark. Maybe a field comes back as nil when it shouldn't be. These bugs are subtle, hard to reproduce, and entirely preventable.

The fix: extract the shared types into a Swift package that both sides depend on. One definition, one source of truth, compile-time guarantees that client and server agree on the shape of every request and response.

The companion repo has the complete working code: View Code

The Monorepo Structure

The simplest way to share code between an iOS app and a Vapor server is a monorepo with a local Swift package. You don't need a private package registry or a separate git repo for the shared code. SPM supports local path dependencies, which means you can point both projects at a folder in the same repo.

Here's what the repo structure looks like:

landmarks/
├── Packages/
│   └── LandmarksShared/
│       ├── Package.swift
│       └── Sources/LandmarksShared/
│           ├── LandmarkApiModel.swift
│           ├── CategoryApiModel.swift
│           ├── CreateLandmarkRequest.swift
│           └── LandmarkApiModel+Mock.swift
├── Server/
│   ├── Package.swift
│   └── Sources/App/
│       ├── Models/LandmarkModel.swift
│       ├── Controllers/LandmarkController.swift
│       ├── Responses/LandmarksShared+Vapor.swift
│       └── configure.swift
└── Landmarks/
    ├── Landmarks.xcodeproj
    ├── Models/
    │   ├── Landmark.swift
    │   └── Api/LandmarkApiModel+Domain.swift
    ├── Services/LandmarkService.swift
    └── Views/...

The LandmarksShared package lives inside a Packages directory. Both the Vapor server and the Xcode project reference it as a local dependency. The package contains only the types that cross the network boundary. Everything else stays where it belongs.

The LandmarksShared Package

The package itself is minimal. It has no dependencies of its own, just pure Swift types that both platforms can use:

// Packages/LandmarksShared/Package.swift
// swift-tools-version:5.9
import PackageDescription

let package = Package(
    name: "LandmarksShared",
    platforms: [
        .iOS(.v17),
        .macOS(.v13)
    ],
    products: [
        .library(
            name: "LandmarksShared",
            targets: ["LandmarksShared"]
        ),
    ],
    targets: [
        .target(name: "LandmarksShared"),
    ]
)

Two things to note. First, it supports both iOS and macOS because the Vapor server runs on macOS (or Linux) and the app runs on iOS. Second, it has zero external dependencies. This package is just Codable structs and enums. It compiles fast and doesn't pull in Vapor, Fluent, or any UIKit/SwiftUI framework.

Moving the API Models

In Post 2, we defined LandmarkApiModel on the iOS side as the type that matches the server's JSON. Now that type moves into the shared package so the server can produce it and the client can consume it from the same definition.

First, a marker protocol that gives us a shared set of conformances:

public protocol ApiModel: Codable, Hashable, Equatable, Sendable {}

Then the landmark type itself:

// Packages/LandmarksShared/Sources/LandmarksShared/LandmarkApiModel.swift
import Foundation

public struct LandmarkApiModel: ApiModel, Identifiable {
    public let id: String
    public let name: String?
    public let location: String?
    public let description: String?
    public let imageName: String?
    public let isFeatured: Bool?
    public let category: CategoryApiModel?
    public let createdAt: Date?
    public let updatedAt: Date?

    public init(
        id: String,
        name: String?,
        location: String?,
        description: String?,
        imageName: String?,
        isFeatured: Bool?,
        category: CategoryApiModel?,
        createdAt: Date?,
        updatedAt: Date?
    ) {
        self.id = id
        self.name = name
        self.location = location
        self.description = description
        self.imageName = imageName
        self.isFeatured = isFeatured
        self.category = category
        self.createdAt = createdAt
        self.updatedAt = updatedAt
    }
}

If you've read Post 2, this should look familiar. It's the exact same LandmarkApiModel we defined there, with the same optionals, the same id as String, the same defensive posture. The only difference is it lives in a shared package now instead of the iOS app. The fields are optional because APIs are messy. The server might omit fields, send nulls, or evolve over time. The API model absorbs that messiness so the domain model doesn't have to.

The category enum keeps its unknown case for forward compatibility:

// Packages/LandmarksShared/Sources/LandmarksShared/CategoryApiModel.swift
import Foundation

public enum CategoryApiModel: String, ApiModel {
    case mountains = "Mountains"
    case lakes = "Lakes"
    case bridges = "Bridges"
    case unknown = "Unknown"

    public init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        let rawValue = try container.decode(String.self)
        self = CategoryApiModel(rawValue: rawValue) ?? .unknown
    }
}

The custom decoder means any unrecognized category becomes .unknown rather than a decoding error. This is the same resilience pattern from Post 2, now shared between both sides.

The CreateLandmarkRequest type is what the client sends when creating a new landmark:

// Packages/LandmarksShared/Sources/LandmarksShared/CreateLandmarkRequest.swift
import Foundation

public struct CreateLandmarkRequest: Codable, Sendable {
    public var name: String
    public var location: String
    public var description: String
    public var imageName: String
    public var isFeatured: Bool
    public var category: String

    public init(
        name: String,
        location: String,
        description: String,
        imageName: String,
        isFeatured: Bool,
        category: String
    ) {
        self.name = name
        self.location = location
        self.description = description
        self.imageName = imageName
        self.isFeatured = isFeatured
        self.category = category
    }
}

Every type has explicit public init methods. Swift packages don't synthesize public initializers for public structs, so you need to write them out. It's a bit of ceremony, but it's a one-time cost per type.

Shared Mocks

One bonus of putting API models in a shared package: your mock data can live there too. Both the iOS app's previews and the server's tests can use the same fixtures:

// Packages/LandmarksShared/Sources/LandmarksShared/LandmarkApiModel+Mock.swift
import Foundation

extension LandmarkApiModel {
    public static let mockApiResponse: [LandmarkApiModel] = [
        LandmarkApiModel(
            id: "A2F0C3D1-E4B5-4A6F-8C7D-9E0F1A2B3C4D",
            name: "Twin Peaks",
            location: "San Francisco, California",
            description: "Twin Peaks are two prominent hills...",
            imageName: "twinpeaks",
            isFeatured: true,
            category: .mountains,
            createdAt: Date(),
            updatedAt: Date()
        ),
        LandmarkApiModel(
            id: "B3A1D4E2-F5C6-4B7A-9D8E-0F1A2B3C4D5E",
            name: "Silver Salmon Creek",
            location: "Lake Clark National Park, Alaska",
            description: "A pristine wilderness area...",
            imageName: "silversalmoncreek",
            isFeatured: false,
            category: .lakes,
            createdAt: Date(),
            updatedAt: nil
        ),
        // ...
    ]
}

Previously, every test file and preview that needed sample data had to construct its own. Now there's one set of mock API responses that both sides share. When you add a field to LandmarkApiModel, the mocks remind you to update them too.

Wiring Up the Server

The Vapor server adds LandmarksShared as a local dependency:

// Server/Package.swift
dependencies: [
    .package(url: "https://github.com/vapor/vapor.git", from: "4.89.0"),
    .package(url: "https://github.com/vapor/fluent.git", from: "4.9.0"),
    .package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.6.0"),
    .package(name: "LandmarksShared", path: "../Packages/LandmarksShared"),
],
targets: [
    .executableTarget(
        name: "App",
        dependencies: [
            .product(name: "Fluent", package: "fluent"),
            .product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"),
            .product(name: "Vapor", package: "vapor"),
            .product(name: "LandmarksShared", package: "LandmarksShared"),
        ]
    ),
]

The path: "../Packages/LandmarksShared" tells SPM to look up and into the Packages directory. No version resolution, no network fetches. It's just a folder on disk.

Here's the interesting part. Vapor route handlers need to return types conforming to Content (Vapor's protocol that extends Codable for request/response bodies). But LandmarkApiModel lives in the shared package, which has no Vapor dependency. You can't add Content conformance there.

The solution is @retroactive conformance. In a single file on the server side, declare that the shared types conform to Content:

// Server/Sources/App/Responses/LandmarksShared+Vapor.swift
import LandmarksShared
import Vapor

extension LandmarkApiModel: @retroactive Content {}
extension CategoryApiModel: @retroactive Content {}

That's it. Two lines. Now LandmarkApiModel works as a return type from Vapor route handlers. The @retroactive annotation tells the compiler you know this conformance is being added outside the original module, which is exactly the pattern shared packages enable.

The Fluent model's mapping to the shared type lives on the server since it knows about the database model:

import LandmarksShared

extension LandmarkModel {
    func toApiModel() -> LandmarkApiModel {
        LandmarkApiModel(
            id: self.id!.uuidString,
            name: self.name,
            location: self.location,
            description: self.description,
            imageName: self.imageName,
            isFeatured: self.isFeatured,
            category: CategoryApiModel(rawValue: self.category.rawValue) ?? .unknown,
            createdAt: nil,
            updatedAt: nil
        )
    }
}

The route handlers return the shared type directly:

@Sendable
func index(req: Request) async throws -> [LandmarkApiModel] {
    let landmarks = try await LandmarkModel.query(on: req.db).all()
    return landmarks.map { $0.toApiModel() }
}

Previously the server had its own LandmarkResponse type. Now it uses LandmarkApiModel from the shared package. Same JSON on the wire, but now both sides know it.

Wiring Up the iOS App

For the Xcode project, drag the Packages/LandmarksShared folder into Xcode and add it as a local package dependency in your project settings. Xcode resolves local packages instantly since there's nothing to fetch.

The big change on the iOS side: delete your local LandmarkApiModel.swift and CategoryApiModel.swift. They now come from import LandmarksShared.

The domain mapping from Post 2 stays in the iOS app, but now it extends the shared type:

// Landmarks/Models/Api/LandmarkApiModel+Domain.swift
import Foundation
import LandmarksShared

extension CategoryApiModel {
    var domainModel: Category? {
        switch self {
        case .mountains: .mountains
        case .lakes: .lakes
        case .bridges: .bridges
        case .unknown: nil
        }
    }
}

extension LandmarkApiModel {
    var domainModel: Landmark? {
        guard let name = name?.trimmingCharacters(in: .whitespaces),
              !name.isEmpty,
              let location = location,
              let description = description,
              let imageName = imageName,
              let apiCategory = category,
              let category = apiCategory.domainModel else {
            return nil
        }

        return Landmark(
            id: UUID(uuidString: id) ?? UUID(),
            name: name,
            location: location,
            description: description,
            imageName: imageName,
            isFeatured: isFeatured ?? false,
            category: category
        )
    }
}

extension Array where Element == LandmarkApiModel {
    var domainModels: [Landmark] {
        compactMap(\.domainModel)
    }
}

This is nearly identical to what we had before. The only difference is import LandmarksShared at the top. The domain model layer from Post 2 is still there. Landmark and Category are still the iOS app's internal truth. What changed is that the API boundary is now a shared definition instead of a duplicated one. The compactMap filtering, the optional unwrapping, the unknown category handling - all still there, all still working the same way.

The LandmarkService decodes into the shared type:

import LandmarksShared

let (data, _) = try await URLSession.shared.data(for: request)
let apiModels = try JSONDecoder().decode([LandmarkApiModel].self, from: data)
return apiModels.domainModels

When Not to Share

Not every type should go in the shared package. Here's the rule: if a type appears in JSON that crosses the network boundary, it belongs in LandmarksShared. If it doesn't, it stays where it is.

Goes in LandmarksShared:

  • API models (LandmarkApiModel, CategoryApiModel)
  • Request types (CreateLandmarkRequest)
  • Mock API responses for testing and previews

Stays on the server:

  • Fluent models (LandmarkModel)
  • @retroactive Content conformances
  • Middleware, validation, migrations

Stays on the client:

  • Domain models (Landmark, Category)
  • Domain mapping extensions (LandmarkApiModel+Domain)
  • Services, stores, views, navigation

The shared package is the API contract. Everything above and below that contract is private to each side.

Adding a New Field

Here's where the payoff becomes obvious. Say you want to add a coordinates field to landmarks. Before shared packages, you'd need to:

  1. Add the field to LandmarkResponse on the server
  2. Add it to LandmarkApiModel on the client
  3. Update the mock data on the client
  4. Add the mapping in domainModel
  5. Hope you got the JSON key and type right on both sides

With the shared package:

  1. Add the field to LandmarkApiModel in LandmarksShared
  2. Update the mock data in LandmarksShared
  3. Update toApiModel() on the server
  4. Update domainModel on the client

Steps 1 and 2 happen in one place. The moment you add coordinates to LandmarkApiModel, both the server's toApiModel() and the client's mock construction need to provide it. If you forget either one, it won't compile. The compiler catches the mismatch instead of your users.

Both Server and Landmarks now fail to compile until they handle the new field. That's not a bug. That's the feature.

What's Next

With shared packages in place, the API contract between client and server is a single source of truth. Both sides import the same types, encode and decode the same JSON, and the compiler enforces consistency.

This foundation makes everything that follows in this series safer. When we add authentication, the request and response types live in one place. When we add push notifications, the device registration request will be shared too. Every new feature builds on the same pattern: define the API types once, consume them everywhere.