DRY 原則とビジネスロジック

Kenta Kosugi
21 min readFeb 14, 2023

--

背景

ビジネスロジックという言葉を聞いて、ワークフローを思い浮かべる人が多すぎて違和感を持ったため、それだけではないということを示したいと思う。

今回考えるユースケース

営業が注文書を作成するシステムのドメインについて考えてみよう。SaaS の契約の場合の見積りだ。

SaaS の見積りを実施する際に、ビジネス的に必要な観点を洗い出してみる。

  • Product の組み合わせ
    組み合わることが不可能な Product の組み合わせも存在するし、他の Product の購入が前提になっているものも存在するはずだ。
    これは SaaS に限ったことではない。
  • 契約の形態
    解約なのか新規なのか追加契約なのか、あるいは一旦解約して Upsell し新規に契約を結びなおす Early Renewal なのかの観点も必要だ。
  • 契約期間
    多くの場合サブスクリプションなので、契約期間が必要になる。

さて、上記をオブジェクト志向で表してみると以下のようになった。なお説明がしやすいという都合上 public で宣言しているものの、本来は修飾子を private や protected 等にし、ドメインモデルの各要素に容易にアクセスできないようにする必要がある。

クラス図

ポイントは以下である。

  • 注文書(OrderForm)クラスは List 型で Contract 型を複数所持している。
  • Contract 型には新規契約(Newly) か解約(Cancellation)、あるいはその両方が格納される。
  • Contract 型は同様に契約期間を表す Term を保持する。
  • ItemLine は新規契約もしくは解約するプロダクトの一覧を保持する。

データ構造を JSON にしてみると、その階層構造が理解しやすくなるかと思う。

{
"orderForm" : {
"id" : "ORDNUMBER-000000X",
"title": "Order Form for XXXXXX株式会社",
"accountId": "XXXXX",
"contracts": [
{
"number": "contracts-001",
"contractType": "Cancellation",
"term": {
"startDate": "2022-01-01",
"endDate": "2022-12-31",
},
"itemLine": {
"products": [
{
"number": "ITEMLINE000001",
"productCode" : "SKU000000001",
"name": "製品XXXXXXXX",
"monthlyPrice": 10000,
"units": 1000,
},
{
"number": "ITEMLINE000002",
"productCode": "SKU000000002",
"name": "製品YYYYYYYY",
"monthlyPrice": 20000,
"units": 1000,
},
{
// 略
}
]
},
},
{
"number": "contracts-002",
"contractType" : "Newly",
"term": {
"startDate": "2023-01-01",
"endDate": "2025-12-31",
},
"itemLine": {
"products": [
{
"number": "ITEMLINE0000010",
"productCode": "SKU00000001",
"monthlyPrice": 10000,
"units": 3000,
},
{
// 略
},
]
},
],
},
}

さて、上記のクラス図には重要な情報が欠けている。SaaS を展開する会社では年間いくら支払ってもらっているかが重要な指標になる。そのため、年間の支払額が必要になるのだが、上記には定義されていない。

年間の支払額を決定するにはプロダクトをどのくらい割り引いているのかを示す割引率も必要だ。

まずは割引率を考えよう。

割引率をどのクラスに追加するのかはビジネスの要件によって異なる。プロダクトごとに異なる割引率を適用できるのであれば、Product クラスに割引率を持たせるべきだし、Contract 単位で割引率を適用したい(その下にぶら下がる Product には一律同一)場合は Contract の直下に定義するべきだ。

今回はプロダクトごとに割引率を設定できるようにするため、Product クラスで所持することにする。

type Product {
number: ID!
productCode: String!
name: String!
monthlyPrice: Float!
units: Int!
dicountRate: Float!
}
クラス図

さて、割引率のデータをどのクラスが持つのかが決まったら年間支払額を考える。しかし、以下のようなドメインモデルを定義するのは待って欲しい。

年間支払額のプロパティが追加された

OrderForm クラスのプロパティとして annualPrice が追加されている。

しかしこれは DRY 原則に違反する。

DRY 原則

DRY 原則とは Don’t Repeat Yourself の略である。達人プログラマーの書籍の中で以下のように記載されている。

「すべての知識はシステム内において、単一、かつ明確な、そして信頼できる表現になっていなければならない」

一言で表すと知識の重複を許さないこと、である。

知識が重複してしまうと同じ変更理由で複数箇所に手をいれなければいけない。これにより変更忘れやテスト忘れが発生し、技術的負債てんこもりのシステムを作り出す原因となる。

この場合、片方を変更するのであればもう片方も変更しなければなりません。さもなければ異星人のコンピューターのようにプログラムは矛盾につまずくことになるのです。これはそのことを憶えていられるかどうかという問題ではありません。いつ忘れてしまうかという問題なのです。

NOT DRY の例1 : 同一データの重複

複数部門で同じデータをそれぞれ持つこと、これは DRY 原則に違反する。例えば、証券会社のリスク部門が上場投資信託(ETF)の株の構成銘柄をデータベースに保存して個別の株の発行体リスクを管理していたとする。

一方、運用部門(IT における運用ではない)も顧客に提出する運用レポートに記載するため ETF の構成銘柄を別に所持していたとする。

これは DRY 原則に違反する。

ETF の構成銘柄は定期的に組み替えが発生するが、同じ理由でリスク管理部門と運用部門がデータを変更する必要があるためである。

そして、サイロごとに同じデータを所持することで、データの定義が異なり(ある部門では varchar(8) で、ある部門では varchar(64) で宣言されている等、そもそも格納できるデータ長に違いがある等)、表記揺れを大量に発生させる。いずれシステムを統一しようなんて話になった際、双方を比較する必要がでてくる。大量の工数が必要になるわけである。

AI・ML においてデータがあるにもかかわらず、駆使できていない会社が存在する理由は主にこれだ。

NOT DRY の例2 :ロジックの重複

個別のシステムが消費税率を計算するロジックを実装していたとする。増税された場合、各システムが同一の理由で複数箇所を変更する必要がある。

これも DRY 原則に違反する。

NOT DRY の例3 :計算で取得可能なデータの永続化

さて、今回主に声を大にして言いたいのはこの部分である

年間支払額(annualPrice)は、各 Product が所持している価格(monthlyPrice)、数量(units)、割引率、それから Contract が所持している期間(Term)から計算で求めらる。

このように計算で求められるものをプロパティとして所持することは DRY 原則に違反する。

もっと簡単な話を。契約期間を表す Term クラスをみてみよう。

Term

StartDate と EndDate が String で宣言されているのは、GraphQL で実装を試したからである。GraphQL では Date 型を利用しようとすると Scalar 型を利用する必要がある。Scalar 型を利用するとよりドメインモデルが洗練されたものになる。

このクラスを変更し、months という EndDate から StartDate の減算で計算可能なプロパティを追加した。Product が所持する monthlyPrice に契約期間が持つこの months を乗算することで年間の契約金額が求められるという寸法だ。

months を追加

さて、このクラスをデータベースに永続化すると以下のようになる。

データベースに永続化したケース

この months のように計算で求められるデータをプロパティとして所持することや、データベースに永続化することは DRY 原則に違反する。なぜなら、StartDate や EndDate のいずれか、または双方が変更になった時、Months も同じ理由で変更する必要があるからだ。

StartDate、EndDate の変更に伴って Months も合わせて直す必要があることを覚えていられるかどうかが問題なのではない。(担当者等が変わって)いつか忘れてしまうことが問題なのである。

この場合、片方を変更するのであればもう片方も変更しなければなりません。さもなければ異星人のコンピューターのようにプログラムは矛盾につまずくことになるのです。これはそのことを憶えていられるかどうかという問題ではありません。いつ忘れてしまうかという問題なのです

本来あるべき姿

上記を見てきて分かったことは Term や OrderForm クラスは本来は以下のようになっている必要がある。

ビジネスロジック getAnnualPrice() の実装
ビジネスロジック getMonths() の実装

違いは明白。オブジェクト指向における、メソッドとして実装する。

REST API や GraphQL の場合

では GraphQL の場合どうだろうか。

type Term {
startDate: String!
endDate: String!
months : Int!
}

実は REST API や GraphQL においては、months がビジネスロジック(計算で導き出せる値)なのかプロパティ(DB 等に永続化されたデータ)なのかは気にしない。REST API の場合は、メソッドを JSON のデータに、GraphQL の場合はリゾルバーでビジネスロジックと紐づけることができるからだ。

例えば、GraphQL の場合、Apollo Server + JavaScript ではリゾルバーにアロー関数式を定義することができ、この中でビジネスロジックを記述することが可能である。

   Term: {
months: (Term) => {
// JavaScript で EndDate - StartDate を計算させる
}
},

Spring Boot + GraphQL の場合、Term クラスに getMonths() メソッドを定義することでリゾルバーとして自動認識される(Transient アノテーションを付与しているのはデータベースに永続化させないため)。

@Embeddable
public class Term {

@Column(name = "start_date")
public String startDate;

@Column(name = "end_date")
public String endDate;

protected Term() {
}

@Transient
public int getMonths() {
int ret = 0;
try {
var startDate = Calendar.getInstance();
var endDate = Calendar.getInstance();

startDate.setTime(DateFormat.getDateInstance().parse(this.startDate.replace("-", "/")));
endDate.setTime(DateFormat.getDateInstance().parse(this.endDate.replace("-", "/")));

endDate.add(Calendar.DAY_OF_MONTH, 1);

int year = endDate.get(Calendar.YEAR) - startDate.get(Calendar.YEAR);
int months = endDate.get(Calendar.MONTH) - startDate.get(Calendar.MONTH);
ret = 12 * year + months;
} catch (java.text.ParseException e) {

}
return ret;
}
}

実際に Spring Boot + GraphQL で実装されたクエリーを実施してみよう。 getMonths() が呼び出され、ビジネスロジックで計算された結果が返却される。

GraphQL で Term の getMonths() メソッドを呼び出し

ドメイン駆動設計

ドメイン駆動設計では、ドメインモデルにビジネスロジックを閉じ込めると言う表現をしている。months を計算するのに、Term 以外の他のクラスに実装するなという極当たり前の話である。データとロジックも一緒に管理しましょうという話。

そもそもデータ(プロパティ)とロジックを別々の場所で管理することは DRY 原則に違反する。データかロジックどちらかに変更が加わった際、変更漏れ、テスト漏れを発生させる可能性が高くなる。同じ知識は同じ場所に集約して記述する。

注文書を作成するシステムの UI を開発する際は StartDate や EndDate を入れる画面は作ったとしても months を入れる画面は作ってはならない。仮に表示する場合は編集不可能な領域として、months が自動計算された結果を表示させるべきである。

また計算で求められるデータだけがビジネスロジックという訳ではない。

先に挙げたものはビジネスロジックそのものである。

  • Product の組み合わせ
    組み合わることが不可能な Product の組み合わせも存在するし、他の Product の購入が前提になっているものも存在するはずだ。
    これは SaaS に限ったことではない。
  • 契約の形態
    解約なのか新規なのか追加契約なのか、あるいは一旦解約して Upsell し新規に契約を結びなおす Early Renewal なのかの観点も必要だ。
  • 契約期間
    多くの場合サブスクリプションなので、契約期間が必要になる。

Product の組み合わせを管理するクラスは、今 Product として何が登録されているかを知ることが可能なクラスである必要がある。以下の JSON を見てもらえれば ItemLine クラス以上で実装する必要があるのは明らかである。

{
"orderForm" : {
"id" : "ORDNUMBER-000000X",
"title": "Order Form for XXXXXX株式会社",
"accountId": "XXXXX",
"contracts": [
{
"number": "contracts-001",
"contractType": "Cancellation",
"term": {
"startDate": "2022-01-01",
"endDate": "2022-12-31",
},
"itemLine": {
"products": [
{
"number": "ITEMLINE000001",
"productCode" : "SKU000000001",
"name": "製品XXXXXXXX",
"monthlyPrice": 10000,
"units": 1000,
},
{
"number": "ITEMLINE000002",
"productCode": "SKU000000002",
"name": "製品YYYYYYYY",
"monthlyPrice": 20000,
"units": 1000,
},
{
// 略
}
]
},
},
{
"number": "contracts-002",
"contractType" : "Newly",
"term": {
"startDate": "2023-01-01",
"endDate": "2025-12-31",
},
"itemLine": {
"products": [
{
"number": "ITEMLINE0000010",
"productCode": "SKU00000001",
"monthlyPrice": 10000,
"units": 3000,
},
{
// 略
},
]
},
],
},
}

仮に製品 Aと製品 B を同時に組み合わせることができないというビジネスルールが存在するのであれば、ItemLine が products に add する際に判定を実装する。

契約の形態もビジネスロジックで自動で導き出せる。

  • contracts の中に newly 単独で存在 → 新規契約
  • cancellation 単独で存在 → 解約
  • newly + cancellation の組み合わせで存在 → Early Renewal

と言った具合である。

Design by Contract

Design by Contract という言葉を聞いたことがあるかもしれない。この Design by Contract はビジネスロジックに密接に関与する。

Design by Contract には以下の3つの考え方がある。

  • 事前条件
  • 事後条件
  • 不変条件

事前条件は、あるビジネスロジックが実行される前に満たされている必要がある条件のことである。例えば、上記の OrderForm の例でいうと、新規契約なのか解約なのか Early Renewal なのかは Contract が登録されていないと判定することができない。

事後条件はまさに Product の組み合わせの話である。addProduct() メソッドを呼び出して、Product を追加した結果、製品 A と製品 B が同じ Contract に入っていないことを保証するものである。

不変条件は常に成り立っている条件である。例えば、units は0を含む正の整数であって負の整数は取り得ない。

こうしたロジックはデータのすぐ側(ドメインモデル内)で実装することが必要である。

オーケストレーションのビジネスロジック

多くの人が想像するビジネスロジックはワークフローにおけるビジネスロジックの可能性が高い。

この注文書作成システムで営業が注文書を作成したあと、承認を回す場合の話である。たとえば、深いディスカウント率を設定しているために社長承認のフローが必要になったりするあれだ。

ワークフロー

そもそもワークフローは承認だけを指す言葉ではない。Google Workflow のビジネスユースケースは以下の通りであり、承認はビジネスプロセスの一部である。

Google Workflows のユースケース

ワークフローは常駐している必要はない。イベントドリヴンで起動するサーバーレスであることが多い。

Google Workflows

上記の例ではオブジェクトストレージに File が保存されることによってサーバーレスを叩き起こす仕組みになっている。今回の例だと、営業が見積書を作成して、ステータスを完了にした瞬間にデータベースからチェンジイベントを発生させてワークフローを起動することも可能だ。

また、金融における BIAN ではワークフローではなく各マイクロサービス同士がオーケストレーションする世界を目指し、API が定義されている。

従来のプロセスモデルビューとコンポーネントタイプのモデルの違いは、顧客に提示された住宅ローンを処理する以下のような簡単な例で示すことができます。左側のプロセスモデルでは、ワークフローはより細かいアクションに分解され、(部分的に)自動化されたワークフローとしてプログラムすることができます。一方、右側のコンポーネントビューは、特殊なビジネス機能(コンポーネント)のリンクされたコレクションを定義するだけです。

従来のプロセスモデルでは、エンドツーエンドでリンクされた一連のアクションが合理化され、可能な限り自動化されています。しかし、この一連の自動化されたタスクを実行するためにシステムを構築した場合、その後発生する可能性のあるさまざまなビジネス状況に対応するために、このシーケンスを修正することはあまり簡単ではないかもしれません。この例では、住宅ローンが事前に承認され、物件を見つける前に様々なチェックが行われます。しかし、何らかの理由で、物件を確認した後でないと承認が下りないという新たな要件が発生した場合はどうでしょうか。

逆に、BIANサービスドメインを使用したサービス中心設計では、関係するすべての専門的なビジネス要素が特定されるが、特定の順序は推測されず、トリガーまたはオーケストレーションによるサービス交換を通じて、必要なときに単に相互作用します。それらが正しく実装されていれば、「サービスセンター」は、個々のコンポーネントの動作にほとんどあるいは全く変更を加えることなく、多くの異なる処理シーケンスやバリエーションをサポートすることができるはずです。
この例では、任意の適切なシーケンスやコラボレーションパターンをサポートする柔軟性に加えて、運用能力の再利用の可能性を強調しています。プロセスモデルでは、ロジックとデータはすべて、この特定のビジネスイベントを処理するために設計された処理に組み込まれており、他の場所で再利用するために簡単に分離することができない可能性があります。
これに対し、サービス中心モデルでは、各サービスドメインは自律的に動作し、多くの異なるビジネスイベントに関与するように設計することができます。

BIAN が目指すのは社内のビジネスプロセスが変わるたびに実装しなおす必要があるワークフローを目指しているのではない。各サービスが自律的に動作(自動承認等含む)し、その結果をイベントとして他ドメインに伝えることでオーケストレーションする世界である。

まとめ

ビジネスロジックにはドメインモデルと密接に紐づいたものと、そうではないものの2種類が存在する。多くの場合、ビジネスロジックはビジネスプロセス上で使われている判定ロジックのことと捉えられているがそれだけではない。

各データを正確性を持って保存するための仕組み(バリデーション)や、計算で導き出せるロジック(months、annualPrice)もビジネスロジックである。

特に後者を実装できないプラットフォームには注意が必要である。なぜならデータとロジックが分散された場所に保存されてしまうためである。同じ知識が分散された場所に保存されるのは DRY 原則に違反する。

--

--

Kenta Kosugi

Javaアプリケーションサーバーの開発からCORBA製品のサポート、QA、証券外務員(第一種免許)、ストレージ屋、アーキテクト、SaaS屋と一貫性のない道を歩んでいます。Red Hatに復帰しました。