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.