ADR-018: Result-Based Decider with Emmett Adapter
Status
Accepted — 2026-02-22
Context
Emmett’s decide function returns Event | Event[] and throws on rejection using IllegalStateError or EmmettError. The Weltenwanderer compiler’s design principle is that domain logic is pure specification — generated deciders should be functional and testable without framework coupling.
The generated code must integrate with Emmett’s CommandHandler({ evolve, initialState }) API, which expects a decide function that throws on failure. Testing throw-based code requires try/catch boilerplate and loses the ability to pattern-match on error types in a type-safe way.
Emmett exports the following types relevant to code generation:
EmmettError— base error classIllegalStateError— signals a command rejection (extendsEmmettError)Command<Type, Payload>— command envelopeEvent<Type, Payload>— event envelopeCommandHandler— wiring function that composesdecideandevolve
The generated decider sits at the intersection of two constraints: it must conform to Emmett’s runtime contract (throw on rejection) while remaining testable and target-agnostic in isolation.
Decision
Generate decide() returning Result<Event[], RejectionError> — pure, no throws. Generate a thin toEmmettDecide() adapter in the wiring file that converts Err to throw new IllegalStateError(...).
Error Type Hierarchy
The four constraint layers from ADR-003 map to distinct generated error types:
| Layer | Keyword | Generated Error Type | Extends | Source |
|---|---|---|---|---|
| Type | : TypeName | TypeScript compile error | N/A | Static |
| Value | validate | ValidationError | EmmettError (Emmett built-in) | Smart Constructor |
| Business Rule | require | RejectionError | Error | decide() Result |
| Postcondition | ensure | PostconditionViolation | IllegalStateError (Emmett built-in) | Adapter |
ValidationError is the Emmett built-in at HTTP status 400. Smart constructors return it directly rather than defining a parallel type. PostconditionViolation extends IllegalStateError so it is instanceof-distinguishable from business rule rejections at the Emmett boundary.
Result Type
Emmett does not export a Result type. The generator ships a minimal inline definition:
type Result<T, E> = | { success: true; value: T } | { success: false; error: E };
function Ok<T>(value: T): Result<T, never> { return { success: true, value };}
function Err<E>(error: E): Result<never, E> { return { success: false, error };}Six lines. No external dependency. The shape is sufficient for exhaustive pattern matching via result.success.
Generated Decider Signature
function decide( command: RegistrationCommand, state: RegistrationState,): Result<RegistrationEvent[], RejectionError> { // guard evaluation, event construction}decide is exported for direct testing. It has no dependency on Emmett at all.
Adapter Pattern
function toEmmettDecide( fn: typeof decide,): (command: RegistrationCommand, state: RegistrationState) => RegistrationEvent | RegistrationEvent[] { return (command, state) => { const result = fn(command, state); if (!result.success) { throw new IllegalStateError(result.error.message); } return result.value; };}The adapter is generated in the wiring file, not in the decider file. CommandHandler receives toEmmettDecide(decide).
Wiring
export const RegistrationHandler = CommandHandler({ decide: toEmmettDecide(decide), evolve, initialState,});Alternatives Considered
Direct Emmett throw-based decide
Generate decide that throws IllegalStateError directly on guard failure. Simpler: zero adapter code, conforms directly to Emmett’s convention.
Rejected for two reasons. First, it couples generated domain logic to Emmett’s error handling contract. Testing requires try/catch blocks, which obscures assertion intent. Second, future code generation targets — Axon, raw PostgreSQL event store, Kafka consumer — would require entirely new decide generators rather than new adapters against a stable pure function.
Either monad from fp-ts or Effect
Use a full-featured functional programming library for the Result type.
Rejected. Adds a non-trivial transitive dependency for a type that is six lines of code. The generated output becomes dependent on a library that the user may not otherwise need or want. The minimal inline Result type is self-contained and has no upgrade surface.
Return union Event[] | RejectionError without a wrapper
Return RegistrationEvent[] | RejectionError directly from decide.
Rejected on type-safety grounds. A RejectionError instance cannot be distinguished from an empty event array at the type level without runtime instanceof checks scattered through calling code. The discriminated union ({ success: true; value: T } | { success: false; error: E }) provides exhaustive narrowing through result.success.
Consequences
Positive
- Generated deciders are target-agnostic. Future code generation targets require only new adapter functions, not new
decidegenerators. - Testing
deciderequires no Emmett dependency. Tests assert onresult.success,result.value, andresult.errorwithout try/catch. - Smart Constructors use Emmett’s built-in
ValidationErrordirectly rather than introducing a parallel type. The HTTP 400 semantic is inherited without additional mapping. PostconditionViolation extends IllegalStateErroris instanceof-distinguishable from business ruleRejectionErrorat the Emmett runtime boundary. Observability tooling can differentiate constraint layer violations.- HTTP error code mapping is deferred entirely to the adapter layer. The pure
decidefunction carries no knowledge of HTTP semantics.
Negative
- The adapter adds approximately 10 lines of generated code per decider. For a domain with many deciders, this is mechanical overhead with no domain expressiveness.
- Users debugging Emmett runtime behavior will see
toEmmettDecidein the call stack between the framework and the domain function. This is one additional frame with no logic, but it is present. - Two representations of the same truth exist simultaneously:
Resultin generated code,throwin Emmett runtime. Developers working across the boundary must be aware of the translation point.