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.