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<TEvent>)</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
Readyonward. Per 02_MODULE_MODEL.md §1, publishing duringConfigureorInitializerisks reaching subscribers that have not finished initializing. - A
CancellableEventis published before the action, a plainIEventafter.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
IsCancelleditself, once, after every handler has run. A handler does notreturnearly or throw to signal cancellation; it callsCancel(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(...), notbus.Publish. The bus has no way to hand a return value back to the publisher, andPublishitself 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.
Publishstays fully synchronous. It has many existing fire-and-forget callers throughout the codebase (PropertyStore.PurchaseAsync,VehicleStore.PurchaseAsync, ...) that call it from ordinary code with noawait. An async handler is started and left running;EventBusobserves itsTaskinternally (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.