
はじめに
開発の中で「例外処理」を実装しなければならないタイミングは、定期的に訪れます。
しかし私は、例外をどのように扱うべきかで度々迷ってしまいます。そして、そのたびにコードを書く手を止めて悩んでいました。
思えば、エンジニアなら誰もが日常的に書いているはずの「例外処理」ですが、なぜかチームを超えて共通化されたベストプラクティスのようなものを見かける機会が少ないように感じます。 そこで今回は、自分なりに調べて辿り着いた「例外の取り扱いに関する、現時点での解」をまとめてみます。
その例外は本当に例外か
私たちが遭遇する「例外」をじっくり分解してみると、大きく2つの性質に分かれていることに気づきます。
① システムに関する本当の意味での例外(True Exception)
ネットワークの遮断、データベースのダウン、外部APIの応答拒否など、プログラム側ではどうしようもない「異常事態」です。これらは開発者が事前にコントロールできない、文字通りの想定外です。
② ドメインロジック的な「失敗」(Domain Result)
「口座の残高が足りない」「ユーザー名が重複している」「パスワードが間違っている」など、ビジネスルール(ドメインロジック)上、十分に起こり得ることが予期されている「想定内の結果」です。これはシステムの異常ではなく、業務フローにおける「分岐」のひとつに過ぎません。
ここで重要なのは、私たちが「例外」だと思い込んで処理しているものの中に、ビジネス上の「結果(Result)」が混ざってしまっている可能性があるということです。
例外というのは、その名の通り「本来想定していない例外的な状況」にのみ使うべきです。処理の「失敗」は、正常に処理が完了した「成功」と同じ地平にある、単なるもう一つの「結果」に過ぎません。
この視点を持つと、いま目の前で扱いたいものが「例外」なのか「結果」なのかを正しく判断できるようになります。結果の中での失敗をうまく取り扱う場合は、ドメインモデリングが非常に有効になります。システム的なものは Exception として扱い、ビジネス上の失敗は ドメイン として扱うのが、設計の基本です。
try-catchを使うケースは稀
この思想を突き詰めていくと、コードの中で try-catch を利用すべきケースは、ごく稀なシチュエーションに限られるのではないかと考えられます。なぜなら、try-catch の多用には明確なデメリットがあるからです。
1. 現代の「GOTO文」になってしまう
例外を投げる(raise/throw)という行為は、関数のシグネチャに現れない「明示的でない副作用」です。 try-catch は関数の実行を途中で遮断し、コールスタックを遡ってどこへでもジャンプできてしまう大域脱出を引き起こします。これは構造化プログラミングで禁止された「GOTO文」と本質的に同じであり、どこで例外が投げられるか追いかけにくく、制御フローを著しく複雑化させます。
2. 例外は多様で、すべてをモデル化するのは不可能
この世に起こりうるすべてのシステムエラーを予測し、それぞれに対して適切な catch ブロックを書き、きれいに仕様化することなど絶対に不可能です。不可能なことに挑戦しようとするからこそ、エラー処理コードが肥大化し、開発者を消耗させます。
3. エラーハンドリングそのものが複雑さを生み出す
名著『A Philosophy of Software Design』では、エラー処理の複雑さについて以下のように指摘されています。
エラーを多用するアプローチはいくつかのバグを捕捉できるかもしれませんが、同時に複雑さも増大させ、それが他のバグを生み出す結果となります。 開発者はエラーを回避または無視するための追加コードを書かなければならず、これがバグの可能性を高めます。 あるいは、追加コードを書くことを忘れてしまい、その場合は実行時に予期しないエラーが投げられる可能性があります。 例外処理の複雑さを排除する最良の方法は、処理すべき例外が存在しないようにAPIを定義することです。 つまり、エラーを定義で消し去るのです。これは冒涜的に聞こえるかもしれませんが、実践においては非常に効果的です。 エラーを多用するアプローチはいくつかのバグを捕捉できるかもしれませんが、同時に複雑さも増大させ、それが他のバグを生み出す結果となります。
エラー処理という「追加コード」を書けば書くほど、そのコード自体が複雑性を生み、新たなバグの温床になります。「エラーが起きたらどうするか」を後からコードで補強するのではなく、「そもそもエラー処理を書かなくていい(ただ落ちればいい、あるいはエラーにならない)設計」に最初から落とし込むべきなのです。
システム的な例外は「Fail Fast」で即座に落とせ
では、モデル化できない「本当の意味でのシステム例外」はどう扱うべきでしょうか? その答えが、Fail Fast(早期潰え)という考え方です。下手に try-catch でキャッチして揉み消すくらいなら、すぐに実行を停止させてシステムを落としたほうがいいのです。
この思想を完璧に裏付けてくれるのが、名著『達人プログラマー』の教えです。
「あり得ない」と思われる事象がコードの実行中に発生した場合、その時点でプログラムはもはや実行可能なものとはなっていないのです。 その時点以降に処理された内容は疑わしいものであるため、速やかに停止させてください。通常の場合、停止したプログラムのほうが、障害によって中途半端に動作しているプログラムよりもダメージは少ないはずです。
壊れた状態で中途半端に動かし続けると、データベースのデータを破損させたり、不整合を見せたりと、さらに傷口を広げることになります。「下手に隠蔽せず、即座に500エラーで落とす」ことこそが、最もシステムを安全に保護するアプローチになります。
ドメインの「失敗」は Result 型で値として扱う
システム例外を Fail Fast で完全に切り離した後に残る「ドメインロジック的な失敗(残高不足など)」に対しては、例外(raise/throw)ではなく Result 型 を利用して解決します。
Result 型(言語によっては Either 型や Success/Failure など)は、エラーを例外として投げるのではなく、「成功した値」または「失敗した理由」を格納したひとつの「値」として戻り値で返す仕組みです。
Pythonにおける具体的なアプローチ
(この問題を意識したのがPythonで書いていたときだったので採用します)
他言語の Result 型のプラクティスでは、Railway Oriented Programming(ROP / 関数型のようにメソッドチェーンでエラーを流していく手法) がよく使われます。しかし、Pythonでこれをやろうとするとコードが複雑化し、言語の良さが消えてしまいます。
Python 3.10以降であれば、大がかりなROPを持ち込まずとも、標準の Union型(|) と パターンマッチ(match-case) を組み合わせるだけで、シンプルかつ強力に「値のエラーハンドリング」が実現します。
from dataclasses import dataclass
@dataclass
class UserAccount:
balance: int
@dataclass
class InsufficientFunds:
message: str
# ROP(メソッドチェーン)は使わず、単に「正常」か「異常」の型を値として返す
def withdraw(account_id: str, amount: int) -> UserAccount | InsufficientFunds:
account = db.get(account_id)
if account.balance < amount:
# 失敗を例外として投げず、「値」として返す
return InsufficientFunds("残高が不足しています")
account.balance -= amount
return account
値として扱う(Result型/Union型)メリット 無理に複雑な仕組み(ROP)に頼らなくても、単に「型を値として返す」だけで以下の絶大なメリットを享受できます。
try-catch の完全なる撲滅
エラーはただの「戻り値(値)」として返ってくるため、処理がいきなり別の場所にジャンプ(大域脱出)しません。コードは常に上から下へ、直線的に読めるようになります。
静的型チェッカーによる「ハンドリングの強制」
Pythonの例外は、関数が何を投げるかを型システム(シグネチャ)で強制できません。しかし、戻り値を Union 型にしておけば、Mypy や Pyright などの静的解析ツールが「エラーの可能性」を明確に検知し、呼び出し側にハンドリングを強制してくれます。
パターンマッチによるエレガントな分岐
受け取った側は、match-case 文を使って、正常系とドメインエラーをフラットに、美しく分岐させることができます。
res = withdraw("acc_123", 1000)
match res:
case UserAccount() as account:
print(f"引き出し成功。現在の残高: {account.balance}")
case InsufficientFunds() as error:
print(f"ビジネスルール違反: {error.message}")
7. 発展:他の言語におけるエラーハンドリングの思想
ここまでPythonにおける「システム例外とドメインの失敗の分離」について考えてきましたが、視点を外に広げてみると、プログラミング言語ごとにこの2つの扱いに対する「思想(哲学)」が全く異なることが分かり、非常に興味深いです。
Goにおける例外の扱い方
GOTO文を徹底排除し、すべてを「値」に寄せる設計 Go は一般的な try-catch という仕組みをあえて持たない言語です。Goにおけるエラーハンドリングの哲学は、徹底的な「明示性」と「制御フローの単純化」にあります。
True Exception(システム例外): panic という仕組みを使い、本当にプログラムがこれ以上実行を継続できない「致命的な異常事態」のときだけ、Fail Fast で即座にプロセスを落とします。
Domain Result(ドメインの失敗): それ以外のすべての失敗(データベースのエラーも、ビジネスルール違反も)は、すべて error という通常の戻り値(値)として関数から返します。
呼び出し側は、お馴染みの以下のコードを愚直に書き、エラーという「値」を地道に上に返していきます。
res, err := withdraw(accountID, amount)
if err != nil {
return err
}
例外によるGOTO的なジャンプを完全に撲滅し、常に「上から下へ」読める直線的な制御フローを維持する設計です。
ただし、Go 流のトレードオフとして、True Exception と Domain Result の区別が型システム上には現れず、すべて一つの error という型に押し込まれているという特徴があります。そこをどう綺麗に切り分けるかは、開発者の規律や実装の工夫に委ねられています。
Rustにおける例外の扱い方
一方で Rust は、本記事で紹介した思想(システム例外の Fail Fast と、ドメインエラーの Result型による値の扱い)が、最初から言語の標準機能(プリミティブ)として完璧に組み込まれている、非常に美しい世界を持っています。
Rust では Result<T, E> という型を使って、失敗する可能性を一等市民として扱います。
True Exception(システム例外): unwrap() や expect() などで、本当に落としてよい場所だけを明示的に panic!(強制終了)させます。中途半端に動かさず、その場で安全に Fail Fast させる仕組みです。
Domain Result(ドメインの失敗): 正常系は Ok(T)、ドメインエラーは Result<T, DomainError> のように明確に型として表現します。
Rust の特に美しい点は、? 演算子という糖衣構文を持っていることです。
// エラーが起きたら、このレイヤーでは処理せずそのまま上のレイヤーへ伝播する let account = withdraw(account_id, amount)?;
これにより、Go のように毎回 if 文を手書きする煩わしさを排除しつつ、制御フローを一切ジャンプさせることなく、型安全に「失敗という値」を上流へと運ぶことができます。そして、最終的なサービス境界(APIのコントローラー層など)で、その型に応じた HTTP ステータスコードへ綺麗にマッピングします。
おわりに
今回の記事は、実際のプロジェクトの中でエラー処理を書いていて、自分の手がピタッと止まってしまった苦い実体験から生まれました。
「このエラーはどう扱うのが正解なんだろう……」と悩み、社内の詳しい先輩に訊いてみたことをきっかけに、自分なりに名著や先達の知見を調べていく中で、ようやく「例外」と「結果」の境界線が自分の中で綺麗に整理できたのが、今回まとめた現時点での私の考えになります。
思い返すと、日頃の開発ではここまでエラーハンドリングについて深く悩むことはあまりありませんでした。ではなぜ、今回これほど考える機会になったのか。
その理由は、利用していたPythonのHTTPクライアントライブラリ(httpx など)が、400や500のエラーリクエストをデフォルトでは例外(Exception)として取り扱ってくれない仕様だったからでした。
ライブラリが例外をスローしてくれなかったがために、本来システム例外としてその場で落とすべきものがドメインロジックまで突き抜けてしまい、結果として「失敗しているのに200 OKが返る」という挙動に直面したのです。「例外を、例外として正しく発生させる(Fail Fastする)」ことの重要性を、身をもって知る最高の教材となりました。
参考文献
例外処理とどう使い分ける?Result型を使ったエラー設計 #burikaigi - Speaker Deck
🎓 学生の方へ | 1day インターンイベント開催!
Speeda ソフトウェアエンジニア職 のインターンイベントを開催します!
Speedaプロダクトチームではアジャイル開発手法の一種であるXP(エクストリームプログラミング)を実践しており、今回のインターンではプラクティスの中でも特徴的なペアプログラミングをみなさんに体験していただきます!
チーム開発を更に深めることができるペアプログラミングの経験は我々の文化を体験することや、今後のエンジニアとしての活動の幅を広げるきっかけになると思います。
少しでもご興味をお持ちいただけた方は、ぜひお気軽にエントリーください。