Skip to content

0008. Separate expected outcomes, infrastructure failures and bugs

  • Status: accepted
  • Date: 2026-10-26

The analysis (chapter 18) asks for three families of errors and forbids silent catch blocks. In TypeScript, thrown values are untyped, so expected business outcomes (“no availability for these dates”) are easy to forget at the call site, while wrapping every failure in a result type makes infrastructure code noisy.

  1. Expected outcomes of a use case are returned, not thrown: a use case returns Result<T, E> from core/shared, where E is a union of plain error objects with a stable code (for example { code: "inventory.capacity_exceeded", date, available }). The compiler forces callers to handle them. The API maps each code to an RFC 9457 problem type with a stable type URI.
  2. Infrastructure failures are thrown as InfrastructureError subclasses that state whether a retry may succeed (retryable), whether the operation may have taken effect anyway (outcomeUnknown, for a commit or a request whose answer was lost) and, when known, after how long. Adapters convert library errors into them at the boundary. The job runner decides on retries from these flags only.
  3. Bugs (violated invariants, impossible states) throw plain errors through assert-style helpers. They stop the operation, are logged with context and are never caught to continue.

A catch must rethrow, convert to one of the three families, or handle a documented case; Biome’s rules against empty blocks and a code review rule enforce it.

  • Use case signatures document their business failures, and the API’s error catalog is generated from the codes.
  • Retry decisions are consistent across jobs, because every adapter reports retryability the same way.
  • Developers must choose the family of each error; the rule of thumb is “could a correct program, with working infrastructure, hit this?” If yes, it is an expected outcome.
  • Exceptions for everything. Rejected: business outcomes would be invisible in signatures.
  • Result types everywhere, including infrastructure (as in Effect or neverthrow). Rejected: heavy for adapters and unfamiliar to most contributors; it can be reconsidered if the codebase outgrows this split.

Amendment: 2026-10-29 — Channex adapters

Section titled “Amendment: 2026-10-29 — Channex adapters”

The Channex adapters in packages/channex convert library errors at the boundary through one helper, translateChannexFailures:

  • Only a ChannexError from the client is Channex’s failure. It becomes the port’s error with the client’s retryable, outcomeUnknown and retryAfterMs, and never with the upstream body, path or cause.
  • Anything else thrown during a call, such as a local check, a filter violation or a bug, passes unchanged and stops the operation.
  • An ARI write returns a send outcome for every failure; one that is not Channex’s leaves the write’s outcome unknown.
  • A connection check returns its expected outcomes: request.invalid for a refused key or property (401, 403, 404), server.unavailable for any other Channex failure.
  • An ARI settings read returns its expected outcome, channex.currency_unsupported, for a property currency GuestCraft cannot price in. The ARI pump pauses a property on a retryable failure; on any other failure, a bug included, or on that outcome, it stops the property until an administrator repairs it, resyncs it or reconnects Channex, and health shows why (ADR 0034). On 2026-10-30 this replaced a temporary exception that reported every ARI read failure as retryable, because the pump could not yet stop a property.
  • An administrator’s ARI mapping reads (rate-plan discovery, a mapping replacement) return their expected outcome, channex.mapping_read_refused (409), for a non-retryable Channex failure: a refused key or property, or an unusable answer. The use case converts it, so the API routes never inspect adapter errors; a retryable failure stays server.unavailable (503) and a bug server.internal (500).