💭

Result/Either派の人もドメインモデルの不変条件違反は例外で実装して良い理由

に公開

この記事では、ドメインモデルを実装するときに頻出する不変条件の設計を、単体テストの考え方/使い方の著者Vladimir Khorikovさんの記事を紹介しつつ説明していきたいと思います。

背景

DDDの書籍やサンプルコードを読むと、以下のようにコンストラクタで不変条件チェックするコードがよく登場します。

data class Range(val from: LocalDate, val to: LocalDate) {
    init {
        // 不変条件を満たさない場合は、コンストラクタ(初期化時)で弾く
        if (from >= to) {
            throw IllegalArgumentException("From must be less than to")
        }
    }
}

Kotlin では、require という引数チェック専用の関数があり、同様の処理を少し短く書くことができます。

data class Range(val from: LocalDate, val to: LocalDate) {
    init {
        require(from < to) { "From must be less than to" }
    }
}

モデル側でこのようなチェックを書くことでモデルを利用する側のコードでは、不変条件のチェックが不要となりビジネスロジックの実装に集中することができます。

Result/Either派

以下のような失敗する可能性のある処理の戻り値をResult<T>型で返却するスタイルです。

fun parseDateByResult(dateString: String): Result<LocalDate> {
    return runCatching {
        LocalDate.parse(dateString)
    }
}

失敗の可能性を戻り値の型として表現することで、関数シグネチャを確認するだけで失敗の有無を判断可能となり、利用者側にエラーハンドリングを強制することもできます。
またエラーの合成や、成功時/失敗時だけの処理も実装しやすく便利です。
ArrowのEither<T, E>やkotlin-resultのようにErrorの型を持てるものも概念は同様です。

ドメインモデルの不変条件チェックのエラーは例外で良い理由

Result/Eitherの便利さを経験すると、例外を投げるコードに違和感を感じるようになり、できるだけResult/Eitherによるエラーハンドリングに寄せていきたくなります。
それでも例外のままで良い理由を紹介します。

理由① ドメイン層はAlways Validな層

単体テストの考え方/使い方 の著者のブログより引用します。
https://enterprisecraftsmanship.com/posts/always-valid-domain-model/

ドメインモデルを使ったアプリケーションでは、ヘキサゴナルアーキテクチャやオニオンアーキテクチャなどドメイン層と外側の(アプリケーションサービス)層を分離したアーキテクチャを使います。

出典:Always Valid Domain Model - Enterprise Craftsmanship

ドメイン層ではビジネスルールを不変条件(invariants)として扱いますが、外側の(アプリケーションサービス)層ではビジネス条件を入力値の検証として使用します。

出典:Always Valid Domain Model - Enterprise Craftsmanship

出典:Always Valid Domain Model - Enterprise Craftsmanship

外側の層から来る入力値の検証で不正なデータがフィルタリングされることは、”例外”ではありませんが、ドメイン層における不変条件違反は”例外”的なことです。

図のように、ドメイン層は不変条件を常に満たす(Always Valid)な層であり、不正な入力値をvalidationするのは外の層の役目です。

外の層の具体例として、

  • WEB APIのController
  • DBに永続化したモデルの復元
  • 単体テストのドライバーからの呼び出し

それぞれ、以下のように実装されることが多いと思います。

  • WEB APIのController
    • OpenAPIのJSON SchemaやBeanValidatorなどにより入力値の検証をするため、不変条件違反となることがない
  • DBに永続化したモデルの復元
    • DBに不正値が混入してしまった場合は、エラーを通知して開発者が調査するしか解決方法がない。共通のエラーハンドリング機構でエラーログだけ出して内部エラーを返却する
  • 単体テストのドライバーからの呼び出し
    • テストデータに不正値が混ざっているだけなので、テストデータを修正するしかない。

上記のように、よくある実装ケースにおいて、Result型を返してエラーハンドリングを強制しても旨みが少ないです。(例外ケースは後述)

理由② ドメイン層の不変条件エラーは開発者のためのエラー

理由①では、ドメイン層で発生するエラーに対してエラーハンドリングが不要な理由を説明しました。
エラーハンドリングしないならドメイン層での不変条件チェックは不要でしょうか?
そんなことはありません。ドメイン層はアプリケーションの中心となる層であり、外の層の事情に左右されるべきではありません。また、ドメインモデルに不変条件があることで、不正なデータが紛れ込む範囲を絞ることができ、堅牢で変更の影響範囲調査が容易なシステムを作ることができます。
ドメイン層での不変条件チェックは将来の自分たち(開発者)を助けるためのエラーなのです。

参考 Recoverable errorsとLogic failures

例外/エラーには、日付のパースやIO操作のように失敗時に復旧可能なRecoverable errorsと、NullPointerExceptionやIllegalArgumentExceptionのように開発者の実装ミスやバグに起因するLogic failuresがあります。Recoverable errorsにはResult型を、Logic failuresには例外を使うというように使い分けると良いです。

参考: https://qiita.com/koher/items/a7a12e7e18d2bb7d8c77

例外ケース

残念ながらシステム開発に銀の弾丸はなく、全ての設計はトレードオフです。
上記の判断ではうまくいかない例外ケースも存在します。いくつか紹介します。

例外ケース① API層とドメイン層の二重チェックが許容できないケース

扱うモデルが複雑すぎるなどの理由で、OpenAPI定義やBeanValidatorによるvalidationと不変条件チェックの二重実装が許容できないケースです。
そういうケースでは、ドメイン層の変換とvalidationをまとめる形でResult型を返す実装を選択することがあります。そういったケースでは、parse, convertFromRawData など、いかにも失敗することがありそうなファクトリメソッドで変換を行うと良いでしょう。
むしろDRYになって良いのでは?と思うかもしれませんが、OpenAPI定義などが困難なモデルはAPI仕様書などのドキュメント整備も困難なためメンテナンスが難しくなります。ソースが仕様書となりますが、Result型を返すparse処理は読みやすいコードになりづらく辛いです。(プログラム言語による)

例外ケース② 例外を許容しない言語や文化

この記事のサンプルコードはKotlinで、Java, Scala, C#などの言語で実装するケースを想定して書きましたが、実装言語によっては例外が推奨されない言語もあります。郷に入っては郷に従いましょう。
Go言語の場合、Logic failuresのケースでは、気軽にpanicしても良いと思っている派ですが、Go開発者の中ではpanicはかなり忌諱されている印象があります。

最後に

ドメインモデルの不変条件チェックの設計について紹介しました。
ドメインモデルで不変条件チェックを加えるとシステムの保守性、堅牢性が上がるので、方針を決めて実装に迷わないようにするのはとても重要です。
また、記事中で紹介したAlways valid modelやError分類の考え方は応用の効く知識なので初めて知った方は是非ともリンク先の記事も読んでみて欲しいです。

株式会社ログラス テックブログ

Discussion