カスタム例外クラスで意図を伝える、ドメイン固有の失敗を型にする
カスタム例外とは、ドメイン固有の失敗を専用の例外クラスとして定義する手法です。失敗の種類を型で表し、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. ドメインごとに基底例外を用意しておくと、個別型でも基底型でも拾えて両対応できます。