Files
initiative/docs/adr/002-domain-events-as-plain-data.md
Lukas d653cfe489 Add ADR template and first two architecture decision records
Document the errors-as-values pattern (ADR-001) and domain events
as plain data objects (ADR-002) to capture the reasoning behind
these foundational design choices.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 11:19:54 +01:00

3.3 KiB

ADR-002: Domain Events as Plain Data Objects

Date: 2026-03-25 Status: accepted

Context

Domain state transitions need to communicate what happened (not just the new state) so the UI layer can react — showing toasts, auto-scrolling, opening panels, etc. The project needs an event mechanism that stays consistent with the pure, deterministic domain core.

Decision

Domain events are plain data objects with a type string discriminant. They form a discriminated union (DomainEvent) of 18 event types. Events are returned alongside the new state from domain functions, not emitted through a pub/sub system:

// Example event
{ type: "TurnAdvanced", previousCombatantId: "abc", newCombatantId: "def", roundNumber: 2 }

// Domain function returns both state and events
function advanceTurn(encounter: Encounter): { encounter: Encounter; events: DomainEvent[] } | DomainError

Events are consumed ephemerally by the UI layer and are not persisted.

Alternatives Considered

Class-based events (e.g., class TurnAdvanced extends DomainEvent { ... }) — common in OOP-style domain-driven design. Adds inheritance hierarchies, constructors, and instanceof checks. No benefit here: TypeScript's discriminated union narrowing (switch (event.type)) provides the same exhaustiveness checking without classes. Classes also can't be serialized/deserialized without custom logic.

Event emitter / pub-sub (Node EventEmitter, custom bus, RxJS) — events are broadcast and listeners subscribe. Decouples producers from consumers, but introduces implicit coupling (who's listening?), ordering concerns, and makes the domain impure (emitting is a side effect). Harder to test — you'd need to set up listeners and collect results instead of just asserting on a return value.

Observable streams (RxJS) — powerful for async event processing and composition. Massive overkill for this use case: events are synchronous, produced one batch at a time, and consumed immediately. Would add a significant dependency and conceptual overhead.

No events (just compare old and new state) — the UI could diff states to determine what changed. Works for simple cases, but can't express intent (did HP drop because of damage or because max HP was lowered?) and gets unwieldy as the state model grows.

Consequences

Positive:

  • Events are serializable (JSON-compatible). If the project ever adds undo/redo or event logging, no changes to the event format are needed.
  • TypeScript's switch (event.type) provides exhaustiveness checking — the compiler warns if a new event type is added but not handled.
  • No framework coupling. Events are just data; any consumer (React, a test, a CLI) can process them identically.
  • Domain functions remain pure — events are returned, not emitted.
  • Testing is trivial: assert that result.events contains the expected objects.

Negative:

  • Events are currently consumed and discarded. There is no event log, replay, or undo capability. The architecture supports it, but it's not built.
  • Adding a new event type requires updating the DomainEvent union, which touches a central file. This is intentional (forces explicit acknowledgment) but adds friction.
  • No built-in mechanism for event handlers to communicate back (e.g., "veto this event"). Events are informational, not transactional.