•
Swift event bus
3 minutes read •
While working on the M25 app, I realized I needed a more structured approach to event handling. This post outlines the design of a simple event bus implementation in Swift. It improves on the initial design here.
A naive event bus built on Combine
Before the details, here’s how it’s used. This is a naive event bus: intentionally minimal, clear, and best suited for prototypes or small modules; you can harden it later with policies, filtering, and scheduling.
// Define the event with a payload
class SongsShuffled: EventBase<ShuffledPlaylist> { }
// Send a SongsShuffled event with a ShuffledPlaylist payload over the bus
eventBus.send(DomainEvent.SongsShuffled(ShuffledPlaylist.sample()))
// Listen to event and assign to a @Published
eventBus.receive(DomainEvent.SongsShuffled.self)
.map(\.payload.songs)
.receive(on: DispatchQueue.main)
.assign(to: &$shuffledSongs)
Naive implementation
The minimal code required for the naive bus to work.
public protocol EventProtocol: CustomStringConvertible {
associatedtype Payload
var generationDate: Date { get }
var id: UUID { get }
var name: String { get }
var source: String { get }
var payload: Payload { get }
}
open class EventBase<Payload>: EventProtocol {
public let generationDate: Date = .now
public let id: UUID = .init()
public let name: String
public let source: String
public let payload: Payload
public init(_ payload: Payload, _ source: String = #file) {
self.source = URL(string: source)?.lastPathComponent ?? source
self.payload = payload
self.name = String(describing: type(of: self))
}
public var description: String {
}
}
And the EventBus:
public class EventBus {
private let sender = PassthroughSubject<any EventProtocol, Never>()
public var all: AnyPublisher<any EventProtocol, Never> {
sender.eraseToAnyPublisher()
}
public
public
.eraseToAnyPublisher()
}
}
This naive design is intentionally minimal. It can be extended to include filtering or a receiveOnMain
convenience to simplify receiving events on the main thread. Because this uses Combine, events can be zipped, merged, and otherwise composed with the framework’s operators.
Integration in the app
I’m designing the app so that all parts communicate via a single event bus. Making EventBus a singleton is appealing, but singletons complicate unit testing—especially when tests run in parallel. One test can send an event that another test receives, causing unexpected behavior.
To mitigate this, use dependency injection to provide the EventBus instance to components that need it. This allows creating separate bus instances per test.
However, this introduces “constructor pollution”: every component that uses the bus must accept it in its initializer. This is a common trade-off discussed in detail in the Dependency Injection book.
I integrate eventBus
via the SwiftUI environment:
extension EnvironmentValues {
var eventBus: EventBus {
get { self[EventBusKey.self] }
set { self[EventBusKey.self] = newValue }
}
}
struct EventBusKey: EnvironmentKey {
public static let defaultValue: EventBus = .shared
}
Example: Using EventBus in a SwiftUI view
Inject from the environment, subscribe with onReceive, and send from UI actions.
struct PlaylistView: View {
@Environment() private var eventBus
@State private var shuffledSongs: [Song] = [] // Assuming `Song: Identifiable`
var body: some View {
List(shuffledSongs) { song in
Text(song.title)
}
.toolbar {
Button( ) {
eventBus.send(SongsShuffleRequested())
}
}
}
}
Thread safety
Events can be produced from multiple threads. Without explicit scheduling, each value is delivered to subscribers on the same thread that called send
. Combine then guarantees, per subscription, that the subscriber’s receive
/handling callback is invoked sequentially (no concurrent reentry) and in order. Across different subscribers (or after receive(on:)
), callbacks may run in parallel on their respective schedulers.
Key points:
- Sends: Multiple producers can call
send
from different threads. If you need global ordering or single-writer semantics, serialize calls tosend
(e.g., via a serial queue/actor). - Delivery thread: Use
receive(on:)
to hop to a specific scheduler (e.g., main) for UI work. Without it, handling runs on the sending thread. - Per-subscriber sequencing: Each subscriber’s handling is serial and ordered; different subscribers can handle events concurrently.
Visualizing parallel production with per-subscriber sequential handling:
Sending (producers)
[thread A] - a ----------- e -
[thread B] ---- b -
[thread C] -------- c --- d -
Delivery (subscribers)
Subscriber 1 (Main) [serial]: a -> c -> e
Subscriber 2 (BG) [serial]: b -> d
// Note: Subscriber 1 and 2 can run in parallel on different schedulers,
// but each subscriber's callback runs one event at a time, in order.
Practical guidance:
- UI safety:
eventBus.receive(Event.self).receive(on: DispatchQueue.main)
before updating views. - Shared mutable state: confine to a serial queue/actor or hop to one with
receive(on:)
. - If strict global ordering is required, ensure
send
is funneled through a single serialization point.
NotificationCenter
With the new API in iOS 26, it’s worth considering the updated NotificationCenter
. I plan to experiment with whether it can serve as an event bus.
Note
If you have used the Objective‑C–based
NotificationCenter
to broadcast notifications across an iOS app, you should know that, starting with iOS 26, Apple introduced a new API to send and receive notifications in a type-safe manner. It also adopts more event-driven naming, such asMessage
instead ofNotification
.
Comments
You can comment on this blog post by publicly replying to this post using a Mastodon or other ActivityPub/Fediverse account. Known non-private replies are displayed below.