Greetings, traveler!
In larger iOS applications, communication between features often becomes harder than the feature itself. A service finishes an operation, a coordinator needs to react, several screens may need to refresh, and passing every dependency through the entire navigation tree quickly starts to feel like unnecessary plumbing.
Dependency injection and state management still have their place. An event bus gives us another tool for loosely coupled notifications where direct ownership would add unnecessary complexity.
The goal of this component is simple: allow one part of the application to publish an event, and allow other parts of the application to subscribe to events of a specific type.
The implementation focuses on type safety, thread-safe storage, automatic cleanup when the owner is deallocated, explicit cancellation for individual subscriptions, MainActor delivery for UI code, and AsyncStream support for async/await consumers.
Defining an Event
Every event sent through the bus conforms to a small marker protocol:
public protocol EventBusEvent: Sendable {}The protocol does not require any methods or properties. Its purpose is to mark a type as an event that can be published through EventBus.
For example:
struct UserDidLoginEvent: EventBusEvent {
let userID: String
}The Sendable requirement matters because events may cross concurrency boundaries. A published event can be delivered from one task to another, consumed through an AsyncStream, or scheduled on the MainActor. Requiring Sendable encourages event payloads to be designed as small, immutable values.
The EventBus Type
The core type is a final class:
public final class EventBus: @unchecked Sendable {
private let lock = NSLock()
private var subscriptionsByEvent: [ObjectIdentifier: [SubscriptionEntry]] = [:]
public static let `default` = EventBus()
public init() {}
}The bus stores subscriptions in a dictionary. The key is an ObjectIdentifier created from the event type. The value is an array of subscriptions for that event.
Conceptually, the storage looks like this:
UserDidLoginEvent.self -> [subscription, subscription]
CartDidUpdateEvent.self -> [subscription]This allows the bus to keep subscriptions separated by event type while still using a single internal storage.
The class is marked as @unchecked Sendable because thread safety is enforced manually through NSLock. The compiler cannot fully verify the safety of the mutable dictionary, so the implementation takes responsibility for synchronizing access.
The component also provides a shared instance:
public static let `default` = EventBus()At the same time, the initializer is public, so the bus can be used as an injected dependency in tests, feature modules, or isolated flows.
Publishing an Event
The publishing API is intentionally small:
public func publish<Event: EventBusEvent>(_ event: Event)Publishing starts by creating a key from the event type:
let eventKey = ObjectIdentifier(Event.self)Then the bus locks internal storage, finds subscriptions for that event type, removes dead or cancelled subscriptions, and creates a snapshot of receive closures:
let receivers: [(Any) -> Void] = lock.withLock {
guard var bucket = subscriptionsByEvent[eventKey] else {
return []
}
bucket = cleanDeadSubscriptions(in: bucket)
subscriptionsByEvent[eventKey] = bucket.isEmpty ? nil : bucket
return bucket.map(\.subscription.receive)
}After the lock is released, the bus delivers the event:
receivers.forEach { $0(event) }This is one of the central design decisions. The lock protects only the internal data structure. Subscriber handlers are called outside the lock, which keeps user code away from the synchronization mechanism.
This also gives the component snapshot delivery semantics. A publish call works with the list of subscribers that existed at the moment the snapshot was created. Subscribers added during the current publish call receive future events. Subscribers removed during delivery may already exist in the snapshot, so each subscription checks its cancellation state before calling the handler.
For regular subscriptions, publish creates a snapshot and delivers the event synchronously after the lock has been released.
Subscribing to Events
The main subscription API accepts an owner and a handler:
@discardableResult
public func subscribe<Owner: AnyObject, Event: EventBusEvent>(
owner: Owner,
to eventType: Event.Type = Event.self,
handler: @escaping (Owner, Event) -> Void
) -> SubscriptionTokenA typical usage can look like this:
eventBus.subscribe(owner: self, to: UserDidLoginEvent.self) { owner, event in
owner.handleUserLogin(event)
}The interesting part is the owner parameter. The subscription is associated with an object, and the bus stores that object weakly. When the owner is deallocated, the subscription becomes inactive and is cleaned up later during publish or subscribe operations.
This design makes the returned token optional for lifetime management. The subscription follows the lifetime of the owner. The token is available when one specific subscription needs to be cancelled manually.
let token = eventBus.subscribe(owner: self) { owner, event in
owner.handle(event)
}
token.cancel()Each call to subscribe creates an independent subscription. The same owner can subscribe to the same event type multiple times, and each subscription receives its own token.
Keeping Ownership Explicit
The owner-aware handler shape is a small but important API decision:
{ owner, event in
owner.handle(event)
}The bus captures the owner weakly internally and passes a strong reference into the handler only when the owner is still alive.
This avoids the common pattern where every subscription needs its own [weak self] block:
{ [weak self] event in
self?.handle(event)
}The API also provides an event-only overload:
@discardableResult
public func subscribe<Owner: AnyObject, Event: EventBusEvent>(
owner: Owner,
to eventType: Event.Type = Event.self,
handler: @escaping (Event) -> Void
) -> SubscriptionTokenThis overload is useful for simple reactions where the owner is only used as a lifetime anchor. When the handler needs to call back into the owner, the owner-aware form keeps the ownership model explicit at the call site.
Internal Subscription Representation
Internally, every subscription is stored as a SubscriptionEntry:
struct SubscriptionEntry {
let id: UUID
let subscription: Subscription
}The UUID identifies one specific subscription. This allows the returned SubscriptionToken to cancel exactly the subscription that created it.
The subscription itself stores a weak owner, a cancellation state, and a type-erased receive closure:
final class Subscription {
private weak var owner: AnyObject?
let cancellationState: CancellationState
let receive: (Any) -> Void
}The receive closure accepts Any because the bus stores subscriptions of many generic event types in one dictionary. The event is cast back to the expected type inside the closure:
self.receive = { [weak owner] rawEvent in
guard !cancellationState.cancelled(),
let owner,
let event = rawEvent as? Event else {
return
}
handler(owner, event)
}Before the handler runs, the subscription checks that the subscription has not been cancelled, the owner is still alive, and the event has the expected type. Then it calls the handler.
Cancellation
The returned token represents one specific subscription:
public final class SubscriptionToken: @unchecked Sendable {
private let lock = NSLock()
private let cancellationState: CancellationState
private let cancelClosure: @Sendable () -> Void
private var isCancelled = false
public func cancel() {
lock.lock()
guard !isCancelled else {
lock.unlock()
return
}
isCancelled = true
lock.unlock()
cancellationState.cancel()
cancelClosure()
}
}Cancellation has two layers.
First, the shared CancellationState is marked as cancelled. This prevents delivery even if the subscription has already been copied into a publish snapshot.
Second, the subscription is physically removed from the internal dictionary.
This distinction matters because publishing uses snapshot delivery. A subscription can be present in a snapshot and then cancelled before its handler is called. The cancellation state keeps that case safe without complicating the public API.
Unsubscribing by Owner
The bus also provides owner-based unsubscribe methods:
public func unsubscribe<Owner: AnyObject, Event: EventBusEvent>(
owner: Owner,
from eventType: Event.Type = Event.self
)This removes all subscriptions created by that owner for one event type.
There is also a broader variant:
public func unsubscribeAll(for owner: AnyObject)
This removes all subscriptions created by the owner across all event types.
These APIs are useful when a component has a clear lifecycle boundary and wants to detach from a group of events explicitly. For cancelling one specific subscription, the token is the more precise tool.
Lazy Cleanup
The bus does not need a deinit hook on the subscriber. Instead, it cleans inactive subscriptions lazily:
func cleanDeadSubscriptions(in bucket: [SubscriptionEntry]) -> [SubscriptionEntry] {
bucket.filter {
$0.subscription.isAlive && !$0.subscription.cancellationState.cancelled()
}
}This cleanup runs during subscription and publishing.
A subscription is considered inactive when its owner has been deallocated or when its cancellation state has been marked as cancelled. The bus removes those entries from the bucket before continuing.
This keeps the public API lightweight. A subscriber can rely on object lifetime, while the bus keeps its internal storage tidy during normal operations.
One-Time Subscriptions
Some events are useful only once. For that case, the bus provides:
@discardableResult
public func subscribeOnce<Owner: AnyObject, Event: EventBusEvent>(
owner: Owner,
to eventType: Event.Type = Event.self,
handler: @escaping (Owner, Event) -> Void
) -> SubscriptionTokenThe one-time subscription uses a small lock-protected gate. This makes the guarantee stronger than simply cancelling after the first event, because multiple publish calls may already have captured the same subscription in their snapshots.
After the first delivery, the subscription cancels itself:
let token = subscribeInternal(owner: owner, to: eventType) { owner, event in
guard onceGate.consumeFirstDelivery() else {
return
}
tokenBox.cancelAndClear()
handler(owner, event)
}
This keeps the public API expressive while keeping the edge case handling inside the component.
The implementation uses a small helper called OnceGate:
final class OnceGate: @unchecked Sendable {
private let lock = NSLock()
private var hasDelivered = false
func consumeFirstDelivery() -> Bool {
lock.lock()
defer { lock.unlock() }
guard !hasDelivered else {
return false
}
hasDelivered = true
return true
}
}The gate ensures that the handler runs only once, even if multiple publish calls race with the same subscription snapshot.
MainActor Subscriptions
UI code often needs event delivery on the main actor. For that, the bus provides a dedicated API:
@MainActor
@discardableResult
public func subscribeOnMain<Owner: AnyObject, Event: EventBusEvent>(
owner: Owner,
to eventType: Event.Type = Event.self,
handler: @escaping @MainActor (Owner, Event) -> Void
) -> SubscriptionToken
The internal subscription schedules delivery like this:
self.receive = { rawEvent in
guard !cancellationState.cancelled(),
let event = rawEvent as? Event else {
return
}
Task { @MainActor in
guard !cancellationState.cancelled(),
let owner = ownerRef.owner as? Owner else {
return
}
handler(owner, event)
}
}This means an event can be published from any context, while the handler runs on the MainActor.
One detail worth mentioning is that MainActor delivery is scheduled asynchronously. The regular subscription path delivers events synchronously, while subscribeOnMain hops to the main actor before calling the handler.
There are also two cancellation checks. One happens before scheduling the task, and another happens inside the task. This covers the case where the subscription is cancelled after scheduling but before the main actor executes the handler.
This API is useful for view controllers, view models, coordinators, and other UI-facing objects.
AsyncStream Support
The bus can also expose events as an AsyncStream:
public func stream<Event: EventBusEvent>(
_ eventType: Event.Type = Event.self,
bufferingPolicy: AsyncStream<Event>.Continuation.BufferingPolicy = .bufferingNewest(100)
) -> AsyncStream<Event>This allows events to be consumed with async/await:
for await event in eventBus.stream(UserDidLoginEvent.self) {
print(event)
}Internally, the stream creates a private owner object:
final class StreamOwner: @unchecked Sendable {}This owner is retained for as long as the stream is active. When the stream terminates, the subscription is cancelled:
streamContinuation.onTermination = { @Sendable _ in
tokenBox.cancelAndClear()
}This preserves the same owner-based lifecycle model used by the regular subscription API.
The default buffering policy is:
.bufferingNewest(100)This gives the stream a bounded buffer and keeps the latest events when the consumer is slower than the producer.
Why the Implementation Uses a Lock
This implementation uses NSLock because the shared mutable state is small and the critical sections are short. The lock protects only the dictionary of subscriptions. Handlers are invoked after the snapshot has been created and the lock has been released.
This design keeps the regular publishing path synchronous and predictable:
eventBus.publish(event)MainActor and AsyncStream integrations add asynchronous behavior only where the API explicitly asks for it.
For this kind of component, a lock-based implementation works well because synchronization is limited to storage access, while user code runs outside the lock.
Можно добавить после раздела Subscribing to Events небольшой раздел Example Usage.
Example Usage
Here is a small example of how this kind of EventBus can be used in an application.
First, define an event:
struct UserDidLoginEvent: EventBusEvent {
let userID: String
}Then publish it from the place where the action happens:
final class AuthService {
private let eventBus: EventBus
init(eventBus: EventBus = .default) {
self.eventBus = eventBus
}
func completeLogin(userID: String) {
eventBus.publish(UserDidLoginEvent(userID: userID))
}
}Any object that needs to react to this event can subscribe to it:
final class ProfileViewModel {
private let eventBus: EventBus
init(eventBus: EventBus = .default) {
self.eventBus = eventBus
eventBus.subscribe(owner: self) { owner, event: UserDidLoginEvent in
owner.reloadProfile(for: event.userID)
}
}
private func reloadProfile(for userID: String) {
// Reload user-specific data
}
}The view model does not need to know about AuthService, and AuthService does not need to know which screens or coordinators are interested in the login event. They communicate through a typed event, while the subscription lifetime is tied to the ProfileViewModel instance.
For UI-related subscribers, the same idea can be used with subscribeOnMain:
eventBus.subscribeOnMain(owner: self) { owner, event: UserDidLoginEvent in
owner.updateUI(for: event.userID)
}This keeps event delivery explicit, type-safe, and aligned with the lifetime of the subscriber.
Conclusion
The most important part of this design is the ownership model. The subscription follows the owner’s lifetime. The bus does not ask subscribers to keep tokens alive just to avoid leaks. The token remains available for explicit cancellation of one specific subscription.
That small decision shapes the rest of the implementation: weak owner references, lazy cleanup, cancellation state, snapshot delivery, MainActor support, and AsyncStream bridging all build around the same lifecycle model.
The result is a small infrastructure component that can help decouple parts of an iOS application while keeping event flow type-safe and predictable.
Available on my GitHub.
