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 {
"""
Event: \(name);\(id);\(source);\(generationDate)
    Payload: \(String(describing: payload))
"""
    }
}

And the EventBus:

public class EventBus {
    private let sender = PassthroughSubject<any EventProtocol, Never>()
    public var all: AnyPublisher<any EventProtocol, Never> {
        sender.eraseToAnyPublisher()
    }

    public func send<T: EventProtocol>(_ event: T) {
        sender.send(event)
    }

    public func receive<T: EventProtocol>(_ receive: T.Type) -> AnyPublisher<T, Never> {
        all
            .compactMap { $0 as? T }
            .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.

import SwiftUI

struct PlaylistView: View {
    @Environment(\.eventBus) private var eventBus
    @State private var shuffledSongs: [Song] = [] // Assuming `Song: Identifiable`

    var body: some View {
        List(shuffledSongs) { song in
            Text(song.title)
        }
        .toolbar {
            Button("Shuffle") {
                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:

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:

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 as Message instead of Notification.

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.

Open Post