PureXML/third_party/rules-swift/shared-protocols.md

7.9 KiB

Shared Protocols Target

The pattern for a single foundation-only target that holds every cross-target protocol seam. Tiledown is a monorepo from day one with many targets in one package, so the seam target applies as soon as one producer target needs to talk to another without importing its concretes. Add it when the first cross-target seam appears.

Companion to dependency-injection.md and package-import-contract.md. The target described here is conventionally named SharedProtocols.

Target regime

Across the package's targets, every internal target falls into one of two categories:

  1. Producer / feature targets import only SharedProtocols plus external dependencies available on their supported platforms. Their SPM dependencies: list is ["SharedProtocols", ...external products] and nothing else internal.
  2. Composition root and live-writer targets (the CLI, networking, storage, environment, and similar glue) import SharedProtocols plus the external framework they bridge. Their dependencies: list is ["SharedProtocols", ...the framework being wrapped]. These are the only places where protocol-to-concrete glue exists.

There is no third category.

Hard rules

  1. SharedProtocols has zero internal dependencies. Its SPM dependencies: list is [].
  2. Allowed imports inside SharedProtocols: Swift standard-library types and the portable Foundation modules supplied by the toolchain. Forbidden: UI frameworks, third-party package products, and every internal target. The target's package-dependency list remains []; a contract that would expose an external or renderer type instead defines a neutral value or stays in its adapter target.
  3. Declarations are top-level. public protocol Recording, not nested under a namespace anchor like extension SharedProtocols { protocol Recording }.
  4. Naming follows the domain noun form. Recording, FileSystem, HTTPRequester. The protocol is the noun; the conformer is LiveRecording. You do not have to suffix the protocol with -able or -ing.
  5. One concept per file, filename equal to the type name. Group related files in subfolders (Coordinators/, Providers/) when there are three or more related declarations.
  6. DEBUG-only preview implementations live here too, as a Preview_*_Impl no-op shape wrapped in #if DEBUG.

What goes in SharedProtocols

  • Protocol seams: every protocol that exists to decouple a producer from a concrete (Recording, HTTPRequester, FileSystem, UUIDProvider, DateProvider, and so on).
  • Coordinator protocols for navigation/flow when the codebase uses them.
  • Value types that travel across targets: enums (HTTPMethod, LogLevel), small structs (HTTPResponse, a domain model).
  • Null implementations (NullRecording, NullHTTPRequester) that composition roots, previews, and tests may inject explicitly when no behavior is intended. Producers still receive the collaborator through init; they do not select a hidden default.
  • DEBUG-only Preview_*_Impl no-op conformances for SwiftUI previews and tests.

What does NOT go in SharedProtocols

  • Live writers and concrete services (LiveRecording, LiveHTTPClient, LiveFileSystem). These live in their own glue targets.
  • Per-capability locator protocols invented just to satisfy a DI checklist. If the composition root can probe the environment in three lines and decide whether to register a service, do not manufacture a one-method protocol for it.

Why

Cross-target coupling through protocols (rather than concrete imports) only works if the protocol is reachable without dragging in the rest of the producer's universe. If a protocol lives in producer target Logging, then any consumer must import Logging to see it. Multiply that across every seam type and a producer ends up importing many internal modules. SharedProtocols collapses that to one import.

Live writers import SharedProtocols for the protocol and the external framework for the concrete, never an intermediate internal target.

Example

// Sources/SharedProtocols/Coordinators/AppCoordinator.swift
@MainActor
public protocol AppCoordinator: AnyObject {
    func showWelcome()
    func showMainApp()
    func start()
}
// Sources/SharedProtocols/Coordinators/PreviewHelpers.swift
#if DEBUG
public final class Preview_AppCoordinator_Impl: AppCoordinator {
    public init() {}
    public func showWelcome() {}
    public func showMainApp() {}
    public func start() {}
}
#endif
// Sources/SharedProtocols/TileSummary.swift
import Foundation

public struct TileSummary: Identifiable {
    public var id: UUID
    public var title: String
    public init(id: UUID = .init(), title: String) {
        self.id = id
        self.title = title
    }
}

Package.swift:

let sharedProtocolsTarget = Target.target(
    name: "SharedProtocols",
    dependencies: []   // zero internal deps
)

Every feature target depends on SharedProtocols and nothing else internal:

let tileFeatureTarget = Target.target(
    name: "TileFeature",
    dependencies: ["SharedProtocols"]
)

Anti-patterns

Per-capability locator protocols

// Avoid: a protocol invented to satisfy a DI checklist
public protocol BinaryLocating: Sendable {
    func locateBinary() -> String?
}

The composition root can probe the filesystem itself in three lines. The producer does not need a locator abstraction.

Protocol nested under a namespace anchor

// Avoid in SharedProtocols
extension SharedProtocols {
    public protocol Recording { /* ... */ }
}

Top-level (public protocol Recording) reduces noise at call sites and matches import SharedProtocols ergonomics.

Inline protocol declared in a producer file

// Avoid: a cross-target seam declared in the producer that uses it
public protocol TileProviding: Sendable {
    func validate() async throws -> Bool
}

A composition root would have to import the producer to see the protocol. The dependency arrow is reversed.

Stop rule

Before adding a new import to a producer Swift file:

  1. Is it an external framework or SPM product? Fine.
  2. Is it SharedProtocols? Fine.
  3. Anything else internal? STOP. Either move the seam into SharedProtocols, or push the orchestration up to the composition root.

CI guard

# SharedProtocols imports only portable toolchain Foundation modules.
SHARED_PROTOCOLS_PATH="Sources/SharedProtocols"
[ -d "$SHARED_PROTOCOLS_PATH" ] || SHARED_PROTOCOLS_PATH="Packages/Sources/SharedProtocols"
grep -rn '^import ' "$SHARED_PROTOCOLS_PATH"/ | while IFS= read -r hit; do
    import_line=${hit#*:*:}
    mod=$(printf '%s\n' "$import_line" | awk '{print $2}')
    case "$mod" in
        Foundation|FoundationEssentials|FoundationInternationalization) ;;
        *)
            echo "FAIL: SharedProtocols imports non-toolchain module $mod ($hit)"
            exit 1
            ;;
    esac
done

Sequencing

If you migrate existing scattered protocols into SharedProtocols, do it as one coordinated arc, not a per-target vote:

  1. Move every cross-target protocol and value type into SharedProtocols.
  2. Update every internal target's deps: producers gain ["SharedProtocols"] and drop intermediate targets; live writers gain ["SharedProtocols", <external framework>].
  3. Update every old import to import SharedProtocols.
  4. Delete the now-empty intermediate source directories and target declarations.
  5. Build and test green; commit.