Chapter 4  ·  Gate 04

Background Controllers

Not every write in this guide happens because a user asked for it. This chapter covers the background controller — the long-lived, Foundation-only object that keeps the app current with no gesture behind it at all: periodic refreshes, reactions to system events, state derived continuously from the Model.

What a background controller is

A background controller is the first concrete instance of the controller paradigm introduced in Chapter 3: it bridges app events and timers to Model-layer work, knowing when to act without doing the work itself.

Some work isn’t triggered by a gesture at all — a periodic refresh, a reaction to the system waking from sleep, a value derived continuously from the Model. That’s a background controller: a long-lived, app- or document-lifetime object, Foundation-only, that never shows UI and is never invoked as part of a user gesture.

protocol BackgroundController: AnyObject {
    func startRunningInBackground()
    func stopRunningInBackground()
}

Conforming controllers start in phase 4 of launch — see Chapter 2.2 — never earlier: initializers must stay fast, and a controller may assume the subsystems below it are already configured.

Reach for one when work has no gesture behind it: refreshing data on an interval, reacting to system events that can happen at any time, maintaining state derived from model changes, cleaning up stale data periodically, recording events for telemetry. And know the three cases that look like one but aren’t: a one-shot user-initiated operation is an Action — Chapter 6.1; work that needs progress reporting and user cancellation is a long-running Action — the Action is the live operation, per that same subchapter; window-scoped state belongs to a view state object — Chapter 8.1.

SuffixDriving signalDoes
*UpdaterTimerPeriodically refreshes data into local state.
*WatchdogTimerChecks for stalled or bad state and corrects it.
*ReaperTimerPeriodically removes stale or expired data.
*TrackerEventRecords observed events for later use.
*ProjectorEventDerives and publishes state from observed model changes.

The specific suffix is preferred over a generic *Controller or *Observer precisely because it makes the type’s purpose legible at the call site without opening the file. A hybrid — a timer that also adjusts its cadence on events — is named for the primary signal, the one that defines its purpose.

Here’s a timer-driven one in full. StaleDraftReaper deletes autosaved note drafts past their keep-window, and shows every lifecycle rule in one place:

final class StaleDraftReaper: BackgroundController {
    private var timer: Timer?
    private var cancellables = Set<AnyCancellable>()

    func startRunningInBackground() {
        startTimer()

        // Pause on sleep, resume on wake — otherwise the app keeps waking
        // the machine for a cleanup nobody is awake to benefit from.
        NSWorkspace.shared.notificationCenter
            .publisher(for: NSWorkspace.willSleepNotification)
            .sink { [weak self] _ in self?.timer?.invalidate() }
            .store(in: &cancellables)

        NSWorkspace.shared.notificationCenter
            .publisher(for: NSWorkspace.didWakeNotification)
            .sink { [weak self] _ in self?.startTimer() }
            .store(in: &cancellables)
    }

    func stopRunningInBackground() {
        timer?.invalidate()
        timer = nil
        cancellables.removeAll()
    }

    deinit {
        timer?.invalidate()
    }

    private func startTimer() {
        timer?.invalidate()
        timer = Timer.scheduledTimer(withTimeInterval: 60 * 30, repeats: true) { [weak self] _ in
            self?.reap()
        }
    }

    private func reap() {
        let cutoff = Date().addingTimeInterval(-30 * 24 * 3600)
        NoteManager.shared.deleteDrafts(olderThan: cutoff)
    }
}

The lifecycle rules the example encodes:

  • Subscribe and start timers in startRunningInBackground(), never in init. The instance may be created during app setup, but nothing may fire before phase 4 — creation and starting are separate on purpose.
  • A scheduled repeating Timer lives until it’s invalidated. The run loop holds it strongly, so without an explicit invalidate() it keeps firing forever — and with the target/selector API it would additionally retain its target. The weak self in the block keeps the timer from pinning the controller; invalidation in stop and deinit keeps an orphaned timer from firing into nothing.
  • Cancellables empty in stop. Block-based NotificationCenter observers added without Combine additionally need their token held and removeObserver called — they don’t clean themselves up.

An event-driven controller is the same skeleton with a subscription in place of the timer: a TagUsageProjector subscribes to the note-changed notification, recomputes tag usage counts, and publishes the result. Which brings up the one design decision every background controller makes — where its output goes. Pick exactly one channel per piece of state:

ChannelWhen
An @Observable property on the controllerViews and state objects consume it by tracking — Chapter 8.3.
A posted notificationBroad fan-out to consumers that don't hold a reference.
A write into a managerThe controller's whole job is refreshing data the manager already owns.

Publishing the same state through two channels means consumers never know which one to trust — one place to look, always.

The rule

A background controller may read from managers and trigger reloads on them, and it may update its own published state — delivered on the main thread, even if its work runs elsewhere. It must never show UI, never be invoked as part of a user gesture, and never be created per-window — that’s a job for view state, not a background controller.

Ahead in this guide The Model layer these controllers read from and write into — the write funnel every manager enforces — is next: Chapter 5, The Model Layer.