Put all app logic in value types with pure functions, and keep objects only to store those values and run side effects. The struct is trivially unit-testable because it has no side effects; the object stays small enough to cover with a few integration tests.
The Functional Core Is a Struct
A struct owns the state and mutates itself through pure mutating functions. Value semantics
isolate every copy, so mutating a local instance cannot ripple through the app, and a test only
has to check outputs.
struct Fasting {
var goal: Goal
private(set) var start: Date?
private(set) var end: Date?
var progress: Double {
guard let start = start else { return 0.0 }
return (end ?? .now).timeIntervalSince(start) / goal.duration
}
mutating func begin() { start = .now }
mutating func finish() { end = .now }
mutating func reset() {
start = nil
end = nil
}
struct Goal {
let duration: TimeInterval
static let trf16 = Goal(duration: 16 * 3600)
static let omad = Goal(duration: 23 * 3600)
}
}
The Imperative Shell Is an Object
Reference types share state between screens, so a small ObservableObject holds the struct and
performs the impure work: persistence, networking, I/O. Its dependencies are async closures, so
tests mock only the side effects.
@MainActor final class TimerStore: ObservableObject {
struct Dependencies {
let save: (Fasting) async throws -> Void
let load: () async throws -> Fasting?
}
@Published var currentFasting = Fasting(goal: .trf16)
private let dependencies: Dependencies
init(dependencies: Dependencies) {
self.dependencies = dependencies
}
func load() async {
currentFasting = (try? await dependencies.load()) ?? currentFasting
}
func save() async {
try? await dependencies.save(currentFasting)
}
}
The View Talks to Both
The view mutates the value directly through the store's published property and calls the shell only for side effects.
struct TimerView: View {
@ObservedObject var store: TimerStore
var body: some View {
VStack {
if let start = store.currentFasting.start {
Text(start, style: .timer)
Button("Finish") { store.currentFasting.finish() }
Button("Save") { Task { await store.save() } }
} else {
Button("Begin") { store.currentFasting.begin() }
}
}
.task { await store.load() }
}
}