Skip to content

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 class
  • IllegalStateError — signals a command rejection (extends EmmettError)
  • Command<Type, Payload> — command envelope
  • Event<Type, Payload> — event envelope
  • CommandHandler — wiring function that composes decide and evolve

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:

LayerKeywordGenerated Error TypeExtendsSource
Type: TypeNameTypeScript compile errorN/AStatic
ValuevalidateValidationErrorEmmettError (Emmett built-in)Smart Constructor
Business RulerequireRejectionErrorErrordecide() Result
PostconditionensurePostconditionViolationIllegalStateError (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 decide generators.
  • Testing decide requires no Emmett dependency. Tests assert on result.success, result.value, and result.error without try/catch.
  • Smart Constructors use Emmett’s built-in ValidationError directly rather than introducing a parallel type. The HTTP 400 semantic is inherited without additional mapping.
  • PostconditionViolation extends IllegalStateError is instanceof-distinguishable from business rule RejectionError at the Emmett runtime boundary. Observability tooling can differentiate constraint layer violations.
  • HTTP error code mapping is deferred entirely to the adapter layer. The pure decide function 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 toEmmettDecide in 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: Result in generated code, throw in Emmett runtime. Developers working across the boundary must be aware of the translation point.