Overview

Model data as structs and enums, define behavior with small protocols, and use a class only when identity or lifecycle is part of the contract. Value types copy on assignment, so they remove aliasing bugs and cross concurrency boundaries easily (swift-concurrency). Read swift first.

Reach for struct and enum first

A function receiving a struct gets a copy and cannot affect the caller’s instance. Use a class when:

  • the type must be observed by reference (@Observable applies only to classes, and ObservableObject is class-bound),
  • it owns an external resource with an open and close lifecycle (a connection, a file handle),
  • an Objective-C framework requires a class hierarchy.

For a value that must have a single owner and be non-duplicable, such as a handle or token, use a ~Copyable struct rather than a class. @Observable final class view models are a framework requirement, not a reason to prefer classes elsewhere.

Replace Boolean flags with an enum for exclusive states

Three Booleans admit eight combinations, most of them impossible. An enum makes the invalid states unrepresentable and switch forces every case to be handled.

enum FeedState {
    case idle
    case loading
    case loaded([Post])
    case failed(Error)
}

Keep mutation explicit

Mark methods that change stored properties mutating; a let binding then refuses to call them. Use private(set) to expose a read-only property while keeping mutation internal.

struct Counter {
    private(set) var value = 0
    mutating func increment() { value += 1 }
}

Rely on copy-on-write; hand-write it only after profiling

Array, Dictionary, and Set share one buffer until one owner mutates it, so passing them is cheap. Implement your own copy-on-write with isKnownUniquelyReferenced only when a profile (Instruments Allocations or Time Profiler) shows copy cost on a large custom value type. Measure before assuming a big struct is slower than a class.

Define protocols around one capability

Split by what a caller depends on, so a reader needs only a reader and a test stub implements only what the subject calls.

protocol ItemReader { func fetch() async throws -> [Item] }
protocol ItemWriter { func save(_ item: Item) async throws }
typealias ItemStore = ItemReader & ItemWriter

Know which protocol members dispatch dynamically

Only members declared as protocol requirements dispatch dynamically. A member that exists only in a protocol extension dispatches on the static type, so a conforming type’s same-named method is ignored when you hold the value as any Proto. Declare the member in the protocol itself when conformers should be able to override the default.

Prefer some and generics; use any for runtime variety

some Protocol and a generic constraint keep the concrete type known to the compiler, which enables static dispatch. any Protocol is an existential box, correct when the concrete type varies at runtime, such as a dependency injected through the environment (swiftui). Return some from factories. any is required syntax for existentials of protocols with associated types, and primary associated types let you constrain them (any Repository<User>). Write any explicitly on every existential; the ExistentialAny upcoming feature makes the compiler enforce it.

Add conditional conformance for container capabilities

protocol Timestamped { var updatedAt: Date { get } }
 
extension Array: Timestamped where Element: Timestamped {
    var updatedAt: Date { map(\.updatedAt).max() ?? .distantPast }
}

A conformance is visible everywhere your module is imported and cannot be removed or overridden by clients; add one only where the behavior follows from the element type.