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>
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.eventscontains 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
DomainEventunion, 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.