Errors and the happy path

Prefer throwing when expected data is missing, so the body of a function reads as a straight happy path. The exception is caught high up, in the Api handlers and in routing, rather than repeated as a null check at every call site. Mark values that can genuinely be absent with an optional prefix, and do not scatter null checks anywhere else.

Throw when expected data is missing

When a function is asked for data that ought to exist and it is not there, the function throws rather than returning null. A function that fetches exactly one record promises exactly one: its return type is the data object, not a nullable, and when the row is not found it throws DatabaseErrorNoRow. The signature alone tells the caller the value is present, so no null check follows the call.

static function Get(int $memberId): MemberData       // throws DatabaseErrorNoRow if absent
{
    return Database::SelectRow('member', 'MemberID', $memberId, MemberDatabase::fromRow(...));
}

The gain is that a caller writes the happy path only. It calls Get, uses the result, and does not branch on a missing record; if the record is absent the throw carries control away to whoever catches it. See Database layer conventions for the full rule on single-record versus list functions.

Catch exceptions high up

Exceptions are caught in a small number of top-level places, not sprinkled through the code that throws them. There are two such places: the Api layer and the routing render pass. Because the catch lives at the top, everything below it, every handler and every page, is written as a straight line of intent.

In Api handlers

An Api handler codes only the happy path: it reads its inputs, does the work, and returns the JSON it wants. When something is missing or invalid it simply throws, and the Api layer catches the exception and maps its class to an HTTP status and message. A module declares that mapping once with ApiConfig::Error, which ties an exception class to a status and a short plain-text message:

ApiConfig::Post('process', function() {
    ApiInputConfig::String('queue');
    ApiConfig::Guard('QueueApi::Authenticate');
    ApiConfig::Handler('QueueApi::Process');
    ApiConfig::Error('QueueRateLimitError', 429, 'Queue rate limit exceeded');
});

Raising QueueRateLimitError anywhere inside the handler now produces a 429 with that message, with no catch block in the handler itself. This is why handler code carries few or no null checks: rather than checking for a missing record and building an error shape by hand, the handler lets the lookup throw and trusts the central mapping to turn that exception into the right response. See The Api layer for the full endpoint model.

In routing

The routing render pass is the other top-level catch, which keeps page code to a single happy path. A page assumes the conditions it needs and throws when they are not met, rather than guarding every step. The common case is a member page that requires a login: it throws MemberNotLoggedInError, and the top of the render pass catches it and redirects to the login page.

// The page assumes a logged-in member and throws if there is none.
throw new MemberNotLoggedInError();

// The top of the render pass catches it and redirects.
Ui::OverrideRedirect(MemberUrl::Login());

Because that catch lives at the top, the page itself never checks whether a member is logged in before doing its work. It writes what the page should show for a logged-in member and lets the throw handle the rest, in the same spirit as the Api handlers above. See Routing for the render pass.

Name what can be null with an optional prefix

Some values can legitimately be absent, and for those a null is a real, handled case rather than an error. Mark them: a function that may find nothing starts its name with optional and returns TYPE|null, never ?TYPE, so the two possibilities read left to right and match the name. Any variable or data-object field that may hold null carries the same prefix.

// May legitimately find nothing: name and return type both say so.
static function optionalByEmail(string $email): MemberData|null
{
    $where = (object)['MemberEmail' => $email];
    return Database::SelectRowIfExistsWhere('member', $where, MemberDatabase::fromRow(...));
}

The payoff is that a name without optional is guaranteed to be set, so you never null-check it or wonder whether you should. Do not scatter null checks anywhere else: a check belongs only where null is the genuinely handled case that the optional naming marks. Everywhere else, prefer the throw and keep the happy path. See Naming for the prefix rule in full.

Next: Module structure and separation.