0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

テーブルモジュールパターンからドメインモデルへ(SAP CAP設計パターンの変更)

Last updated at Posted at 2024-05-05

テーブルモジュールパターンからドメインモデルへ(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のSELECTINSERTなどがデータフローパイプラインの起点や終点となる。

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, /* ... */

変更後は、レポジトリContractRepositoryread()関数にクエリが移動した。変更前と同様にクエリを実行する。その結果得られたデータセットdatasetから、集約ルートのクラスのインスタンスを生成して返す。

集約ルートはContract。クラスContractを定義しておき、レポジトリで生成する。集約ルートなので、関連するオブジェクトも合わせて生成する。すべてが正常に生成されないとContractの生成ができない。

ソフトウェアライセンス契約のキーID、契約日whenSignedはプリミティブ型のままとした。

契約金額amountnumberではなく、クラス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を、ふたつのクラスCompletedRecognitionsStrategyThreeWayRecognitionsStrategyが実装している。

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;
  }
  /*...*/
}

構造:複数のクラスの関連によりドメイン知識が表現される

テーブルモジュールの場合には、ドメインロジックはテーブルモジュールクラスですべてであった。データセットのためにデータ型を使っていたが、それ以上のドメイン知識はソースコードの内部を見ないとわからない。とはいえ、テーブルモジュールにすべてが書かれているので、そこまで複雑で無ければ負担にはならないと思う。

ドメインモデルの場合には、クラス図にもドメイン知識が表現されて面白さがある。ドメイン知識そのものが複雑であることに変わりはないので、クラス図がそれなりに複雑に見えた方が正しい。クラスの命名にドメインの言葉が使われるし、デザインパターンといった設計語彙の蓄積を利用することができる点が強力。

  1. https://qiita.com/skusunoki/items/f59944c3ae75501e3f86

  2. 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

  3. テーブルデータゲートウェイ( Table Data Gateway )とローデータゲートウェイ(Row Data Gateway)を合わせてゲートウェイと呼ぶ。DAOと呼ばれることもある。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?