Skip to content

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.