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>
46 lines
2.9 KiB
Markdown
46 lines
2.9 KiB
Markdown
# 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.
|