【Effective Java】抽象概念に適した例外をスローする


Effective Javaの独自解釈です。
第3版の項目73について、自分なりにコード書いたりして解釈してみました。

概要

  • 下位レイヤーの例外はそのまま出力させるのではなく、上位レイヤーの概念に応じた例外に翻訳することで以下のメリットが得られる。
    • ログ調査しやすくなる
    • さらに上位のレイヤーでの例外ハンドリングがしやすくなる
  • 実装で例外発生を確実に防げるなら、翻訳は考えず例外発生そのものを防ぐ。

説明

以下の環境で実装したコードで説明する。

  • Java 11
  • Doma 2.19.2
  • Spring Boot 2.3.3
  • MySQL 5.7

DB挿入時における例外出力の例

DBにUSERテーブルがあるとし、レコードを新規追加することを考える。
USERテーブルには主キーとしてidカラムを持っている。

Java側の関連クラスは以下の通り。(importなど略)

Entity

public class User {

    private String id;

    private String name;
}

Dto

public class CreateUser {

    private String id;

    private String name;
}

Daoインタフェース

public interface UserDao {

    @Insert
    int insert(User user);
}

下位レイヤーの例外をそのまま出力させた場合

既にUSERテーブルに存在するidを持つcreateUserを引数に入れ、以下のサービス層のメソッドでinsertを試み例外を発生させる。

public User createUser(CreateUser createUser) {

    User user = new User();
    user.setId(createUser.getId());
    user.setName(createUser.getName());

    userDao.insert(user);

    return user;
}

このとき、標準エラーには以下が出力される。

〜略〜
nested exception is org.springframework.dao.DuplicateKeyException: [DOMA2004] 一意制約違反により更新処理が失敗しました。
SQLファイルパス=[null]。
ログ用SQL=[]。
詳しい原因は次のものです。{2}; nested exception is org.seasar.doma.jdbc.UniqueConstraintException: [DOMA2004] 一意制約違反により更新処理が失敗しました。
SQLファイルパス=[null]。
ログ用SQL=[]。
詳しい原因は次のものです。{2}] with root cause

java.sql.SQLIntegrityConstraintViolationException: Duplicate entry 'A01' for key 'PRIMARY'
〜略〜

Daoレイヤーから出力されたDuplicateKeyExceptionがそのままcreateUserメソッドを突き抜けてスローされている。

弊害としては以下の通り。

  • スタックトレースをたどればcreateuserメソッドで主キー重複例外が出てるから、USERのID重複エラーだなと一応わかるが、ひと目ではわかりにくい。
  • 上位レイヤーのコントローラ層で、Entityに応じたエラーハンドリング処理をしたい場合、DuplicateKeyExceptionが投げられると、どのEntityに対しての例外なのか判別するために、ややこしい文字列解析などする必要が出てくる。

上位レイヤーの例外に置き換えた場合

createUserメソッドの主な関心事はDBの制約ではなく、Userそのものである。ということでUserに着目した独自例外を作ってやり、それに翻訳する処理を施す。

独自例外

public class UserDuplicatedIdException extends Exception {

    public UserDuplicatedIdException(String message, Throwable cause) {
        super(message, cause);
    }
}

Primary KeyというDB寄りの言葉は使わず、あくまでUseridというUser寄りの言葉で例外クラスを作る。
createUserメソッドは以下のように、DuplicateKeyExceptionからUserDuplicatedIdExceptionへの翻訳処理を実装する。

public User createUser(CreateUser createUser) throws UserDuplicatedIdException {

    User user = new User();
    user.setId(createUser.getId());
    user.setName(createUser.getName());

    try {
        userDao.insert(user);
    } catch (DuplicateKeyException e) {
        throw new UserDuplicatedIdException("Can't create already existing id user.", e);
    }

    return user;
    }

先程の例と同じようにid重複のUSERテーブルレコードを挿入しようとすると、以下の標準エラー出力が出力される。

〜略〜
nested exception is com.demo.domain.exceptions.UserDuplicatedIdException: Can't create already existing id user.] with root cause

java.sql.SQLIntegrityConstraintViolationException: Duplicate entry 'A01' for key 'PRIMARY'
〜略〜

createUserからは、このレイヤーの関心事であるUserに関する例外UserDuplicatedIdExceptionが出力される。

利点は先程とは逆で以下の通り。

  • 例外クラスを見ただけでどのEntityで何が起こったかがひと目で分かる。
  • コントローラ層では、catchした例外クラスに応じて楽に分岐処理を実装できる。

また、障害やデバッグ時にログ調査をする際も、目的の例外クラスでgrepをかけられるので捗る。

例外翻訳は乱用しない

先述の例で、USERidNullで登録しようとすると、以下の標準エラーが出力される。

〜略〜
nested exception is org.springframework.dao.DataIntegrityViolationException: [DOMA2009] SQLの実行に失敗しました。
SQLファイルパス=[null]。
ログ用SQL=[]。
原因は次のものです。java.sql.SQLIntegrityConstraintViolationException: Column 'id' cannot be null。
根本原因は次のものです。java.sql.SQLIntegrityConstraintViolationException: Column 'id' cannot be null; SQL [insert into USER (id, name) values (?, ?)]; Column 'id' cannot be null; nested exception is java.sql.SQLIntegrityConstraintViolationException: Column 'id' cannot be null] with root cause

java.sql.SQLIntegrityConstraintViolationException: Column 'id' cannot be null
〜略〜

なるほど、この場合はDataIntegrityViolationExceptionを別の例外に翻訳すればいいんだなと思うかもしれないが、それはNGである。

insert時のDBの中身はクライアント側で制御できないが、insertしようとするEntityは制御可能である。
コントローラ層でリクエストのid指定がない場合はバリデーションで弾くなり、代替値を入れるなりすれば、この例外は100%発生しないので、翻訳処理も必要なくなる。