テーブルモジュールパターンからドメインモデルへ(SAP CAP設計パターンの変更)
SAPのサーバーサイド用のフレームワークであるCAPの設計に、テーブルモジュールが使えることを示した1。CAPがフレームワークとして提供する配列(二次元表)をテーブルモジュールクラスの生成に使い、ドメインロジックを実現する。データソース、UI、ドメインロジックの3者が同一の二次元表で実現できるとき、テーブルモジュールパターンは有効。ただ、ドメインロジックが二次元表でおさまらないことも多い。それに気づいた段階でドメインモデルに移行していくことになる。
CAPのフレームワークから見て、どのようなときにテーブルモジュールパターンが使え、その限界がどこにあるのかについてSAP開発コミュニティで議論があってもいいと思う。本記事では、テーブルモジュールパターンからドメインモデルへ移行するときの変化点について示す。
テーブルモジュールクラスの分解
テーブルモジュールでは、CDSの定義により使える配列(データセット)を利用した。その部分は生かしたい。そこで、ドメインモデルだけを抽出する。この抽出の境界をどこに設定するかによって、パターンが異なる。
CAPのどこで境界線をひくか
テーブルモジュールを分解する前に、そもそもCAPでどのような分け方ができるかを確認しておきたい。
境界によるアーキテクチャ分類
CAP設計のどこかにデータソースレイヤとドメインロジックレイヤの境界線を引く。すると境界線をまたぐオブジェクトが必要になる。境界はまたぐオブジェクトで表すことができる。SQL、DTO、データセット、個別ドメインオブジェクト、集約の5パターンがある。
テーブルモジュールパターンはデータセットを境界とした。本記事では、レポジトリによりドメインモデルに移行する。他にも個別ドメインオブジェクトに境界を引くこともできる。
境界 | データソースパターン | ドメインロジックパターン | CAPで実現できるか |
---|---|---|---|
SQL | クエリ関数 | データフローパイプライン2 | 可 |
DTO | ローデータゲートウェイ | トランザクションスクリプト | 素朴なケースで可 |
データセット | テーブルデータゲートウェイ | テーブルモジュール | 二次元表に限定して可 |
個別ドメインオブジェクト | データマッパ | ドメインモデル | 可 |
集約 | レポジトリ | ドメインモデル | 可 |
SQLを境界にする:データフローパイプライン
先にドメインモデルではない(オブジェクト指向ですらない)ものを確認しておく。
クエリ関数でSQLを生成する。CAPのCQLもSQLを生成するJavaScriptの関数である。CQLはSELECT.from().where().columns()
のようなまるでSQLのような見た目をしている。CQLはクエリ関数といえる。
データフローパイプライン2は、関数型プログラミングでドメインロジックを表現する方法。IOからIOの間を関数でつなぐ。関数が連続することもある。それをデータフローパイプラインという。そのパイプラインでビジネスロジックが表現される。関数の引数と返り値にTypeScriptの型を与えるが、この型をビジネスコンセプトと対応付ける。データフローパイプラインは型によって安全につながる。
このパターンでは、CAPのCQLはビジネスロジックの一部となる。CQLのSELECT
やINSERT
などがデータフローパイプラインの起点や終点となる。
DTO/データセットを境界にする:トランザクションスクリプトまたはテーブルモジュール
DTOのようにロジックを中に一切持たないオブジェクトを返すのはゲートウェイ3。テーブルモジュールパターンに読み替えれば、DTO(の配列)はデータセット。
CAPにはサービスクラスが継承するcds.ApplicationService
にCRUDメソッド(read()
メソッドなど)がある。.read()
メソッドはオブジェクトの配列を返してくれる。当然、返された配列にビジネスロジックはついていない。DTOといえる。すなわち、cds.ApplicationService
はゲートウェイといえる。
ODataからのAPIがキーを指定した場合にはローデータゲートウェイとして機能し、キーを指定しない場合にはテーブルデータゲートウェイとして機能する。継承したクラス内でゲートウェイ機能を使えるだけではなく、cds.connect.to()
で接続して外部からもゲートウェイ機能を使える。
CAPでは、フレームワークの境界がDTOまたはデータセット。ここで境界線を引くと、トランザクションスクリプトかテーブルモジュールになる。
ドメインオブジェクトを境界とする:ドメインモデル
ドメインオブジェクトを返すためには、(TypeScriptでクラスベースの開発をする場合)クラスの定義と生成の必要がある。それを行ってくれるのがデータマッパ。生成のバリデーションは各ドメインオブジェクトが行う。
データマッパはCAPでは提供していない。CAPはORMではない。必要なら開発者が独自実装する。だが、CAPでデータマッパの開発の機会は少ないと思う。
仮に、CDSで深い階層が生じたとしても、Deep Readをせずに各エンティティから別々にデータセットを取得し、各要素からドメインオブジェクトを生成する実装をしたとしたらデータマッパとなる。どうせ実装するならレポジトリにするだろう。
集約を境界とする:ドメインモデル
複数あるドメインオブジェクトを集約としてひとまとめにして、相互の整合性を保証した状態で出し入れを行うのはレポジトリ。ドメインオブジェクト単独では整合性の保証ができないので、集約ルートで整合性を保証する。レポジトリは集約ルートに限定する役割となる。
集約のデータ範囲は自分が実装する側の場合にはDeep Readで関係するデータをまとめて取得するイメージ。S/4HANAなどの他のサービスを使う場合も集約の単位で提供されているはず。その単位でドメインオブジェクトを作る。その内部もドメインオブジェクトで構成される。
集約で境界線を引きレポジトリを作るパターンもドメインモデルパターンの一種。
分解のイメージ
本記事では、テーブルモジュールからドメインモデルに変更する。具体に入る前にイメージをつけたい。
OとRの分離と集約の構成を行うイメージ
変更前はテーブルモジュールだった。ドメインモデルの機能を抽出することで、ドメインオブジェクトを切り離す。個別ドメインオブジェクトを生成するのではなく、集約を生成する。その結果レポジトリとなる。
分解の実際
サービスクラス:レポジトリの生成
もとはテーブルモジュールインスタンスを生成。生成されたインスタンスのビジネスロジックを呼び出していた。データセットを抽出するクエリはサービスクラスからdeepRead.call()
を呼び出していた。
export class RevenueCalculationServiceTM extends cds.ApplicationService {
this.on("calculateRecognitions", this.entities.Contracts,
/* ... */
const contracts = new Contracts( await deepRead.call(this, /* ... */));
contracts.calculateRecognitions(/* ... */));
/* ... */
変更後はドメインモデルの生成の前に、まずレポジトリを生成。レポジトリクラスはContractRepository
。CAPのcds.ApplicationService
がデータアクセスの機能を提供することは変わらないので、データアクセス機能をレポジトリにインジェクションするため、this
(サービスクラス)を引数として渡す。
生成されたレポジトリインスタンスの.read()
メソッドによりドメインモデルのインスタンスを生成する。
export class RevenueCalculationServiceDM extends cds.ApplicationService {
this.on("calculateRecognitions", this.entities.Contracts,
/* ... */
const repository = new ContractRepository(this);
const aContract = await repository.read(/* ... */);
aContract.calculateRecognitions();
/* ... */
集約の生成
元はデータセットを外部の関数をつかって取得していた。そのデータセットはテーブルモジュールクラスのコンストラクタ引数としていたのは上記でみた。
async function deepRead( this: RevenueCalculationServiceTM, contractID: number): Promise<Contract[]> {
return await SELECT.from(this.entities.Contracts, /* ... */
変更後は、レポジトリContractRepository
のread()
関数にクエリが移動した。変更前と同様にクエリを実行する。その結果得られたデータセットdataset
から、集約ルートのクラスのインスタンスを生成して返す。
集約ルートはContract
。クラスContract
を定義しておき、レポジトリで生成する。集約ルートなので、関連するオブジェクトも合わせて生成する。すべてが正常に生成されないとContract
の生成ができない。
ソフトウェアライセンス契約のキーID
、契約日whenSigned
はプリミティブ型のままとした。
契約金額amount
はnumber
ではなく、クラスMoney
を使う。クラスMoney
について本ドメインモデルのコアではないが、PoEAAではかなりの分量を用いて詳述されている。ファクトリMoney.dollars()
を使い、コンストラクタは使用していない。
ソフトウェアプロダクトproduct
は、クラスProduct
を使う。ファクトリProduct.factoryProduct()
を使う。後述するが収益認識の計算方法もファクトリで定義される。
収益認識revenueRecognitions
も、クラスRevenueRecognition
が使われる。
export class ContractRepository {
/*...*/
public async read(contractID: number): Promise<Contract> {
const dataset = await SELECT.from(this.srv.entities.Contracts /* ... */
return new Contract(
dataset[0].ID,
dataset[0].whenSigned,
Money.dollars(dataset[0].amount),
Product.factoryProduct(dataset[0].product.type),
dataset[0].revenueRecognitions.map(
(r: any) =>
new RevenueRecognition(new Money(r.amount, Currency.USD()), r.date),
),
);
}
ビジネスロジックの配置
テーブルモジュールでビジネスロジックは、コード行数の多い長い関数となっていた。これがどうなったかを見る。
ビジネスロジックメソッドのキー引数はなくなる
テーブルモジュールの場合には、引数にキーを取り、データセットからそのキーのレコードを検索し、レコード中身もメソッドの冒頭にて取得していた。
export class Contracts {
public calculateRecognitions(contractID: number): void {
this._index = this._dataset.findIndex(
(contract) => contract.ID == contractID,
);
const contractRow = this._dataset[this._index];
/*...*/
}
}
ドメインモデルの場合には、オブジェクト生成時にオブジェクトが知った状態になっているので、引数にキーは不要。Product
別に収益認識の計算を行うので、収益認識の計算はProduct
にフォワード。
class Contract {
/* ... */
public calculateRecognitions(): void {
let rr = this.product.calculateRecognitions(this);
this._revenueRecognitions = [...rr];
}
}
ガード節:ファクトリに移動
テーブルモジュールの場合には、ガードが必要な要素は検索されたレコードに対して行っていた。
if (contractRow === undefined) return;
if (contractRow.product === undefined) return;
if (contractRow.amount === undefined) return;
ドメインモデルの場合には、各オブジェクトのファクトリで行っている。ファクトリで行われるということから、重要ではあるもののコアではない。このような知識はビジネスルールから除外される。ガード節は汎用サブドメインといえると思う。
class Money {
/*..*/
public static dollars(amount: number): Money {
assert(amount >= 0, "Amount must be greater than or equal to 0");
return new Money(amount, Currency.USD());
}
/*..*/
}
class Product {
/*...*/
public static factoryProduct(type: "WP" | "SS" | undefined): Product {
return type === "WP"
? Product.factoryWordProcessor()
: type === "SS"
? Product.factorySpreadsheet()
: Product.factoryUndefinedProduct();
}
/*...*/
}
条件分岐:アルゴリズムの分岐はストラテジーパターンに
テーブルモジュールパターンの場合には、収益認識計算処理を区別するため、条件分岐していた。条件分岐に if
を使っていた。
if (contractRow.product.type === "WP") {
/*...*/
}
if (contractRow.product.type === "SS") {
/*...*/
}
ドメインモデルの場合には、ストラテジーパターンを使って収益認識計算アルゴリズムを切り替える。インタフェースRecognitionsStrategy
を、ふたつのクラスCompletedRecognitionsStrategy
とThreeWayRecognitionsStrategy
が実装している。
Product
クラスはRecognitionStrategy
をコンストラクタ引数として与えられている。レポジトリからProduct
を生成するファクトリを呼び出すが、type
がワードプロセッサの場合にはCompletedRecognitionsStrategy
をコンストラクタ引数にわたし、スプレッドシートの時にはThreeWayRecognitionsStrategy
をコンストラクタ引数に渡す。
interface RecognitionsStrategy {
calculateRecognitions(contract: Contract): RevenueRecognition[];
}
class CompletedRecognitionsStrategy implements RecognitionsStrategy {
/*...*/
}
class ThreeWayRecognitionsStrategy implements RecognitionsStrategy {
/*...*/
}
class Product {
/* ... */
public static factoryWordProcessor(): Product {
return new Product( 1, "Word Processor", "WP", new CompletedRecognitionsStrategy() );
}
public static factorySpreadsheet(): Product {
return new Product( 2, "Spreadsheet", "SS", new ThreeWayRecognitionsStrategy(30, 60) );
}
/* ... */
}
ありがちで煩雑な計算処理:再利用可能なクラスに配置
テーブルモジュールパターンの場合には、ありがちで煩雑な計算処理はプライベートメソッドに切り出していた。
export class Contracts {
/* ... */
public calculateRecognitions(contractID: number): void {
/* ... */
const alloc_amount = this.allocate_amount(amount, 3);
/* ... */
}
/* ... */
private allocate_amount(amount: number, by: number): number[] {
const lowResult = Math.floor((amount / by) * 100) / 100;
const highResult = lowResult + 0.01;
const result = [];
let remainder = (amount * 100) % by;
for (let i = 0; i < by; i++) {
result.push(i < remainder ? highResult : lowResult);
}
return result;
}
/* ... */
}
ドメインモデルの場合には、Money
クラスに切り出している。再利用可能な場所に置くという発想。実装側は単に置いた場所を変えるだけの変更だが、呼び出す側は再利用性が高まっている。リファクタリングによって別のクラスにこのロジックを移動したとしても同じくallocate()
が使える。わかりやすいところでは、テストでも直接allocate()
が使えるようになった。
class ThreeWayRecognitionsStrategy implements RecognitionsStrategy {
/* ... */
calculateRecognitions(contract: Contract): RevenueRecognition[] {
/* ... */
const alloc_amount = contract.amountMoney.allocate(3);
/* ... */
}
/* ... */
}
class Money {
/*...*/
public allocate(by: number): Money[] {
const lowResult = this.devide(by);
const highResult = lowResult.add(
new Money(1 / this.centFactor(), this.currency()),
);
const result = [];
let remainder = this.amount().amount_internal % BigInt(by);
for (let i = BigInt(0); i < BigInt(by); i++) {
result.push(i < remainder ? highResult : lowResult);
}
return result;
}
/*...*/
}
構造:複数のクラスの関連によりドメイン知識が表現される
テーブルモジュールの場合には、ドメインロジックはテーブルモジュールクラスですべてであった。データセットのためにデータ型を使っていたが、それ以上のドメイン知識はソースコードの内部を見ないとわからない。とはいえ、テーブルモジュールにすべてが書かれているので、そこまで複雑で無ければ負担にはならないと思う。
ドメインモデルの場合には、クラス図にもドメイン知識が表現されて面白さがある。ドメイン知識そのものが複雑であることに変わりはないので、クラス図がそれなりに複雑に見えた方が正しい。クラスの命名にドメインの言葉が使われるし、デザインパターンといった設計語彙の蓄積を利用することができる点が強力。
-
https://www.amazon.co.jp/exec/obidos/ASIN/B07B44BPFB Domain Modeling Made Functional: Tackle Software Complexity with Domain-Driven Design and F# (English Edition) ↩ ↩2
-
テーブルデータゲートウェイ( Table Data Gateway )とローデータゲートウェイ(Row Data Gateway)を合わせてゲートウェイと呼ぶ。DAOと呼ばれることもある。 ↩