閉じる

【Python】例外クラス自作入門:エラー原因を明確にする設計術

Pythonでのエラー処理は、コードの信頼性と読みやすさを大きく左右します。

標準の例外クラスだけでも動くコードは書けますが、運用フェーズやチーム開発になると、エラー原因が分かりにくくデバッグに時間がかかりがちです。

本記事では、「なぜ例外クラスを自作すべきか」から「実務レベルの設計術」までを体系的に解説し、実践的なサンプルコードと図解を交えながら、メンテナブルな例外設計の考え方を身につけていただきます。

Pythonで例外クラスを自作するメリット

なぜPythonでカスタム例外クラスが必要か

PythonにはValueErrorTypeErrorなど多くの標準例外がありますが、実際の業務ロジックでは「何が原因で」「どの処理が」「どのように失敗したのか」をより具体的に表現したくなります。

たとえばユーザー登録処理であれば、次のような質問に答えられるエラー設計が望ましいです。

ユーザー入力が不正だったのか、外部APIが落ちているのか、DBの一意制約違反なのか。

それぞれの状況で同じValueErrorを投げてしまうと、ログを見ても原因の切り分けに時間がかかります。

そこでカスタム例外クラス(自作の例外クラス)を導入すると、以下のような利点があります。

  • エラー原因が名前で伝わるため、ログを一目見ただけでおおよその問題が分かる
  • ドメインごとに例外を分けてcatchできるため、復旧処理やリトライ戦略を柔軟に設計できる
  • テストコードで特定の例外を期待することで、失敗パターンの振る舞いを明示的に検証できる

このように、カスタム例外は単なる「エラーの種類追加」ではなく、システムの可観測性と保守性を高めるための設計ツールと捉えると良いです。

標準例外との違いと使い分け

標準例外は、Python言語仕様に近いレベルのエラーを表現するのに適しています。

たとえばTypeErrorIndexErrorのような「プログラミング上のミス」や、「想定外の型・インデックス」に対するエラーです。

一方で、ビジネスロジックやアプリ固有のルール違反については、カスタム例外を使った方がはるかに分かりやすくなります。

代表的な使い分けの方針を表にまとめます。

観点標準例外を使うべきケースカスタム例外を使うべきケース
性質言語レベル・ライブラリレベルの失敗アプリ固有のドメインエラー
TypeError, ValueError, FileNotFoundErrorInvalidOrderStateError, DomainValidationError
対応多くはバグとして修正対象多くは想定内エラーとして処理分岐
テスト「発生しないこと」を期待することが多い「発生すること」を期待するテストも多い

原則として、言語仕様に近いエラーは標準例外、ビジネスルール由来のエラーはカスタム例外で表現すると整理しやすくなります。

カスタム例外クラスの基本設計

Exceptionを継承した最小限のカスタム例外

Pythonの例外クラスは、BaseExceptionをルートとしたクラス階層を持ちます。

通常のアプリケーションでは、そのサブクラスであるExceptionを継承して自作例外を定義します。

最小限のカスタム例外クラスは、次のように非常にシンプルです。

Python
# 最小限のカスタム例外クラスの例

class MyAppError(Exception):
    """アプリ共通の基底例外クラス"""
    pass


def do_something():
    # 何らかのアプリ固有のエラーが起きたと仮定
    raise MyAppError("アプリで処理できない状態が発生しました")


if __name__ == "__main__":
    try:
        do_something()
    except MyAppError as e:
        # カスタム例外として捕捉できる
        print(f"カスタム例外を捕捉しました: {e}")
実行結果
カスタム例外を捕捉しました: アプリで処理できない状態が発生しました

このようにアプリ固有の基底例外クラスを1つ用意しておくと、後で説明するドメインごとの例外階層を整理しやすくなります。

エラー原因を表す名前の付け方

カスタム例外の効果は、ほぼ名前の分かりやすさで決まると言っても過言ではありません。

名前を付けるときは、次の3つの観点を意識すると整理しやすくなります。

1つ目は「何が」です。

対象となるドメインオブジェクトや処理対象を表します。

たとえばUser、Order、Paymentなどです。

2つ目は「どう問題なのか」です。

NotFound、Invalid、Timeout、Conflictなど、状態や失敗の種類を示します。

3つ目は「どの文脈で」です。

同じ「NotFound」でも、DB検索なのか外部APIなのかで意味が変わるので、Repository、API、Cacheなどを付けることがあります。

これらを組み合わせると、次のような例外名が自然に導けます。

  • InvalidUserInputError
  • OrderNotFoundError
  • PaymentAPITimeoutError

「Error」で終わる命名に統一することで、クラス一覧を見たときに例外クラスだとすぐ判別でき、IDEでの検索もしやすくなります。

メッセージ設計と属性の持たせ方

カスタム例外には、単にエラーメッセージ文字列だけを持たせる方法と、構造化された情報を属性として持たせる方法があります。

運用フェーズを意識すると、後者の方がログやモニタリングとの相性が良くなります。

次の例では、codeuser_idを属性として持つバリデーション例外を定義しています。

Python
class ValidationError(Exception):
    """入力値バリデーションで発生する例外"""

    def __init__(self, message: str, *, code: str | None = None, field: str | None = None):
        # 親クラス(Exception)のコンストラクタにもメッセージを渡す
        super().__init__(message)
        # 独自の属性を追加
        self.code = code
        self.field = field


def register_user(name: str):
    if not name:
        # フィールド名とエラーコードを付与して例外を投げる
        raise ValidationError(
            "ユーザー名は必須です",
            code="REQUIRED",
            field="name",
        )
    print(f"ユーザー登録成功: {name}")


if __name__ == "__main__":
    try:
        register_user("")
    except ValidationError as e:
        # ログ出力などで属性を活用できる
        print(f"[ValidationError] message={e}, code={e.code}, field={e.field}")
実行結果
[ValidationError] message=ユーザー名は必須です, code=REQUIRED, field=name

人間が読むメッセージ(自然文)と、機械が処理しやすいコードや属性を両方持たせておくと、ログ集計やUIエラーメッセージの切り替えなどに応用しやすくなります。

実践的な例外クラスの作り方と使い方

ドメインごとの例外クラス階層を設計する

大規模なアプリケーションでは、例外クラスが増えてきます。

その際に階層構造を意識せずに増やすと、後から捕捉ロジックが複雑化してしまいます。

そこでおすすめなのが、次のような層で考える設計です。

  • アプリ全体の基底例外MyAppError
  • ドメイン層の基底例外DomainError
  • インフラ層の基底例外InfrastructureError
  • さらにその下に具体的なUserErrorAPIErrorなど

簡易的なサンプルコードで見てみます。

Python
class MyAppError(Exception):
    """アプリケーション共通の基底例外"""
    pass


class DomainError(MyAppError):
    """ドメインロジック関連の例外"""
    pass


class InfrastructureError(MyAppError):
    """外部システム・インフラ関連の例外"""
    pass


class UserNotFoundError(DomainError):
    """ユーザーが存在しない場合の例外"""
    pass


class PaymentAPIError(InfrastructureError):
    """決済APIとの通信で発生する例外"""
    pass


def get_user(user_id: int):
    raise UserNotFoundError(f"ユーザーが見つかりません: user_id={user_id}")


def charge_payment(user_id: int, amount: int):
    raise PaymentAPIError(f"決済APIエラーが発生しました: user_id={user_id}, amount={amount}")


if __name__ == "__main__":
    try:
        get_user(1)
    except DomainError as e:
        print(f"[DomainError] {e}")

    try:
        charge_payment(1, 1000)
    except InfrastructureError as e:
        print(f"[InfrastructureError] {e}")
実行結果
[DomainError] ユーザーが見つかりません: user_id=1
[InfrastructureError] 決済APIエラーが発生しました: user_id=1, amount=1000

このように層ごとの基底例外を用意しておくと、層単位で一括ハンドリングしやすくなり、アーキテクチャの分離にも役立ちます。

バリデーションエラー用の例外クラス

入力値の検証(バリデーション)は、多くのアプリで頻繁に行われます。

バリデーションエラーをValueErrorで済ませてしまうと、どのフィールドの、どのルールに違反したのかが分かりにくくなります。

そこで専用のバリデーション例外クラスを用意し、必要な情報をまとめて渡す設計が有効です。

Python
class FieldError:
    """単一フィールドのバリデーションエラー情報を保持するクラス"""

    def __init__(self, field: str, message: str, code: str | None = None):
        self.field = field
        self.message = message
        self.code = code

    def __repr__(self) -> str:
        return f"FieldError(field={self.field!r}, message={self.message!r}, code={self.code!r})"


class ValidationError(Exception):
    """複数フィールドのバリデーションエラーをまとめて扱う例外"""

    def __init__(self, errors: list[FieldError]):
        messages = ", ".join(f"{e.field}: {e.message}" for e in errors)
        super().__init__(messages)
        self.errors = errors


def validate_user_input(name: str, age: int) -> None:
    errors: list[FieldError] = []

    if not name:
        errors.append(FieldError("name", "名前は必須です", code="REQUIRED"))
    if age < 0:
        errors.append(FieldError("age", "年齢は0以上である必要があります", code="MIN_VALUE"))
    if errors:
        raise ValidationError(errors)


if __name__ == "__main__":
    try:
        validate_user_input("", -5)
    except ValidationError as e:
        print("バリデーションエラーが発生しました")
        for err in e.errors:
            print(f"  - field={err.field}, message={err.message}, code={err.code}")
実行結果
バリデーションエラーが発生しました
  - field=name, message=名前は必須です, code=REQUIRED
  - field=age, message=年齢は0以上である必要があります, code=MIN_VALUE

このように複数のエラーをまとめて1つの例外で返せる仕組みを整えておくと、フロントエンドとの連携やAPIレスポンス設計もスムーズになります。

API通信エラー用の例外クラス

外部APIとの通信に失敗した場合、単にrequests.exceptions.RequestExceptionなどのライブラリ例外をそのまま外に漏らすと、アプリ側から見た「意味」が分かりにくくなります

そこで、インフラ層ではライブラリ例外を受け取り、アプリ固有のAPI例外にラップして再送出するのがよくあるパターンです。

Python
import requests


class APIError(Exception):
    """外部API通信に関する基底例外"""
    pass


class APITimeoutError(APIError):
    """APIのタイムアウト"""
    pass


class APIResponseError(APIError):
    """APIレスポンスが不正、または期待しないステータスコード"""
    def __init__(self, status_code: int, message: str | None = None):
        self.status_code = status_code
        super().__init__(message or f"Unexpected status code: {status_code}")


def fetch_user_profile(user_id: int) -> dict:
    url = f"https://api.example.com/users/{user_id}"
    try:
        res = requests.get(url, timeout=1.0)
    except requests.Timeout as e:
        # ライブラリ例外をアプリ固有の例外に変換
        raise APITimeoutError(f"ユーザープロファイル取得でタイムアウトしました: user_id={user_id}") from e
    except requests.RequestException as e:
        raise APIError(f"ユーザープロファイル取得で通信エラーが発生しました: {e}") from e

    if res.status_code != 200:
        raise APIResponseError(res.status_code, f"ユーザー取得に失敗しました: status_code={res.status_code}")

    return res.json()
実行結果
# 実際の実行環境・APIエンドポイントに依存するため、ここでは出力例は省略します

ライブラリ依存の例外を、アプリ固有の例外クラスにマッピングすることで、上位層からは「API通信として何が起きたか」だけを意識すればよくなり、責務の分離が明確になります。

ログ出力を意識した例外メッセージ設計

例外メッセージは、開発者がログを見て瞬時に状況をイメージできることを目標に設計します。

単に「エラーが発生しました」では不十分で、次のような情報をできるだけ含めると効果的です。

  • 対象となるエンティティ(例: user_id、order_id)
  • 実行しようとした操作(例: 決済、メール送信)
  • 失敗の要約(例: タイムアウト、認証エラー)
  • 必要に応じて、復旧のヒント

たとえば決済APIエラーの場合、次のようなメッセージが考えられます。

Python
class PaymentAPIError(Exception):
    def __init__(self, user_id: int, amount: int, reason: str):
        message = (
            f"決済API呼び出しに失敗しました "
            f"(user_id={user_id}, amount={amount}, reason={reason})"
        )
        super().__init__(message)
        self.user_id = user_id
        self.amount = amount
        self.reason = reason


def charge(user_id: int, amount: int):
    # 処理の都合で意図的にエラーを発生させてみる
    raise PaymentAPIError(user_id, amount, reason="timeout")


if __name__ == "__main__":
    try:
        charge(123, 1000)
    except PaymentAPIError as e:
        # ログにそのまま出しても状況が把握しやすい
        print(f"[ERROR] {e}")
        # 属性をJSONに整形して構造化ログにすることも可能
        print(
            {
                "error": "PaymentAPIError",
                "user_id": e.user_id,
                "amount": e.amount,
                "reason": e.reason,
            }
        )
実行結果
[ERROR] 決済API呼び出しに失敗しました (user_id=123, amount=1000, reason=timeout)
{'error': 'PaymentAPIError', 'user_id': 123, 'amount': 1000, 'reason': 'timeout'}

メッセージ単体で、可能な限りチケットが切れるレベルの情報量を意識すると、運用時のトラブルシュートが大きく楽になります。

exceptの書き方とキャッチする粒度の決め方

カスタム例外を定義したら、どの粒度で捕捉するかが重要な設計ポイントになります。

広く捕捉しすぎると原因が分からなくなり、細かく捕捉しすぎるとコードが煩雑になります。

1つの指針として、層ごとに粒度を変える考え方があります。

たとえば次のようなイメージです。

  • インフラ層: ライブラリ例外を細かくcatchし、層固有の例外に変換
  • ドメイン層: ValidationErrorなど、ドメインエラーごとに個別にcatch
  • プレゼンテーション層(ハンドラー、Webフレームワーク): 例外の種類ごとにHTTPステータスを変え、最後に想定外のExceptionをまとめて500にする

簡単な例を示します。

Python
class MyAppError(Exception):
    pass


class ValidationError(MyAppError):
    pass


class APIError(MyAppError):
    pass


def use_case(user_input: dict) -> dict:
    if "name" not in user_input:
        raise ValidationError("nameは必須です")
    if user_input.get("name") == "api_error":
        raise APIError("外部APIエラー")
    return {"message": "ok"}


def handle_request(user_input: dict) -> tuple[int, dict]:
    try:
        result = use_case(user_input)
        return 200, result
    except ValidationError as e:
        # クライアントの入力ミスとして400を返す
        return 400, {"error": "validation_error", "message": str(e)}
    except APIError as e:
        # 一時的な障害として503を返す
        return 503, {"error": "api_error", "message": str(e)}
    except MyAppError as e:
        # 想定内だが分類されていないアプリエラー
        return 500, {"error": "app_error", "message": str(e)}
    except Exception as e:
        # 想定外のバグなど
        return 500, {"error": "unexpected_error", "message": "予期しないエラーが発生しました"}


if __name__ == "__main__":
    print(handle_request({"name": "alice"}))
    print(handle_request({}))  # ValidationError
    print(handle_request({"name": "api_error"}))  # APIError
実行結果
(200, {'message': 'ok'})
(400, {'error': 'validation_error', 'message': 'nameは必須です'})
(503, {'error': 'api_error', 'message': '外部APIエラー'})

広い例外→狭い例外の順序でexceptを書くことで、より具体的なハンドリングから順に適用されるようにします。

メンテナブルな例外設計のベストプラクティス

例外クラスの命名規則と配置パッケージ

例外クラスは、増えてくるとどこに何があるか分からなくなりがちです。

そのため、命名と配置にルールを持たせます。

命名については、次のような方針がシンプルで有効です。

  • クラス名は必ず「〜Error」で終わる
  • ドメイン名 + 状態 + Error の順で組み立てる(例: UserNotFoundError)
  • 層ごとの基底例外にはDomainErrorInfrastructureErrorなど汎用名を付ける

配置については、プロジェクト構成に応じていくつかパターンがありますが、よく使われるのは次のような構成です。

場所役割
myapp/exceptions.pyアプリ全体で使う基底例外(MyAppErrorなど)
myapp/domain/exceptions.pyドメイン層の例外(DomainError、ValidationErrorなど)
myapp/infra/exceptions.pyインフラ層(APIError、DatabaseErrorなど)

層ごと・モジュールごとにexceptions.pyを用意し、そこに例外を集約すると、探索性が向上し、IDEでの補完や参照も行いやすくなります。

乱立を防ぐための設計指針

カスタム例外は便利ですが、増やしすぎると逆にメンテナンスが難しくなります

似たような例外が大量にあると、どれを使うべきか分からなくなり、プロジェクト内での一貫性も失われます。

乱立を防ぐために、例外クラスを新規追加する際は次の観点をチェックすると良いです。

1つ目は既存の例外で代用できないかです。

たとえばUserNotFoundErrorCustomerNotFoundErrorが別々に存在しても、ハンドリングが同じであればEntityNotFoundErrorでまとめた方がよい場合もあります。

2つ目は複数箇所で使う見込みがあるかです。

特定の関数内だけで完結する一時的な例外であれば、共通化せずローカルな例外名でも構いません。

3つ目はハンドリング方針が異なるかです。

例外を分けるかどうかの最終判断は、「catchした後に処理を分けたいかどうか」で決めると整理しやすくなります。

「catchの粒度が変わるなら例外を分ける、変わらないなら属性だけ変える」というルールをチーム内で共有しておくと、設計の一貫性が保ちやすくなります。

ライブラリ公開時の例外設計

自分のPythonコードをライブラリとして公開する場合、例外設計はパブリックAPIの一部になります。

利用者は、そのライブラリがどのような例外を投げるのかを前提に、エラー処理を実装するからです。

ライブラリでの例外設計では、次のポイントを意識します。

まずライブラリ共通の基底例外クラスを必ず1つ用意します。

たとえばMyLibErrorのようなクラスです。

これにより、利用者はexcept MyLibError:と書くだけでライブラリ由来のすべてのエラーを捕捉できます。

次に公開する例外クラスを明示的にドキュメントに記載します。

どの関数・メソッドがどの例外を投げうるかを示すことで、利用者は安心してエラー処理を書けます。

簡単なライブラリ風の例を示します。

Python
# mylib/exceptions.py

class MyLibError(Exception):
    """ライブラリ共通の基底例外"""
    pass


class ConfigError(MyLibError):
    """設定ファイルの不備など"""
    pass


class ConnectionError(MyLibError):
    """接続に関連するエラー"""
    pass


class DataFormatError(MyLibError):
    """データフォーマットが不正な場合"""
    pass
Python
# mylib/client.py

from .exceptions import (
    MyLibError,
    ConfigError,
    ConnectionError,
    DataFormatError,
)


def load_config(path: str) -> dict:
    if not path.endswith(".yaml"):
        raise ConfigError("設定ファイルは.yaml形式である必要があります")
    # 実装は省略
    return {}


def fetch_data() -> dict:
    # 実装は省略し、例としてエラーを発生
    raise ConnectionError("サーバーに接続できませんでした")


def parse_data(raw: str) -> dict:
    # 実装は省略し、例としてエラーを発生
    raise DataFormatError("JSONとして解析できませんでした")
Python
# ライブラリ利用者側のコード例

from mylib.client import load_config, fetch_data, parse_data
from mylib.exceptions import MyLibError, ConfigError, ConnectionError, DataFormatError


def main():
    try:
        config = load_config("settings.yaml")
        raw = fetch_data()
        data = parse_data(raw)
    except ConfigError as e:
        print(f"設定エラー: {e}")
    except ConnectionError as e:
        print(f"接続エラー: {e}")
    except DataFormatError as e:
        print(f"データ形式エラー: {e}")
    except MyLibError as e:
        print(f"ライブラリ内のその他のエラー: {e}")


if __name__ == "__main__":
    main()
実行結果
接続エラー: サーバーに接続できませんでした

このようにライブラリ側で基底例外と代表的な例外クラスを定め、利用者がそれを前提にエラー処理を書けるようにしておくことが、よいAPI設計につながります。

まとめ

Pythonで例外クラスを自作することは、単なる「好み」ではなく、エラー原因の可視化と、メンテナンス性の高いシステム設計に直結する重要なテクニックです。

標準例外は言語レベルの失敗に任せ、ビジネスロジックやインフラ連携の失敗は、名前と属性を丁寧に設計したカスタム例外で表現することで、ログの読みやすさ、テストのしやすさ、チームでの合意形成が大きく向上します。

基底例外を中心とした階層構造を作り、粒度の適切なcatchとログメッセージ設計を意識すれば、運用現場で「原因が分からないエラー」に悩まされる時間を大きく減らすことができるでしょう。

クラウドSSLサイトシールは安心の証です。

URLをコピーしました!