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

47 lines
3.3 KiB
Markdown

# 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:
```typescript
// 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.