Skip to content

Event explainers ​

Event sourcing systems get rich very quickly. A BookBorrowed event has a borrower, a due date, a branch — and any tooling that wants to render it as something other than JSON has to know what those fields mean.

Nagare's answer is IEventExplainer<TEvent> — an optional contract that turns a typed event into a one-line story. The dashboard uses it to render the journal as prose; you can use it for audit-log projections, for in-app event detail panels, for slack-bot alerts, and anywhere else you'd otherwise be hand-rolling string formatters.

Contract ​

csharp
public sealed record EventExplanation(
    string Title,
    string Summary,
    string? Detail = null,
    string? Actor = null,
    IReadOnlyDictionary<string, string>? Tags = null);

public interface IEventExplainer<TEvent>
    where TEvent : IAggregateEvent<TEvent>
{
    EventExplanation Explain(EventEnvelope<TEvent> envelope);
}

The shape is deliberately small:

  • Title — short, headline-style ("Book borrowed", "Member suspended"). Used as a chip in lists.
  • Summary — one sentence ("'Dune' borrowed by user-42, due 2026-05-09").
  • Detail (optional) — multi-line context for a detail view. Pretty rare to need.
  • Actor (optional) — who or what caused this. Avoids the "by null" footgun.
  • Tags (optional) — a small map of categorical metadata (severity, amount, branch). The dashboard renders these as a metadata strip; an audit-log projection can write them to columns.

Explainers run inside the dashboard — they must be pure. No I/O, no DI lookups beyond what's captured at construction time. The same explainer is also called from background subscriptions when projecting; making it impure is a footgun there.

Register ​

csharp
public sealed class BookEventExplainer : IEventExplainer<BookEvent>
{
    public EventExplanation Explain(EventEnvelope<BookEvent> envelope) => envelope.Event switch
    {
        BookAdded a    => new("Book added",    $"'{a.Title}' by {a.Author}",
                              Tags: new Dictionary<string, string> { ["isbn"] = a.Isbn }),
        BookBorrowed b => new("Book borrowed", $"by {b.BorrowerId}, due {b.DueAt:yyyy-MM-dd}",
                              Actor: b.BorrowerId,
                              Tags: new Dictionary<string, string> { ["due"] = b.DueAt.ToString("o") }),
        BookReturned r => new("Book returned", "back on the shelf",
                              Actor: r.BorrowerId),
        _ => new(envelope.Event.GetType().Name, envelope.Event.GetType().Name),
    };
}

builder.Services.AddEventExplainer<BookEventExplainer, BookEvent>();

You register one explainer per event type. The framework picks the explainer for TEvent automatically; without one, a key=value generic explainer takes over (good enough to ship, not good enough to keep).

Where it shows up ​

Dashboard ​

Every event in the journal, the live tail, and the provenance lens uses your explainer. The Title becomes a categorical chip; the Summary becomes the readable line; tags render in the metadata strip on the provenance lens.

Projecting the journal into a dedicated audit log? Use the explainer, not your own string templates. One source of truth.

csharp
public sealed class AuditLogProjection(IDbContext db, IEventExplainer<BookEvent> explainer)
    : ISubscription<BookEvent>
{
    public SubscriptionId SubscriptionId => new("audit-log-book-events");

    public async Task Handle(EventEnvelope<BookEvent> envelope)
    {
        var explanation = explainer.Explain(envelope);
        await db.AuditLog.InsertAsync(new
        {
            envelope.AggregateId,
            envelope.Position,
            envelope.CreatedAt,
            Title    = explanation.Title,
            Summary  = explanation.Summary,
            Actor    = explanation.Actor,
            TagsJson = JsonSerializer.Serialize(explanation.Tags ?? new Dictionary<string, string>()),
        });
    }
}

Now the prose your dashboard shows and the prose stored in your audit log agree by construction. Adding a new event variant only requires updating the switch in one place.

Domain notifications, slack alerts, in-app feeds ​

Same shape, different sink. Inject IEventExplainer<TEvent> wherever you build human-facing strings from events.

Why pattern-match in Explain ​

switch (envelope.Event) against the closed event hierarchy keeps the explainer exhaustive — adding a new variant produces a compiler warning unless you handle it. That is the whole reason explainers live next to the events instead of in the rendering code.

Anti-patterns ​

  • Don't call services from Explain. Take what you need at construction time. The explainer is called per event in subscription hot paths.
  • Don't put HTML or markdown in the strings. The renderer (dashboard, audit DB, slack) decides escaping. The explainer's strings are plain text.
  • Don't make the Summary multi-line. That's what Detail is for. UIs assume Summary fits on one line.
  • Don't omit the explainer for "internal" event types. The dashboard renders them with a key=value fallback that is technically readable and aesthetically rough.

Migration story ​

Adding IEventExplainer<TEvent> later is non-breaking. Until you register one for an event type, the framework's generic explainer renders a Created · Name="hello" style summary. Land the dashboard or audit projection first, layer explainers in over time, starting with the events your team queries most.

流れ — flow.