Error Handling¶
Exception, or result? This document is the rule the rest of the folder already assumes —
PersistenceResult, InteractionStartResult, ItemLoadException,
ModuleDependencyException are all instances of the pattern defined here. (An earlier draft
of this line also named InteractionResult and TransferResult — neither exists as written:
the Interaction framework's actual bespoke result is InteractionStartResult
(Code/Interaction/InteractionStartResult.cs), and IInventoryService.TransferAsync
(Code/Inventories/IInventoryService.cs) uses the plain Result, not a bespoke type, per §2's own
"only grow a bespoke type when the extra cases are load-bearing" rule.)
1. Three kinds of failure¶
| Kind | Example | Handling |
|---|---|---|
| Programmer error | Null argument, invalid enum value, calling a method before initialization | Throw. Never expected to happen in correct code; a bug, not a condition to design around. |
| Expected gameplay failure | Can't afford the door, inventory full, target out of range | Return a result type. Happens constantly in correct code and is not exceptional. |
| Unrecoverable startup failure | Missing module dependency, unresolvable item parent, invalid configuration | Throw, and halt boot. Loud and immediate is strictly better than starting in a broken state. |
This is Standards/00_CODING_STANDARDS.md §3 made specific: "Player input is never a programmer error... prefer returning a result type over throwing for expected failures."
2. The result shape¶
One generic shape, reused rather than reinvented per subsystem:
namespace Applejack.Core;
/// <summary>
/// The outcome of an operation that can fail for expected, non-exceptional reasons. See
/// Documentation/Architecture/13_ERROR_HANDLING.md.
/// </summary>
public readonly struct Result
{
public bool Succeeded { get; }
/// <summary>Player-facing, or operator-facing for non-player-triggered operations. Never a stack trace.</summary>
public string? FailureMessage { get; }
public static Result Success() => new(true, null);
public static Result Failure(string message) => new(false, message);
}
public readonly struct Result<T>
{
public bool Succeeded { get; }
public T? Value { get; }
public string? FailureMessage { get; }
public static Result<T> Success(T value) => new(true, value, null);
public static Result<T> Failure(string message) => new(false, default, message);
}
Subsystem-specific result types (PersistenceResult<T>, TransferResult) exist instead of
using the generic Result<T> everywhere only when they carry more than
success/value/message — PersistenceResult distinguishes NotFound from Corrupt from
TransportFailure (06_PERSISTENCE.md §1) because a
caller's correct response genuinely differs between those. Reach for the plain Result<T>
first; only grow a bespoke type when the extra cases are load-bearing for callers, not because
it "might be useful".
3. Player-facing failure¶
An expected failure's FailureMessage is written to be shown to a player — plain language,
no implementation detail:
// ✗
Result.Failure("InsufficientFundsException in DebitAsync")
// ✓
Result.Failure("You can't afford this — it costs $150 and you have $40.")
The RPC handler that produced the failure is responsible for delivering it to the requesting client (a toast, an inline error, whatever the calling UI expects) — see 11_UI_ARCHITECTURE.md §5. This is deliberately separate from 12_LOGGING_AND_DIAGNOSTICS.md: "the player tried to buy a door they couldn't afford" is not an operator-log-worthy event by default, and routing every expected failure through the log would drown the entries that matter.
4. Exceptions: what's expected¶
Programmer-error and startup-failure exceptions are ordinary, specific, and never caught to paper over the underlying bug:
public void Transfer(ItemInstance item, IInventory from, IInventory to)
{
ArgumentNullException.ThrowIfNull(item);
ArgumentNullException.ThrowIfNull(from);
ArgumentNullException.ThrowIfNull(to);
// ... the actual transfer, which returns TransferResult for the expected-failure cases
}
catch { } swallowing an exception silently is
forbidden without exception. A caught
exception is either handled meaningfully (converted to a Result at a boundary that expects
one, per §5) or logged and rethrown — never discarded.
5. The boundary: where exceptions become results¶
Exceptions are for what's exceptional; at the edge of the system — an [Rpc.Host] handler, a
GameModule lifecycle method that legitimately might fail on bad input from elsewhere — an
unexpected exception is caught once, logged with full detail via
12_LOGGING_AND_DIAGNOSTICS.md, and turned into whatever
failure surface that boundary presents outward (an ignored RPC, a startup abort). This
boundary conversion happens in exactly one place per boundary kind — the event bus's dispatch
loop (04_EVENT_BUS.md §4) is one concrete instance of
this pattern, not a special case invented separately.
6. Startup failures name themselves¶
Every exception type used for an unrecoverable startup failure —
ModuleDependencyException, ItemLoadException, ConfigurationValidationException — carries
enough detail in its message to fix the problem without attaching a debugger: which module,
which asset, which property, and why. "Startup failed" is useless; "Module 'Inventory' depends
on 'Items', which is not registered" is actionable. This is the direct fix for
D-08 and D-04,
both of which failed silently in the original — the new rule is not just "fail", it is "fail
loudly and specifically".
6.1 The plugin exception — startup failures that don't stop startup¶
ModuleDependencyException and a lifecycle exception from GameModule.Boot are both,
deliberately, unrecoverable: a misconfigured first-party module should stop the server.
Plugins (Applejack.Core.Plugins) are the one deliberate exception to §6's rule. The same
exception shapes — a missing/cyclic dependency, a lifecycle stage throwing — occur, but for a
plugin they are caught, logged with the same "name the problem specifically" discipline,
and turned into a PluginState.Disabled or PluginState.Failed catalog entry rather than
propagated. The server keeps booting with that plugin (and anything depending on it)
quarantined. See 23_PLUGIN_SYSTEM.md §5 and
ADR-0010 for why: a plugin author's mistake must never
be able to take a server down the way a first-party module's rightly can.
7. Testing¶
Every result-returning method has tests for its failure paths, not just its success path — Standards/03_TESTING_STANDARDS.md §4: "'You cannot afford this' is behaviour and needs a test as much as the success case does." Every named startup-failure exception has a test constructing the exact broken input (unresolvable parent, missing dependency, cycle) and asserting the specific exception and its message content, not just that some exception was thrown.