Advanced Swift · advanced

Access control, modules and Swift Package Manager

The six access levels, what a module boundary actually enforces, and packages as the tool that turns architecture diagrams into compiler errors.

12 min read12 min practice0/2 exercises5 recall cards

By the end you will be able to

  • Choose the right access level, and explain what public promises that internal does not
  • Read and write a Package.swift with targets, products and semver dependencies
  • Use local packages to make architectural boundaries compiler-enforced
Guess firstAnswering before you read makes the explanation stick — even when you get it wrong.

The Clean Architecture lesson said the domain core must not depend on UI or networking. In a single-target app, what enforces that rule?

The six access levels

From most open to most closed — with the two questions each answers: who can see it? and (for classes) who can subclass/override it?

LevelVisible fromThe promise it makes
openanywhereuse and subclass/override from other modules
publicanywhereuse from other modules — but subclass/override only inside this one
packagetargets in the same packageshared plumbing between sibling modules, hidden from the app
internal (default)this modulefree to change without breaking anyone outside
fileprivatethis fileco-conspirators in one file only
privatethis declaration + extensions in the same fileimplementation detail, full stop

The open/public split deserves its why, because it looks like hair-splitting until you maintain a framework. public already lets strangers call your class; open lets them override it — and an unknown override is riding every internal call path you have. Mark a class public-not-open and you may freely change which of your methods calls which; mark it open and that freedom is gone, because someone, somewhere, overrode the method your refactor just stopped calling. Two different promises, so Swift refuses to bundle them into one keyword.

Three of these do the daily work. internal is the default and the workhorse — inside one module you rarely annotate at all. private hides invariants — state that must only change through methods that maintain it. public is a commitment: everything public is API someone can build on, which makes it expensive to change forever after. The operational rule: start at the bottom and be promoted reluctantly — a private made internal is a one-line change; a public made internal is a breaking change for every client.

package (Swift 5.9) fills the once-painful gap: two targets in one package sharing helpers without exposing them to the app — pre-5.9 codebases full of public // not really, just for the other module are what it retired.

And the modifier you will write most in model code:

public struct Account {
    public private(set) var balance: Decimal      // world reads, only we write
    public mutating func deposit(_ amount: Decimal) {
        balance += amount
    }
}

private(set) splits the two halves: public getter, private setter — readable state that can only change through your rule-enforcing methods.

Package.swift: the manifest

A Swift package is a directory with a manifest — itself Swift:

// swift-tools-version: 6.0
import PackageDescription

let package = Package(
    name: "DomainKit",
    platforms: [.iOS(.v17)],
    products: [
        .library(name: "DomainKit", targets: ["DomainKit"]),
    ],
    dependencies: [
        .package(url: "https://github.com/pointfreeco/swift-snapshot-testing",
                 from: "1.17.0"),
    ],
    targets: [
        .target(name: "DomainKit"),                       // Sources/DomainKit
        .testTarget(name: "DomainKitTests",
                    dependencies: [
                        "DomainKit",
                        .product(name: "SnapshotTesting",
                                 package: "swift-snapshot-testing"),
                    ]),
    ]
)

The vocabulary: targets are the modules (each compiles separately, each is an access-control boundary); products are what the package offers to the outside; dependencies are other packages, pinned by version rules. Sources live under Sources/<TargetName> by convention — the manifest stays this small in most real packages.

Semver: what from: "1.17.0" promises

Dependency versions follow semantic versioningMAJOR.MINOR.PATCH — and the requirement rules are contracts about breakage:

  • from: "1.17.0" — the default and almost always right: any version >= 1.17.0 and < 2.0.0. You accept features and fixes; you refuse the major bump that is allowed to break your build.
  • .upToNextMinor(from: "1.17.0") — only patches (< 1.18.0); for dependencies you trust less.
  • exact: — reproducibility at the price of never healing; branch:/revision: — development only, and the manifest cannot ship to a registry with them: a published release depending on main would mean the "same" version resolves to different code next week, breaking the one promise a registry makes to everyone downstream — a version, once published, never changes meaning.

The half of semver that engineers forget: it binds you when you publish. Marking your library 2.1.0 → 2.2.0 while removing a public method breaks every from: client in a way the tooling promised them you would not. Which is the deeper reason public is expensive: your public surface × semver = what a major version costs.

The from: resolution rule, executable — highest version below the next major.

1.19.1 — not 2.1.0, though it is newer, because crossing the major would accept advertised breakage; and a floor nothing satisfies resolves to nothing, which is SPM's "no versions satisfying" error in miniature.

Local packages: architecture the compiler checks

The payoff that ties this track together. A modularised app:

MyApp.xcodeproj
Packages/
  DomainKit/        entities, use cases    — depends on: nothing
  DataKit/          repositories, network  — depends on: DomainKit
  FeatureCheckout/  screens, view models   — depends on: DomainKit, DataKit

Because DomainKit declares no dependencies, import SwiftUI inside it is a build error — the Clean core's framework-freedom is now physics, not policy. Because DataKit's conformances are internal and only its protocols-and-constructors are public, features cannot reach around the repository seam even on a bad day. Two further effects teams feel immediately: packages build in parallel and cache (faster incremental builds than one monolithic target), and each package's tests run without booting the app.

The migration path mirrors the Combine lesson's: leaves first — extract the domain core, then the data layer, one seam at a time.

Check yourself

You are extracting DomainKit and must decide the access level for struct PricingRules (used by features) and its helper func roundToCharm(_:) (used only inside the package's two targets). What do they get?

Implement the resolver, with a ceiling

Write the code

Extend the semver resolver to support both from: (up to next major) and upToNextMinor: requirements.

Solution unlocks after 3 attempts

Design the public surface

Write the code

AnalyticsKit is being extracted into a package. Decide each symbol's access level so the app can log events — and nothing else.

Solution unlocks after 3 attempts
Design itSaved on this device. Never graded.

Sketch the package split for an app you know: 3–5 packages, each with its dependency arrows (which must be acyclic), and for one package write its intended public surface as a bullet list. Then the audit question: which single symbol would teams be most tempted to make public that you are deliberately keeping internal — and what future cost does that resist?

Checkpoint

You can now:

  • Choose access levels by promise, defaulting low and promoting reluctantly
  • Read and write a Package.swift and explain every requirement rule
  • Predict what a semver constraint resolves to — and what publishing one obliges
  • Turn the architecture track's boundaries into compiler-enforced package walls

Next up: the platform surfaces beyond your app — widgets and notifications.

How well do you know this now? Rating yourself honestly, then being tested on it, is how you find out where your intuition is wrong.