diff --git a/docs/adr/000-template.md b/docs/adr/000-template.md new file mode 100644 index 0000000..a85346a --- /dev/null +++ b/docs/adr/000-template.md @@ -0,0 +1,20 @@ +# ADR-NNN: [Title] + +**Date**: YYYY-MM-DD +**Status**: accepted | superseded | deprecated + +## Context + +What is the problem or situation that motivates this decision? + +## Decision + +What did we decide, and why? + +## Alternatives Considered + +What other approaches were evaluated? + +## Consequences + +What are the trade-offs — both positive and negative? diff --git a/docs/adr/001-errors-as-values.md b/docs/adr/001-errors-as-values.md new file mode 100644 index 0000000..5a8f6f1 --- /dev/null +++ b/docs/adr/001-errors-as-values.md @@ -0,0 +1,45 @@ +# ADR-001: Errors as Values, Not Exceptions + +**Date**: 2026-03-25 +**Status**: accepted + +## Context + +Domain functions need to communicate failure (invalid input, missing combatant, violated invariants). The standard JavaScript approach is to throw exceptions, but thrown exceptions are invisible to TypeScript's type system — nothing in a function's signature tells the caller that it can fail or what errors to expect. + +This project's domain layer is designed to be pure and deterministic. Thrown exceptions break both properties: they alter control flow (a side effect) and make the function's output unpredictable from the caller's perspective. + +## Decision + +All domain functions return `SuccessType | DomainError` unions. `DomainError` is a plain data object with a `kind` discriminant, a machine-readable `code`, and a human-readable `message`: + +```typescript +interface DomainError { + readonly kind: "domain-error"; + readonly code: string; + readonly message: string; +} +``` + +Callers check results with the `isDomainError()` type guard before accessing success data. Errors are never thrown in the domain layer (adapter-layer code may throw for programmer errors like missing providers). + +## Alternatives Considered + +**Thrown exceptions** — the JavaScript default. Simpler to write (`throw new Error(...)`) but error paths are invisible to the type system. The caller has no compile-time indication that a function can fail, and `catch` blocks lose type information about which errors are possible. Would also make domain functions impure. + +**Result wrapper types** (e.g., `neverthrow`, `ts-results`) — formalizes the pattern with `.map()`, `.unwrap()`, `.match()` methods. More ergonomic for chaining operations, but adds a library dependency and a layer of indirection. The project's use cases are simple enough (call domain function, check error, save or return) that raw unions are sufficient. + +**Validation libraries** (Zod, io-ts) — useful for input parsing but don't cover domain logic errors like "combatant not found" or "no previous turn". Would only address a subset of the problem. + +## Consequences + +**Positive:** +- Error handling is compiler-enforced. Forgetting to check for an error produces a type error when accessing success fields. +- Domain functions remain pure — they return data, never alter control flow. +- Error codes are stable, machine-readable identifiers that UI code can match on. +- Testing is straightforward: assert the return value, no try/catch in tests. + +**Negative:** +- Every call site must check `isDomainError()` before proceeding. This is slightly more verbose than a try/catch that wraps multiple calls. +- Composing multiple fallible operations requires manual chaining (check error, then call next function). A Result wrapper would make this more ergonomic if the codebase grows significantly. +- Contributors familiar with JavaScript conventions may initially find the pattern unfamiliar. diff --git a/docs/adr/002-domain-events-as-plain-data.md b/docs/adr/002-domain-events-as-plain-data.md new file mode 100644 index 0000000..9d1a534 --- /dev/null +++ b/docs/adr/002-domain-events-as-plain-data.md @@ -0,0 +1,46 @@ +# 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.