There is a moment in every codebase where someone draws a box on a whiteboard, labels it EventBus, and draws arrows fanning out to every other box. The room nods. It looks like architecture. It looks like the right architecture — loosely coupled, extensible, the textbook answer to “how do we let many components react to one thing happening?”

That diagram is a trap. I drew it for Moments, my photo manager, in late March 2026. I had the design reviewed by an external UI architect. I shipped it in April across six phased PRs. And on the third of May I deleted the whole thing in a single commit titled, with some satisfaction:

refactor: delete EventBus, AppEvent, library/commands, MediaClient v1 — closes #580

This is what I learned about why the bus was wrong, what replaced it, and the broader pattern: an event bus is decoupling-by-indirection, and the indirection itself is the cost.

What the bus looked like

The original design was, by the standards of the form, a good one. A single EventBus lived on the GTK main thread. Background Tokio tasks pushed AppEvents into it via a Send + Clone sender. glib::idle_add_once drained the queue and fanned out to subscribers. Subscriptions were RAII-handle-based with re-entrancy-safe deferred drops. It had tests. It had a design doc. It worked.

Here is the central type — the bit that everything else depended on:

#[derive(Debug, Clone)]
pub enum AppEvent {
    Error(String),
    ThumbnailReady { media_id: MediaId },

    // Commands: UI intent → library command handler
    TrashRequested { ids: Vec<MediaId> },
    FavoriteRequested { ids: Vec<MediaId>, state: bool },
    AddToAlbumRequested { album_id: AlbumId, ids: Vec<MediaId> },
    CreateAlbumRequested { name: String, ids: Vec<MediaId> },
    DeleteAlbumRequested { ids: Vec<AlbumId> },
    // ...

    // Results: command handler → subscribers
    MediaTrashed { ids: Vec<MediaId> },
    AlbumCreated { album: Album },
    // ...
}

By the time I deleted it, AppEvent had twenty-one variants in a single file, split into “commands” (UI intent) and “results” (library outcomes). Buttons emitted *Requested events. A CommandHandler trait dispatched each one to a struct in src/commands/. Library backends emitted *Result events. Widgets subscribed to whichever results they cared about and patched their ListStore models.

It was internally consistent, and it was wrong.

Where it started hurting

There was no single bug big enough to justify deleting it. There was just the same small bug, in slightly different costumes, every week.

The enum became a junk drawer. Every new feature added a request variant and a result variant. The file grew. Understanding what could fire meant scanning the whole enum. Worse, dead variants accumulated: refactor: remove dead AppEvent variants (#576) is a real commit, removing variants that had no subscribers anywhere. Nobody had noticed because the bus made the absence of subscribers invisible.

Double-update bugs. When the album-create button fires CreateAlbumRequested, the command handler calls the library, gets back an Album, and emits AlbumCreated. The album grid widget — which initiated the action — also subscribes to AlbumCreated, because how else would it learn? So the widget refreshes from the broadcast. But the widget’s own code path knew the result first and tried to insert the row optimistically. Now you have two writers fighting over the same ListStore. The fix in any individual case is easy — guard with a flag, defer one path. The pattern of needing the fix everywhere is the smell.

Re-entrancy hazards. One commit message that survives in the history reads fix: defer navigate in ImportComplete handler to avoid bus re-entrancy. A subscriber to ImportComplete navigated to a new view. Navigating triggered unrealize on the old view. unrealize dropped a Subscription. Dropping a Subscription mutated the bus’s subscriber list — while the bus was iterating it. The original design had anticipated this and added a “deferred removals” mechanism. That mechanism was a tax on every drop, paid forever, to fix a problem the bus itself created.

O(n) no-op events. During an Immich sync, the face-recognition service updates the face_count on every person row. There can be hundreds. Each update fired a result event. Each event hit every subscriber, even though face_count wasn’t a property any GObject was bound to. The fix was to teach the service to skip emission for update_face_count. So now the service had to know which of its own state changes were “broadcast-worthy” — which is a leaky responsibility for a layer that shouldn’t know who’s listening at all.

You can’t follow it at runtime. Set a breakpoint on bus.send(). Hit it. Where does control end up? Twelve subscribers across four widgets and two background services, in some order, on the next idle tick. Now do that for an interaction that fires three events. The bus had given me decoupling at the cost of being unable to read the program.

The translation layer was the first thing to fall. Library events arrived as a separate LibraryEvent enum and were translated into AppEvent at the boundary. The translation was pure ceremony. It came out in refactor: eliminate LibraryEvent → AppEvent translation loop (#520) — about three weeks after the bus shipped. That should have been the warning sign: the bus’s main job had been routing events from one layer to another, and as soon as the layers started talking directly, the bus’s reason for existing started to thin out.

What replaced it

The replacement is, deliberately, almost boring. Each library service holds its own typed EventEmitter<T> and emits a small enum specific to that service:

#[derive(Debug, Clone)]
pub enum AlbumEvent {
    AlbumAdded(AlbumId),
    AlbumUpdated(AlbumId),
    AlbumRemoved(AlbumId),
    AlbumMediaChanged(AlbumId),
}

Four variants. Owned by the album feature. Nothing about media, faces, sync, errors, or UI intent.

The EventEmitter<T> itself is a single file:

pub struct EventEmitter<T: Clone> {
    senders: Arc<Mutex<Vec<mpsc::UnboundedSender<T>>>>,
}

impl<T: Clone> EventEmitter<T> {
    pub fn subscribe(&self) -> mpsc::UnboundedReceiver<T> {
        let (tx, rx) = mpsc::unbounded_channel();
        self.senders.lock().expect("poisoned").push(tx);
        rx
    }

    pub fn emit(&self, event: T) {
        let mut senders = self.senders.lock().expect("poisoned");
        senders.retain(|tx| tx.send(event.clone()).is_ok());
    }
}

A subscriber gets a fresh receiver. Dead receivers prune themselves on the next emit. Clones share the same subscriber set. That is the entire primitive.

The service uses it in two places — once to expose subscription, once to emit:

pub struct AlbumService {
    db: Arc<Database>,
    events: EventEmitter<AlbumEvent>,
}

impl AlbumService {
    pub fn subscribe(&self) -> mpsc::UnboundedReceiver<AlbumEvent> {
        self.events.subscribe()
    }

    pub async fn delete_album(&self, id: &AlbumId) -> Result<()> {
        self.db.delete_album(id).await?;
        self.events.emit(AlbumEvent::AlbumRemoved(id.clone()));
        Ok(())
    }
}

On the GTK side, the client subscribes once at configure time and spawns a Tokio listener that dispatches model patches back to the main thread:

pub fn configure(&self, library: Arc<Library>, tokio: tokio::runtime::Handle,
                 events_rx: mpsc::UnboundedReceiver<AlbumEvent>) {
    let client_weak: glib::SendWeakRef<AlbumClientV2> = self.downgrade().into();
    tokio.spawn(Self::listen(events_rx, library, client_weak));
}

async fn listen(mut rx: mpsc::UnboundedReceiver<AlbumEvent>,
                library: Arc<Library>,
                client_weak: glib::SendWeakRef<AlbumClientV2>) {
    while let Some(event) = rx.recv().await {
        match event {
            AlbumEvent::AlbumRemoved(id) => {
                let weak = client_weak.clone();
                glib::idle_add_once(move || {
                    if let Some(client) = weak.upgrade() {
                        client.remove_from_models(&id);
                    }
                });
            }
            // ...
        }
    }
}

That is the whole architecture. There is no central registry. There is no AppEvent enum. The album feature defines its events, owns its emitter, and hands receivers to whoever asks. A new feature adds a new service with its own emitter; nothing in the existing code has to change.

The double-update fix that was a design fix

The most interesting thing about the new pattern isn’t the channel itself. It’s a small contract documented on every command method:

Client-initiated mutations don’t emit events — the client patches its model directly in its callback.

When AlbumClientV2::create_album runs, it calls the service, gets back the new album, inserts it into its tracked models itself, and that’s that. The service does not fire AlbumAdded for client-initiated work. The bus-era double-update bug cannot exist, because the broadcast path and the initiator path are no longer the same path.

The bus had pushed me toward “everyone reacts to everything uniformly”, which sounds clean and is a lie — initiators always know more than subscribers. The new design lets the initiator use what it knows. Events are reserved for state changes the initiator didn’t cause: a remote sync arriving, a background scan completing, a peer process writing the database.

What I’d tell myself in 2025

Three things, in order of how useful they would have been.

An event bus is decoupling-by-indirection. It feels like you’ve removed a dependency. You haven’t. You’ve moved it into a giant enum that nobody owns and that everyone has to read to understand the program. The dependency is still there; you’ve just made it untyped, ungrep-able, and impossible to follow at runtime. A direct mpsc::UnboundedSender<AlbumEvent> from the album service to the album client is more coupled than a bus message of type AppEvent::AlbumCreated, and that coupling is exactly what makes the program legible.

The fan-out problem is rarer than you think. I had reached for a bus because “many subscribers might react to one event.” In practice, almost every event in Moments has one or two subscribers, and the subscribers are statically known. A bus’s variable, late-bound subscriber list buys you very little when the real subscriber set is “the album client and nothing else.”

Architecture review can validate internal consistency but not problem fit. The bus design was reviewed and approved. The reviewer was right that, given the bus, the design was sound. They couldn’t have told me — and I couldn’t have asked — whether a bus was the right shape for the problem in the first place. That’s a question only a few weeks of using it can answer, and in this case a few weeks were enough. Build the smallest version of the boring thing first. Earn the right to a bus.

The deletion commit removed about 1,200 lines net. The replacement, including EventEmitter and four service-specific event enums, is closer to 200. The bus had been doing what a thin wrapper around mpsc does, dressed up as architecture.

I don’t regret building it — without the bus there’d be nothing concrete to compare against, and the lessons would have stayed in the realm of opinion. But if I were starting again, I would write pub fn subscribe(&self) -> mpsc::UnboundedReceiver<AlbumEvent> first and not draw the diagram at all.