Skip to content

Event Bus

How one module observes another without depending on it — and the direct replacement for the legacy hook dispatcher that D-07 describes.


1. Two kinds of event

namespace Applejack.Core.Events;

/// <summary>
/// Something that already happened. Subscribers observe; they cannot change the outcome.
/// Named in the past tense.
/// </summary>
public interface IEvent { }

/// <summary>
/// Something about to happen. Subscribers may cancel it, explicitly, by setting
/// <see cref="IsCancelled"/> -- never by returning a value. Named for what is about to occur.
/// </summary>
public abstract class CancellableEvent : IEvent
{
    /// <summary>
    /// Set by a subscriber to prevent the action. Once true, later subscribers in the
    /// publish order still run (so more than one system can log or react), but the
    /// publisher checks this after every handler and does not perform the action.
    /// </summary>
    public bool IsCancelled { get; private set; }

    /// <summary>Cancels the event. <paramref name="reason"/> is logged and may reach the player.</summary>
    public void Cancel(string reason)
    {
        IsCancelled = true;
        CancellationReason = reason;
    }

    public string? CancellationReason { get; private set; }
}
public readonly record struct DoorPurchased(Character Buyer, Door Door, int Price) : IEvent;

public sealed class DoorPurchasing : CancellableEvent
{
    public required Character Buyer { get; init; }
    public required Door Door { get; init; }
}

This is the direct fix for D-07: the legacy dispatcher let any handler suppress core behaviour by returning a non-nil value, in undefined order, from a routine that also happened to be the entire game's hook dispatcher because it replaced hook.Call globally. Here, cancellation is a named, explicit method call on a specific event type, and nothing about the bus is global — it is a registered service like any other.

2. The bus

namespace Applejack.Core.Events;

public interface IEventBus
{
    /// <summary>
    /// Registers <paramref name="handler"/> to run whenever <typeparamref name="TEvent"/>
    /// is published. Subscription has no ordering guarantee relative to other subscribers of
    /// the same event -- if order matters, that is a sign two handlers should be one, or that
    /// the dependency belongs in <c>DependsOn</c> instead of the event bus.
    /// </summary>
    void Subscribe<TEvent>(Action<TEvent> handler) where TEvent : IEvent;

    /// <summary>
    /// Removes <paramref name="handler"/> if it was previously registered via the matching
    /// Subscribe overload. A no-op, not an error, if it was never subscribed or has already
    /// been removed -- safe to call defensively, including twice.
    /// </summary>
    void Unsubscribe<TEvent>(Action<TEvent> handler) where TEvent : IEvent;

    /// <summary>
    /// Registers an asynchronous <paramref name="handler"/>. The bus fires and forgets --
    /// Publish does not await it and returns before it necessarily completes. A faulted Task
    /// is caught and logged exactly like a throwing synchronous handler. See §7.
    /// </summary>
    void Subscribe<TEvent>(Func<TEvent, Task> handler) where TEvent : IEvent;

    /// <summary>As <c>Unsubscribe(Action&lt;TEvent&gt;)</c>, for a handler registered via the
    /// async Subscribe overload.</summary>
    void Unsubscribe<TEvent>(Func<TEvent, Task> handler) where TEvent : IEvent;

    /// <summary>
    /// Publishes <paramref name="event"/> to every current subscriber. A handler that throws
    /// is caught, logged with the handler and its owning module named, and does not prevent
    /// other handlers from running or abort the publish. See 12_LOGGING_AND_DIAGNOSTICS.md.
    /// </summary>
    void Publish<TEvent>(TEvent @event) where TEvent : IEvent;
}

The real IEventBus.cs elides [CallerMemberName]/[CallerFilePath] plumbing from this sample for readability, the same way it already elides them here for the sync Subscribe; see that file for the exact signatures. Every IEventBus implementation in the codebase -- the real EventBus and every test fake -- implements all five members directly; none of them carry a default body.

Subscribing to an event creates no compile-time coupling to the publisher's concrete type — a module may subscribe to events from a module it does not declare in DependsOn, because doing so cannot reach into that module's internals. This is deliberately looser than the service registry: services are "I need this module's behaviour", events are "tell me when something happens, from anyone".

3. Publishing rules

  • Publish only from Ready onward. Per 02_MODULE_MODEL.md §1, publishing during Configure or Initialize risks reaching subscribers that have not finished initializing.
  • A CancellableEvent is published before the action, a plain IEvent after. DoorPurchasing (cancellable) runs before money moves; DoorPurchased (fact) runs once it has, and by definition cannot be cancelled — the correct response to "I don't like that this happened" is a different subscriber undoing part of its own state, not vetoing history.
  • The publisher checks IsCancelled itself, once, after every handler has run. A handler does not return early or throw to signal cancellation; it calls Cancel(reason) and lets the publish loop finish, so every subscriber — including a logging subscriber that wants to record every attempt, cancelled or not — sees the event.

4. Failure isolation

sequenceDiagram
    participant P as Publisher
    participant B as Event bus
    participant H1 as Handler A (throws)
    participant H2 as Handler B
    participant L as Log

    P->>B: Publish(DoorPurchasing)
    B->>H1: invoke
    H1-->>B: throws
    B->>L: log exception, handler, owning module
    B->>H2: invoke (unaffected)
    H2-->>B: returns
    B-->>P: publish complete

An exception in a handler is contained at the bus, not the publisher — the publisher's code never needs a try/catch around Publish. This is the one property of the legacy dispatcher worth keeping, delivered without a monkey-patched global: see 02_MODULE_MODEL.md §7 and 13_ERROR_HANDLING.md.

5. What does not belong on the bus

  • Request/response. The bus is fire-and-forget. A module that needs an answer, not just a notification, calls a service method ( 03_SERVICE_REGISTRY.md), not an event.
  • High-frequency, per-tick data. An event allocates and is dispatched to every subscriber; it is for "a thing happened", not a replacement for [Sync] state replication. See 07_NETWORKING.md.
  • Anything requiring ordered, guaranteed multi-step orchestration. If handler B must run after handler A completes some side effect, that is a direct call, not two subscribers to the same event racing on unordered dispatch.

6. Testing

IEventBus is a plain interface; a fake in-memory bus that records published events and invokes subscribers synchronously is sufficient for unit tests, including asserting that a throwing handler does not prevent a second handler from observing the same event:

[TestMethod]
public void ThrowingHandlerDoesNotPreventOtherHandlersRunning()
{
    var bus = new FakeEventBus();
    var secondHandlerRan = false;

    bus.Subscribe<DoorPurchased>(_ => throw new InvalidOperationException());
    bus.Subscribe<DoorPurchased>(_ => secondHandlerRan = true);

    bus.Publish(new DoorPurchased(FakeCharacter.Buyer, FakeDoor.Any, Price: 150));

    Assert.IsTrue(secondHandlerRan);
}

7. Async reactions

Every real cross-module reaction has to do I/O (typically a persistence save), so a sync-only Action<TEvent> bus could never host a real subscriber -- only ever a test's own synchronous fake. The async Subscribe<TEvent>(Func<TEvent, Task> handler) overload exists for exactly that: a notification whose reaction is allowed to keep running after Publish returns.

  • A request stays a direct call, async or not. This is the same distinction §5 already draws for the sync bus, and it does not change just because the reaction happens to be async: if the caller needs the reaction's result, or needs it to have finished before moving on, that is a service method call -- await service.DoThingAsync(...), not bus.Publish. The bus has no way to hand a return value back to the publisher, and Publish itself is deliberately synchronous (see below), so it cannot wait for an async subscriber even if the publisher wanted it to.
  • An async notification can be a subscription. "Tell every interested module a vehicle was purchased, and let each one do whatever I/O it needs in reaction, on its own time" is exactly what the async overload is for -- the publisher does not need the reaction to have happened before it moves on, only for it to happen eventually.
  • Publish stays fully synchronous. It has many existing fire-and-forget callers throughout the codebase (PropertyStore.PurchaseAsync, VehicleStore.PurchaseAsync, ...) that call it from ordinary code with no await. An async handler is started and left running; EventBus observes its Task internally (so a fault is still caught and logged, never an unobserved-task-exception) but the publisher never does.

Real example. VehicleKeyMinter (Code/Vehicles/VehicleKeyMinter.cs) subscribes HandleAsync to VehiclePurchased from VehicleModule.RegisterServices -- minting the purchased vehicle's key and saving it into the buyer's inventory, both of which are persistence I/O. This is the first real, first-party subscriber anywhere in the codebase: every earlier Subscribe call was a unit test constructing EventBus (or a fake) directly, never proof that delivery works through the actual shared, DI-resolved instance CoreModule registers once and every module receives. VehicleStore.PurchaseAsync itself no longer knows minting exists -- it publishes VehiclePurchased and moves on.