ビジネスアプリケーションとビジネスルール
楽楽精算開発部の id:smdr3s です。主に Java を使ったサーバーサイドを担当しています。
弊社のサービスである楽楽精算は、その名のとおり経費精算のサービスです。主に企業にお勤めの方が、業務での移動時ににかかった交通費や業務に必要な物品を購入した際の代金などを経費として会社に申請する際にご利用いただいております。
楽楽精算にはさまざまな機能がありますが、その一つに「申請ルール」という機能があります。 これは、経費申請時にあらかじめ設定しておいたルールで申請内容を検証し、ルール違反があればメッセージを表示して警告したり、申請を拒否したりする機能です。
例えば、交通費精算の際に「利用交通機関がタクシーで、料金が3,000円以上、かつ理由欄に記述がない、場合は申請できないようにする」ようなことが可能です。
申請ルールの設定
申請時にルール違反があった場合
このように、ビジネスアプリケーションには複数の条件を組み合わせたビジネスルールによる検証を行うことがよくあります。 また、ビジネスルールに従って蓄積されたデータの中から条件に合致するものを抽出したりすることもあります。
そのような検証や抽出に使用するルールや条件を実装するときに役立つのが Specification パターンです。
用語について
この記事では、パターン名にも使われている Specification という用語を以下の二通りに訳して使用しています。
- ルール:最終的に満たされるべき基準の総称。「タクシー利用ルール」など。
- 条件:ルールに含まれ、構成する個々の基準の名称。「金額上限条件」「理由記述条件」など。
Specification をそのまま訳すと「仕様」であり、「仕様」にも上記のような「満たすべき基準」の意味はあるかと思いますが、個人的にあまりしっくりこなかったため別の言葉を使用しています。また、全体と個々の中身を区別したい場面が多かったためそれぞれに別の言葉を割り当てました。(後述しますが、それらを同一視できるのがこのパターンのメリットの一つでもあるのですが、便宜的に使い分けました。)
パターン適用前
上の「申請ルール」の説明に使用したビジネスルールを基に「タクシー利用申請アプリケーション」の実装を検討します。 このアプリケーションで検証するビジネスルールは以下のとおりとします。
- タクシー利用ルール
- 料金が3,000円以上の場合は理由欄に理由を記述すること
申請はこのようなレコードです。
public record TaxiApplication( int fee, // 料金 String description // 理由 ) { }
申請時にルールに沿っているかを検証します。 手続き的に書く場合は検証処理は Logic クラスあたりに書かれそうです。
public class TaxiApplicationLogic { public void apply(TaxiApplication application, User applicant) { // いろいろ // タクシー利用ルールのチェック // 3,000円以上かつ理由欄が空の場合は検証エラー if (application.fee() >= 3000 && application.description().length() <= 0) { throw new ApplicationRuleValidationException(); } // いろいろ } }
ただ、このように書いてしまうと下記のような問題があります。
- ルール検証のテストがしづらい
- ルールが変わるごとに Logic クラスに修正が入る
- 同じルールを使用している箇所があった場合、ロジックが重複する
- 全体のコードからルールを司るコードの特定が困難
この状態は、ソフトウェア品質特性で言う、保守性の副特性である試験性、安定性、解析性に影響が出ているか、出る可能性が比較的高い状態かと思います。
これは重要なビジネスルールであるはずのタクシー利用ルールのコードが手続きの中に隠され、独立していないことが最も大きな原因と考えられます。
Specification パターン
これを Specification パターンを使用して改善していきたいと思います。
Specification パターンにはいくつかの方式があるのですが、基本的にはルールや条件の検証を実行する Specification インターフェースが基になります。
public interface Specification<T> { boolean isSatisfiedBy(T candidate); }
Specification インターフェースには、通常 isSatisfiedBy
という名前のメソッドを定義します。このメソッドは検証対象のオブジェクトをパラメータとして受け取り、実装ではそのオブジェクトが定義されたルールまたは条件を満たしているかどうかを検証し結果をブール値で返します。
Hard Coded Specification
最も単純な実装は Hard Coded Specification です。 ルールの検証ロジックをそのまま Specification インターフェースの実装クラスに実装します。
// タクシー利用ルールクラス public class TaxiUsageSpecification implements Specification<TaxiApplication> { @Override public boolean isSatisfiedBy(TaxiApplication candidate) { return candidate.fee() < 3000 || candidate.description().length() > 0; } }
(先ほどの Logic クラス内の実装ではルールに沿わないことを判定していましたが、今回はルールに沿うことを判定するため、真偽が逆になっています。)
これでルールをクラスにすることができました。 このクラスを利用するよう上記の Logic クラスのチェック部分を変更します。
// タクシー利用ルールオブジェクトの生成 public class SpecificationFactory { public static Specification<TaxiApplication> getTaxiUsageSpecification() { return new TaxiUsageSpecification(); } } public class TaxiApplicationLogic { public void apply(TaxiApplication application, User applicant) { // いろいろ // タクシー利用ルールオブジェクトの取得 Specification<TaxiApplication> taxiUsageSpecification = SpecificationFactory.getTaxiUsageSpecification(); // 申請がルールに沿っていなければエラー if (!taxiUsageSpecification.isSatisfiedBy(application)) { throw new ApplicationRuleValidationException(); } // いろいろ } }
ルールのコードをクラスに切り出すことでコードの特定が容易になり、同じルールであればこのロジックを使い回せるようになりました。
このように Hard Coded Specification は実装が簡単で、ルールがコードに直接表現されているため、ルールが単純な場合には読みやすいかと思います。
しかし、ルールをコードにベタ書きしているのはパターン適用前と変わらず、少しでもルールに変更があった場合にはコードの修正が必要となります。 ルールの条件が固定されており変更の可能性も低い場合にのみ使用するのが良いかと思います。
Parameterized Specification
ルールの大まかな条件は決まっているが、細かい差異や変更がある場合に使用できるのが Parameterized Specification です。 ルールの実装クラスのオブジェクトを作成するときにパラメータを渡せるようにすることで、差異のあるルールを作成することができます。
今回の例では、タクシー料金が3,000円以上の場合に理由の記述が必要なルールとなっていますが、将来的にこの金額を変更したい、という話が出てきそうですのでこれをパラメータで渡せるようにしてみます。
// タクシー利用ルールクラス public class TaxiUsageSpecification implements Specification<TaxiApplication> { private final int maxFreeFee; // 理由記述が不要な料金上限 TaxiUsageSpecification(int maxFreeFee) { this.maxFreeFee = maxFreeFee; } @Override public boolean isSatisfiedBy(TaxiApplication candidate) { return candidate.fee() < maxFreeFee || candidate.description().length() > 0; } }
// タクシー利用ルールオブジェクトの生成 public class SpecificationFactory { public static Specification<TaxiApplication> getManagerTaxiUsageSpecification() { // 理由記述が必要な基準料金等の条件はDB等から取得できるものとします int freeThreshold = CompanyRuleRepository.getTaxiRule().freeThreshold; return getTaxiUsageSpecification(freeThreshold); } }
タクシー利用ルールが Specification<TaxiApplication>
のオブジェクトであることに変更はありませんので Logic クラスに修正は必要ありません。
このように Parameterized Specification ではパラメータを使用することでルールの条件を調整することができます。 今回はパラメータを一つだけしか渡していませんが、ルールに含まれる複数の条件にパラメータを渡したり、一つの条件に複数の複数のパラメータを渡したりすることも可能です。
また、オブジェクトの生成ごとに異なるパラメータを渡すことができますので、例えば従業員と管理職で制限金額が異なる場合などに、ロジックは共通のまま異なるルールオブジェクトを作成することができます。
一方、ルールに直接条件が書かれているのは変わりませんので、条件の追加や削除などの要件に対応するにはコードの修正が必要になります。
Composite Specification
最も柔軟で動的なルールを作成できるのが Composite Specification です。 いままでの方式ではルールごとに Specification インターフェースを実装したクラスを作成していましたが、Composite Specification では条件ごとに独立したクラスを作成し、Composite の名のとおりそれらの条件クラスを組み合わせて最終的なルールを構成できるようにします。
今回のケースでは「料金上限」と「理由記述」の2つの条件がありますので、それらの条件ごとに Specification インターフェースを実装したクラスを作成します。
// 料金上限条件クラス public class MaxFeeSpecification implements Specification<TaxiApplication> { private final int maxFee; MaxFeeSpecification(int maxFee) { this.maxFee = maxFee; } @Override public boolean isSatisfiedBy(TaxiApplication candidate) { return candidate.fee() < maxFee; } } // 理由記述条件クラス public class ReasonSpecification implements Specification<TaxiApplication> { @Override public boolean isSatisfiedBy(TaxiApplication candidate) { return candidate.description().length() > 0; } }
条件の組み合わせを行うクラスを実装します。これも Specification インターフェースを実装します。 Specification インターフェースの結果はブール値ですので、AND, OR, NOTそれぞれの論理演算を行う条件クラスがあればすべての組み合わせに対応できます。
// AND条件 public class AndSpecification<T> implements Specification<T> { private final Specification<T> left; private final Specification<T> right; public AndSpecification(Specification<T> left, Specification<T> right) { this.left = left; this.right = right; } @Override public boolean isSatisfiedBy(T candidate) { return left.isSatisfiedBy(candidate) && right.isSatisfiedBy(candidate); } } // OR条件 public class OrSpecification<T> implements Specification<T> { private final Specification<T> left; private final Specification<T> right; public OrSpecification(Specification<T> left, Specification<T> right) { this.left = left; this.right = right; } @Override public boolean isSatisfiedBy(T candidate) { return left.isSatisfiedBy(candidate) || right.isSatisfiedBy(candidate); } } // NOT条件 public class NotSpecification<T> implements Specification<T> { private final Specification<T> specification; public NotSpecification(Specification<T> specification) { this.specification = specification; } @Override public boolean isSatisfiedBy(T candidate) { return !specification.isSatisfiedBy(candidate); } }
そして、これらの条件を組み合わせればルールを表すオブジェクトを作成できます。
// タクシー利用ルールオブジェクトの生成 public class SpecificationFactory { public static Specification<TaxiApplication> getTaxiUsageSpecification() { int freeThreshold = CompanyRuleRepository.getTaxiRule().freeThreshold; // 条件を組み合わせてルールを作成します return new OrSpecification( // 以下のいずれかの条件を満たすこと new MaxFeeSpecification(freeThreshold), // 料金が指定金額以下 new ReasonSpecification()); // 理由が記述されている } }
今回のようにルール単純なケースでは実装が複雑になっただけのように見えるかもしれませんが Composite Specification には以下のようなメリットがあります。
条件の再利用性が向上する
条件が個々のクラスに独立したことにより、条件だけを再利用することが可能になります。
例えば、タクシー利用ルールに「料金は○万円未満」の条件が追加されたとしても、(従来は理由の記述が必要な料金の条件判断に利用していた)MaxFeeSpecification
クラスを使用し、以下のように実装することができます。
// タクシー利用ルールオブジェクトの生成 public class SpecificationFactory { public static Specification<TaxiApplication> getTaxiUsageSpecification() { int freeThreshold = CompanyRuleRepository.getTaxiRule().freeThreshold(); int limitFee = CompanyRuleRepository.getTaxiRule().limitFee(); return new AndSpecification( // 上限は必須条件のためAND new MaxFeeSpecification(limitFee), // 上限を超えていないこと new OrSpecification( // その他の条件は従来どおり new MaxFeeSpecification(freeThreshold), new ReasonSpecification())); } }
テスト性が向上する
その他の方式ではルール全体で一つのクラスであったため、テストを行う場合はルール全体を対象とするしかなく、それに含まれる条件ごとにテストを行うことはできませんでしたが、Composite Specification では条件ごとにクラスを作成するため、それぞれの条件の独立したテストを行うことが可能になります。
ルールと条件を統一したインターフェースで扱える
最終的なルールである SpecificationFactory+getTaxiSpecification()
の戻り値オブジェクトも、それを構成する条件である MaxFeeSpecification
, ReasonSpecification
や AND, OR, NOT の論理条件も、すべて Specification インターフェースを実装したものであるため、相互に組み合わせや代替が可能です。
(ルールと条件を区別していたのは私の都合でしたので当然ではありますが…)
例えばあるロジックでは (A && B)
のルールが利用されており、他のロジックでは ((A && B) || C)
のルールが利用されていた場合、(A && B)
で X
というルールクラスを作成すれば、前者のロジックで X
を利用できるのはもちろん、後者のロジックでも X
を条件として (X || C)
として利用することができます。
この場合、テストも X
に対して行えるため、後者のルールのテストも単純化することができます。
動的にルールを構成できる
条件がオブジェクトとして独立しているため、これらを動的に生成し、組み合わせを行って自由にルールを作成することができます。 もちろん利用される個々の条件はあらかじめ実装しておく必要がありますし、それらを組み合わせてルールを構成するロジックの実装の難易度は高いかと思いますが、要求の変化があるたびにコードを変更する必要がなくなるため、さまざまなユーザの要求に迅速に対応しやすくなります。
楽楽精算の「申請ルール」機能のように、ユーザが自由にルールを設定できる要件にも対応できます。(なお、楽楽精算の実際の実装とは異なる可能性があります。)
まとめ
Specification パターン は、基本的にインターフェース1つ、メソッド1つで構成される単純な構成でありながら非常に強力な効果が得られるパターンです。
そして、活用がしやすく実践的でありながら、関数プログラミングや論理プログラミングといったパラダイムにも触れることができ、興味深く勉強できる利点があります。(個人の感想です。)
参考文献には、拡張として2つのルールや条件の包摂を判断する手法 [1] や、条件判断の実装を外部に依存する(させる)ことでSQL等の実装を使用して抽出時のパフォーマンスを上げる方法 [2] も述べられておりますので、ぜひ参考にしてみてください。
関連するデザインパターン
Strategy パターン
Specification パターンはルールをインターフェースに実装し、オブジェクトによってふるまいを変化させることができますので、本質的には Strategy パターン [1] であると言えます。
Composite パターン
Composite Specification では、ルールと条件を同一のインターフェースで扱うことでそれらを再帰的に構成することを実現しています。これはその名のとおり Composite パターンを適用したものです。
Interpreter パターン
Composite Specification で動的にルールを構成する場合、Interpreter パターンで条件オブジェクトを組み合わせて最終的なルールオブジェクトを構成することが可能です。
参考文献
- Eric Evans and Martin Fowler. "Specifications". https://www.martinfowler.com/apsupp/spec.pdf
- Elic Evans. エリック・エヴァンスのドメイン駆動設計. 翔泳社, 2011, 576p.
- Martin Fowler. エンタープライズアプリケーションアーキテクチャパターン. 翔泳社, 2005, 576p.
補足
上の Composite Specification の実装では AND, OR, NOT の条件を独立したクラスとして実装しましたが、Java 8 以降の interface の default 実装を使用することも可能です。
public interface Specification<T> { boolean isSatisfiedBy(T candidate); default Specification<T> and(Specification<T> other) { return candidate -> isSatisfiedBy(candidate) && other.isSatisfiedBy(candidate); } default Specification<T> or(Specification<T> other) { return candidate -> isSatisfiedBy(candidate) || other.isSatisfiedBy(candidate); } default Specification<T> not() { return candidate -> !isSatisfiedBy(candidate); } }
この場合のルールの組み立ては以下のように行います。
public class TaxiRule { public static Specification<TaxiApplication> getRule() { int freeThreshold = CompanyRuleRepository.getTaxiRule().freeThreshold(); int limitFee = CompanyRuleRepository.getTaxiRule().limitFee(); return new MaxFeeSpecification(limitFee) .and( new MaxFeeSpecification(freeThreshold) .or(new ReasonSpecification())); } }
また、上の Specification<T>
と同様の実装が Java 8 以降で関数型インターフェース Predicate<T>
として標準実装されているため、こちらを使用することも可能です。