カスタム例外クラスで意図を伝える、ドメイン固有の失敗を型にする

カスタム例外とは、ドメイン固有の失敗を専用の例外クラスとして定義する手法です。失敗の種類を型で表し、catchでの分岐を明快にするための設計です。

汎用例外だと呼び出し側が区別できない

失敗を全部 \Exception や \RuntimeException で投げていると、呼ぶ側は文字列メッセージを頼りに分岐するしかなくなります。メッセージで分岐するのは脆くて、文言を変えた瞬間に壊れますよね。失敗の種類を型で表すと、catch の段階で意図がはっきりします。

class InsufficientBalanceException extends \RuntimeException {}
class AccountLockedException extends \RuntimeException {}

class Wallet
{
    public function withdraw(int $amount): void
    {
        if ($this->locked) {
            throw new AccountLockedException('口座が凍結されています');
        }
        if ($amount > $this->balance) {
            throw new InsufficientBalanceException('残高不足');
        }
        $this->balance -= $amount;
    }
}

catchを型で絞れば分岐が素直になる

型が分かれていると、呼び出し側は拾いたい失敗だけをピンポイントで拾えます。残高不足は入金を促す、凍結はサポート案内へ、といった分岐がメッセージ解析なしに書けるわけですね。複数の catch を並べられるのも読みやすさに効きます。

try {
    $wallet->withdraw(5000);
} catch (InsufficientBalanceException $e) {
    return response('残高が足りません', 422);
} catch (AccountLockedException $e) {
    return response('サポートへお問い合わせください', 423);
}

共通の基底を挟むとまとめ拾いもできる

ドメインごとに基底例外を一つ用意しておくと、細かく拾いたいときは個別型で、大雑把に拾いたいときは基底型で、と両方に対応できます。付加情報を持たせたいなら名前付きの生成メソッドを用意すると、生成箇所が読みやすくなって気に入っています。

abstract class PaymentException extends \RuntimeException {}

class InsufficientBalanceException extends PaymentException
{
    public static function shortBy(int $lack): self
    {
        return new self("残高が{$lack}円不足しています");
    }
}

// 呼ぶ側は基底で一括、または個別型で細かく
try { /* ... */ }
catch (PaymentException $e) { /* 決済系すべて */ }

まとめ

カスタム例外は、失敗の意味をコードの語彙として残す作業だと思っています。型で表しておけば catch が明快になり、メッセージ頼みの脆い分岐から解放されます。むやみに増やす必要はないですが、呼び出し側が扱いを変えたい失敗については、型を分けておく価値が十分にある気がします。

よくある質問

Q. なぜ汎用の\Exceptionではだめなのですか?
A. 呼ぶ側がメッセージ文字列で分岐するしかなくなり、文言を変えた瞬間に壊れる脆い作りになるからです。

Q. 例外は細かく分けるべきですか?
A. むやみに増やす必要はありません。呼び出し側が扱いを変えたい失敗についてだけ、型を分ける価値があります。

Q. まとめて拾いたいときは?
A. ドメインごとに基底例外を用意しておくと、個別型でも基底型でも拾えて両対応できます。

類似投稿

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です