-----------追記-------------
仕様パターンについては以下の書籍で可能な限り詳しく解説しています。
興味あれば読んでみてください。
pospome.booth.pm
-----------追記おわり-------------
エリック・エヴァンスのDDD本では「仕様パターン」という実装パターンが説明されている。
仕様上のバリデーションはエンティティや値オブジェクトに実装してはいけない。
複雑な仕様による複雑なバリデーションロジックは
クラスの肥大化を招いてしまう。
class User { //こういったバリデーションは肥大化を招く public boolean isXXX(){ //複雑なロジック } }
また、こういったバリデーションは複数のエンティティを必要とする場合があるので、
どのクラスの責務とするのかを明確に判断できないこともある。
class User { //引数に Group をとっているが、Groupクラスに isXXX() を実装してはいけないのか? //なぜ User に実装しているのか? public boolean isXXX(Group group){ } }
とはいえ、アプリケーションレイヤに実装すると、
ドメイン知識の流出になってしまう。
class UserApplication { public void getUserGroup(User user){ if(this.isXXX() === false){ //エラー } } //ここにバリデーションを実装するのはダメ public boolean isXXX(){ //複雑なロジック } }
このようなバリデーションはドメインレイヤの「仕様クラス」というクラスにまとめるといい。
というのが仕様パターン。
仕様クラスの用途は3つ。
1.検証 ... エンティティ、値オブジェクトのバリデーション。
2.選択 ... コレクションから特定のオブジェクトを抽出する。(フィルタリング機能)
3.生成 ... 何かの要求に適合する新しいオブジェクトを生成。
1.検証
これはエンティティ、値オブジェクトのバリデーション機能。
「xxxはyyyを満たす」などの仕様を管理する機能。
isXXX(), canXXX() などのメソッド名になると思う。
DDD本では請求書の仕様として「延滞請求書」「巨額請求書」を例にしている。
引数に渡した請求書オブジェクト(Invoice)がチェック対象になる。
//請求書 Interface InvoiceSpec { public boolean isSatisfied(Invoice invoice); } //延滞 class DelayInvoiceSpec implements InvoiceSpec { public boolean isSatisfied(Invoice invoice){ //延滞チェックロジック return true; } } //巨額 class LargeInvoiceSpec { public boolean isSatisfied(Invoice invoice){ //巨額請求チェックロジック return true; } }
上記はインターフェースで isSatisfied() を定義しているので、
1つの仕様を1つのクラスに実装するというアプローチをとっている。
ポイントとしては、
このようなバリデーションは複数のエンティティにまたがったチェックも必要になるので、
必ずしもエンティティや値オブジェクトに1対1で対応させて機械的に用意するものではなく、
あくまで仕様単位で実装する必要がある。
ここで気になるのが、
エンティティ、値オブジェクトのプロパティに対するバリデーションもエンティティ、値オブジェクトに実装するのか?
という点。
例えば、以下はコンストラクタでのプロパティバリデーション。
class User { private int id = 0; public User(int id){ if(id < 0){ //エラー } } }
仕様上、「idが0未満になることはない」ということであれば、
上記のコンストラクタでのプロパティチェックも仕様クラスに実装するべきだろうか?
個人的にこれを仕様クラスに実装すると、
エンティティ、値オブジェクトの数だけFactory用途の仕様クラスを作ることになってしまうので、
避けた方がいいと思う。
なんとなく違和感がある。
プロパティの妥当性チェックはエンティティ、値オブジェクトに実装するが、
あくまでプロパティが取りうる値をチェックするものにして(nullチェックや負数チェックなど)、
その値が仕様として正しいかは仕様クラスに実装した方がいいと思う。
以下の User のコンストラクタでは groupId が0未満かはチェックしているが、
ユーザーがそのグループに所属しているかはチェックしていない。
class User { private int groupId = 0; public User(int groupId){ if(id < 0){ //groupIdが0未満はあり得ないので、エラー } } }
ユーザーがそのグループに所属しているかは UserSpec を利用してチェックする。
class UserSpec { public boolean isValidGroupId(User user){ //userの持っている groupId が正しいか(ユーザーがそのグループに所属しているか)をチェックするロジックを実装する。 //おそらくDBにアクセスして、チェックする必要がある。 return true; } }
ちなみに、一番最初の User の例で挙げた「idが0未満になることはない」という条件については
個人的には仕様でもあり、プロパティが取りうる値のチェックでもあると思うので、
判断が微妙なところだが、
エンティティや値オブジェクトのプロパティを守る最低限のチェックであれば、
コンストラクタでチェックしてもいいと思う。
とはいえ、判断が難しいな・・・。
2.選択
コレクションから特定のオブジェクトを抽出する。(フィルタリング機能)
特定のオブジェクトを取得するのにはリポジトリを利用するが、
取得対象のオブジェクトは仕様に沿ったオブジェクトであることが多い。(延滞請求書を取得するなど)
なので、リポジトリにも仕様クラスを利用することができる。
DDD本では延滞請求書を取得するリポジトリを例に挙げている。
クライアントコードから利用するのはリポジトリだが、
リポジトリが仕様クラスからどのようなデータを取得するのかを取得している。
以下はイメージを掴むための簡易的な実装例。
まずは仕様クラスとリポジトリ。
class DelayInvoiceSpec { public boolean isSatisfied(Invoice invoice){ //延滞チェック return true; } } class InvoiceRepo { public List<Invoice> getDelayInvoice(){ DelayInvoiceSpec spec = new DelayInvoiceSpec(); //DBから全請求書を取得 List<Invoice> invoices = Db::getAllInvoice(); // DelayInvoiceSpec の isSatisfied() でフィルタリング // filter()部分は擬似コードです。 return invoices.filter( (Invoice invoice) { return spec.isSatisfied(invoice); }); } }
クライアントコードは以下。
クライアントコードが仕様クラスを意識することはない。
InvoiceRepo repo = new InvoiceRepo();
repo.getDelayInvoice();
この実装のポイントは延滞判断ロジック(isSatisfied())を仕様クラス(DelayInvoiceSpec)が持っているところ。
これによって、「延滞しているかどうか」というドメイン知識がドメインレイヤに置かれている。
インフラレイヤでは全請求書を取得する汎用的なSQLなので、
特別なドメイン知識を持たせずに済む。
この実装の問題点は InvoiceRepo の Db::getAllInvoice() で全請求書を取得してからフィルタリングしているところ。
請求書の数が1億件とかになると効率が悪くなってしまうので
利用ケースによっては現実的な実装とはいえない。
なぜわざわざ仕様クラスを利用して、フィルタリングをするのか?
延滞対象の請求書を直接取得するSQLを書けばいいのではないか?
確かに、用途に最適化されたSQLを書くことで、パフォーマンスは最適化されるが、
SQLにドメイン知識が流出する可能性がある。
延滞チェックであれば、
以下のように特定の日時を指定して請求書を取得するSQLを
利用すれば「延滞対象の請求書を取得する」という要件は満たせるかもしれない。
select * from table where a between xxx and yyy;
この場合は「特定日時を指定して請求書を取得する」という汎用的なSQLであると判断できるので、
特に問題ないと思う。
ただし、延滞請求書の判定が複雑な仕様になると日付だけではレコード数を絞り込めないので、
ドメイン知識(システム仕様)を満たす複雑な検索条件のSQLを発行する必要がある。
select * from table where a = xxx and b = xxx and c between ??? and ???;
このようなSQLは特定の用途でしか利用されない(汎用的とは言えない)ので、
ドメイン知識の流出になっている可能性がある。
これはインフラレイヤへのドメイン知識の流出はDDDに反する。
ちなみに、汎用的かどうかの判断基準はSQLを実行するメソッド名に
ドメイン知識(ユビキタス言語)が反映されているかどうかで判断できると思う。
例えば、全レコード取得は getAllData() 、日付指定は getDataByDate() などドメイン知識が反映されていないが、
用途に特化した複数の検索条件を持つ複雑なSQL場合は
検索条件を表現できるメソッド名が思いつかないことが多い。
仮に検索条件をメソッド名に反映させると、getDataByXxxAndYyyAndZzz() などになる。
これはいかがなものか。
延滞請求書であれば、getDelayData() など「延滞 = Delay」というドメイン知識が反映されることになると思う。
これは汎用的ではない。
ドメイン知識が流出してしまっている。
つまり、「ドメイン知識をドメインレイヤに閉じ込める」というDDDの原則を守るのであれば、
仕様クラスにフィルタリングロジックを実装する必要があるが、
パフォーマンスを最適化できるとは限らない。
パフォーマンスを最適化するのであれば、
ドメイン知識を反映させ、特定の用途でしか利用されないSQLを実装する必要があるので、
DDDの原則に反する。
間を取って、
汎用的なSQLで可能な限りレコード数を絞り込んで、
絞り込めない部分は仕様クラスの判断ロジックでフィルタリングするということも可能だが、
パフォーマンスが汎用的なSQLで絞り込めるレコード数に依存してしまうので、
パフォーマンスが担保できる保証はない。
難しい問題ではあるが、
「ドメイン知識の流出は防いだけど動きません」じゃ話にならないので、
個人的にはパフォーマンスは最適化したい。(特にDBのようなI/O周りは)
自分は仕様クラスを用いたフィルタリングを利用せず、
素直にSQLに遅延請求書の条件を直書きすると思う。
CQRSを利用することで解決できる問題ではあるが、
実際にCQRSを導入する際の実装コスト、複雑性を考えると、
万能な選択肢とは言えない。
とりあえず、仕様クラスでフィルタリングする実装を採用し、
一部のパフォーマンスを最適化したい機能はSQLを最適化するという
ハイブリッドな実装も可能だが、
わけがわからない状態になりそうなので、微妙なところ・・・。
ちなみに、DDD本ではダブルディスパッチによる実装例が載っている。
根本的な解決策とはいえないが、
実装例として確認しておくといいと思う。
3.生成
何かの要求に適合する新しいオブジェクトを生成する実装パターン。
これはDDD本のサンプルプログラムに実装例が載っていた。
以下は抜粋したもの。
//各エンティティや値オブジェクト final TrackingId trackingId = cargoRepository.nextTrackingId(); final Location origin = locationRepository.find(originUnLocode); final Location destination = locationRepository.find(destinationUnLocode); //仕様クラス final RouteSpecification routeSpecification = new RouteSpecification(origin, destination, arrivalDeadline); //Cargo のコンストラクタの引数に仕様クラス自体をセットしている。 final Cargo cargo = new Cargo(trackingId, routeSpecification);
ポイントは Cargo に仕様クラスである RouteSpecification を渡していること。
Cargo が要求するルートのオブジェクト仕様を
RouteSpecification が満たしている。
以下はRouteSpecificationの実装を抜粋したもの。
public class RouteSpecification extends AbstractSpecification<Itinerary> implements ValueObject<RouteSpecification> { private Location origin; private Location destination; private Date arrivalDeadline; public RouteSpecification(final Location origin, final Location destination, final Date arrivalDeadline) { Validate.notNull(origin, "Origin is required"); Validate.notNull(destination, "Destination is required"); Validate.notNull(arrivalDeadline, "Arrival deadline is required"); Validate.isTrue(!origin.sameIdentityAs(destination), "Origin and destination can't be the same: " + origin); this.origin = origin; this.destination = destination; this.arrivalDeadline = (Date) arrivalDeadline.clone(); } public Location origin() { return origin; } public Location destination() { return destination; } public Date arrivalDeadline() { return new Date(arrivalDeadline.getTime()); } public boolean isSatisfiedBy(final Itinerary itinerary) { return itinerary != null && origin().sameIdentityAs(itinerary.initialDepartureLocation()) && destination().sameIdentityAs(itinerary.finalArrivalLocation()) && arrivalDeadline().after(itinerary.finalArrivalDate()); }
RouteSpecification は Location を2つとDateで構成されている。
コンストラクタはnullチェックのみ。
また、 origin(), destination(), arrivalDeadline() で Location, Date を取得できるようにしている。
isSatisfiedBy() は RouteSpecification の検証用メソッド。
Cargoのコンストラクタとプロパティは以下。
コンストラクタに渡した RouteSpecification 自体をプロパティとして保持し、
RouteSpecification.origin() で Location を別プロパティで保持している。
public class Cargo implements Entity<Cargo> { private TrackingId trackingId; private Location origin; private RouteSpecification routeSpecification; private Itinerary itinerary; private Delivery delivery; public Cargo(final TrackingId trackingId, final RouteSpecification routeSpecification) { Validate.notNull(trackingId, "Tracking ID is required"); Validate.notNull(routeSpecification, "Route specification is required"); this.trackingId = trackingId; // Cargo origin never changes, even if the route specification changes. // However, at creation, cargo orgin can be derived from the initial route specification. this.origin = routeSpecification.origin(); this.routeSpecification = routeSpecification; this.delivery = Delivery.derivedFrom( this.routeSpecification, this.itinerary, HandlingHistory.EMPTY ); }
自分は RouteSpecification を Route のようなエンティティとして実装してしまいそうだけど、
サンプルでは仕様クラスとして実装している。
Routeというエンティティではなく、仕様と判断する基準が分からない。
Routeというエンティティにした場合にドメインロジックが存在しないからだろうか?
Cargo の要求するルート仕様を RouteSpecification が満たしているしているところが、
「生成」のポイントというか実装なのだろうか?
この点はちょっと分からない・・・。
ということで、仕様クラスの話でした。