An observer API is a delivery contract, not merely an array of closures. Callers need to know who retains a subscription, whether cancellation suppresses a callback already waiting in a delivery pass, and what happens when an observer adds another observer or emits another event.
We will build a small synchronous event bus with explicit answers. It preserves registration order, lets cancellation suppress a not-yet-started callback, defers newly registered observers until the next event, and reports rejected reentrant delivery.
Represent the lifetime with a token
final class EventSubscription {
private var cancelAction: (() -> Void)?
init(cancel: @escaping () -> Void) { cancelAction = cancel }
func cancel() {
let action = cancelAction
cancelAction = nil
action?()
}
deinit { cancel() }
}
Cancellation removes its own action before invoking it. A repeated or reentrant call therefore has no second effect. Deinitialization is a cleanup fallback, while an owner can call cancel explicitly when its feature stops.
A subscription must be retained while observation is desired. Discarding a newly returned token immediately ends this subscription’s lifetime. That is intentional, so the API’s usage and documentation should make token ownership visible.
The cancellation closure will weakly refer to the bus. A retained token should not keep the entire producer alive after its actual owner releases it.
State the delivery rules in the implementation
final class EventBus<Event> {
private var callbacks: [Int: (Event) -> Void] = [:]
private var order: [Int] = []
private var nextID = 0
private var delivering = false
func observe(_ callback: @escaping (Event) -> Void) -> EventSubscription {
nextID += 1
let id = nextID
callbacks[id] = callback
order.append(id)
return EventSubscription { [weak self] in
self?.callbacks[id] = nil
self?.order.removeAll { $0 == id }
}
}
@discardableResult
func emit(_ event: Event) -> Bool {
guard !delivering else { return false }
delivering = true
defer { delivering = false }
let scheduled = order
for id in scheduled {
callbacks[id]?(event)
}
return true
}
}
The delivery pass snapshots identifiers, not closures. Before each callback, it looks up the identifier in the current callback dictionary. Cancellation before an observer’s turn removes that entry and suppresses its delivery.
New observers append to the live order array, but their identifiers are absent from the current snapshot. They begin with a later event. This avoids mutating the collection being iterated and gives addition a clear timing rule.
emit returns false for a nested attempt rather than silently pretending that nested event was delivered. A product that needs queued nested events should implement an explicit queue and termination policy instead. Unbounded recursive emission can otherwise overflow the stack or create an infinite feedback loop.
Verify ordinary delivery and idempotent cancellation
final class EventRecorder<Event> {
var values: [Event] = []
}
let integerEvents = EventBus<Int>()
let integerRecorder = EventRecorder<Int>()
let integerSubscription = integerEvents.observe { [weak integerRecorder] value in
integerRecorder?.values.append(value)
}
assert(integerEvents.emit(7))
assert(integerRecorder.values == [7])
integerSubscription.cancel()
integerSubscription.cancel()
assert(integerEvents.emit(8))
assert(integerRecorder.values == [7])
The bus retains the callback, and the callback weakly references the receiver. The receiver’s separate owner keeps it alive. If a callback strongly captures an owner that retains the bus or token, inspect the complete graph for cycles.
A weak capture does not automatically remove the dead callback from the bus. In this implementation the token still owns explicit cancellation. A more specialized weak-observer registry could prune dead entries, but that is a separate policy.
Swift’s ARC documentation explains weak references and closure capture cycles. The observer pattern does not change those ownership rules.
Exercise changes during notification
func checkObserverOrdering() {
let bus = EventBus<String>()
var received: [String] = []
var second: EventSubscription?
var late: EventSubscription?
let first = bus.observe { [weak bus] _ in
received.append("first")
second?.cancel()
if late == nil {
late = bus?.observe { _ in received.append("late") }
}
assert(bus?.emit("nested") == false)
}
second = bus.observe { _ in received.append("second") }
assert(bus.emit("initial"))
assert(received == ["first"])
assert(bus.emit("next"))
assert(received == ["first", "first", "late"])
first.cancel()
second?.cancel()
late?.cancel()
}
checkObserverOrdering()
The second observer is registered before the event begins, but cancellation suppresses it before its callback starts. The late observer is created during the first pass and appears only in the next pass. Reentrant emission is rejected on both passes.
The assertions are deterministic because delivery is synchronous and serialized. They do not depend on sleeps, background scheduling, or a UI lifecycle. A test checking only that some closure was called would miss every timing rule demonstrated here.
sequenceDiagram
accTitle: Cancellation changes a scheduled observer pass
accDescr: The bus snapshots first and second observers. The first cancels the second and adds a late observer. The second is skipped and the late observer waits for the next event.
participant Owner
participant Bus
participant First
participant Second
Owner->>Bus: emit event
Bus->>Bus: snapshot observer IDs
Bus->>First: callback
First->>Bus: cancel second
First->>Bus: register late observer
Bus->>Bus: second ID no longer registered
Bus-->>Owner: delivery pass completed
Exactly-once needs a scope
Each observer registered at the start of this pass can be invoked at most once during that pass. Cancellation can result in zero deliveries. Calling emit again is another pass and can notify the same observer again.
This is not durable event delivery. The bus stores no history, provides no acknowledgment, and cannot replay events after a restart. If an event represents a required payment or persistence action, an optional in-memory subscription should not be its only execution mechanism.
Also distinguish an event from a current-state query. A late subscriber receives future events, not an automatic copy of the latest state. If a screen needs the current state immediately, provide a snapshot or a replay policy explicitly.
Protocols describe recipients when they need a named interaction
A closure is sufficient for one event value. A protocol can be clearer when a receiver handles several related notifications with shared semantics. A class-only receiver protocol also permits weak storage when that ownership relationship is appropriate.
Do not create one universal application observer protocol with dozens of unrelated methods and empty defaults. Consumers become difficult to construct, and missing handling can disappear into no-ops. Separate coherent event domains or use explicit event enums when that makes the supported cases clearer.
A generic Event type keeps this bus from accepting an unrelated payload by accident. A concrete event enum can describe several cases while retaining exhaustive handling in a receiver.
Isolation must protect the whole delivery operation
This bus is not thread-safe. The dictionary, order array, and delivering flag assume serialized access. Locking individual dictionary accesses would not by itself establish a coherent pass or make arbitrary observer callbacks safe under a lock.
For a UI feature, main-actor isolation on the bus and its consumers can be appropriate. For asynchronous event streams, an actor or AsyncSequence may better express cancellation and backpressure. Decide whether a slow observer should delay every other observer, as it does in this synchronous design.
Never assume using a protocol or closure automatically chooses the callback’s executor. Execution context is part of the event contract.
Review cleanup under every owner transition
A screen may stop observing when hidden, on sign-out, or when a document is replaced, even though its object remains alive. Explicit cancellation handles those transitions; waiting for deinit ties behavior to memory lifetime instead of feature lifetime.
Tests should keep the token alive, cancel twice, release a receiver, cancel before its turn, add during delivery, and attempt reentrant emission. For a queued implementation, also test whether cancelling removes queued deliveries or only prevents future scheduling.
As an exercise, add a result that reports how many callbacks actually started in a pass. Keep that distinct from the number scheduled at the beginning. The difference makes cancellation observable and reinforces the central lesson: an observer API should describe delivery precisely enough that callers can reason about the events they will see.
Worked lifetime checks: tokens do not own the event producer
Automatic token cleanup and producer lifetime are separate properties. Exercise both explicitly:
func checkObserverLifetimes() {
let bus = EventBus<Int>()
let recorder = EventRecorder<Int>()
var subscription: EventSubscription? = bus.observe { recorder.values.append($0) }
assert(bus.emit(1))
assert(subscription != nil)
subscription = nil
assert(bus.emit(2))
assert(recorder.values == [1])
var temporaryBus: EventBus<Int>? = EventBus()
weak var observedBus = temporaryBus
let survivingToken = temporaryBus!.observe { _ in }
temporaryBus = nil
assert(observedBus == nil)
survivingToken.cancel()
}
checkObserverLifetimes()
In the first case, releasing the sole token cancels registration through deinit. In the second, retaining a token does not retain the bus, and cancelling after the bus disappears remains harmless.
These checks would catch a cancellation closure accidentally capturing the bus strongly. A cycle involving an observer’s own callback is a different edge and requires a corresponding owner-release test.
Explicit cleanup still matters when a feature stops before its objects are released. A hidden screen can remain in a navigation stack, and an account coordinator can retain a child for later reuse. Relying only on deinit would keep its observation active throughout that interval.
A useful production owner stores its tokens alongside the lifecycle they represent and cancels them at the documented stop transition. The automatic fallback handles forgotten release paths; it should not be the only expression of application-level intent.
Continue through the Protocol-Oriented Programming roadmap.