Skip to content

Functional Core, Imperative Shell (Swift)

A SwiftUI guide for coding agents. Also covers value type state logic, struct mutating pure functions, observableobject side effects, testable app logic swift, store holds value type.

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() }
  }
}

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-swiftui-guide({ topic: "swiftui-functional-core-imperative-shell-in-swift" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems