ドメインオブジェクトとWeb API
データ形式とドメインオブジェクトを変換する際に起こる不一致
JSONとドメインオブジェクトを単純に変換するだけでは、以下の不一致がある場合不都合になる。
関心事の不一致
ドメインオブジェクトの持つ全ての情報がAPIを利用する側で必要とは限らない。また、ドメインオブジェクトが期待するデータ項目がすべてPOSTされるとは限らない。
データ構造の不一致
APIで使うデータ形式はデータだけが関心事であるため、できるだけ単純な構造の方が使いやすくなる。データだけに注目した場合、ロジックの整理を重視したドメインオブジェクトの階層構造をそのまま階層的なデータ構造として表現することはあまり意味を持たない。
変換用の中間オブジェクト
以上の不一致が小さい場合はドメインオブジェクトとJSONの単純なマッピングで済ませることができるが、不一致が大きい場合は変換用の中間オブジェクトを用意したほうがコードをシンプルに保ちやすくなる。
レスポンスオブジェクト
レスポンス用のデータ形式に合わせたレスポンスオブジェクトを生成する。このクラスのファクトリメソッドが構造の違いを吸収する。
<?php class BookResponse { public static function build(Book $book) { // BookオブジェクトからBookResponseを生成する // ファクトリメソッド } }
リクエストオブジェクト
HTTPでPOSTされたリクエストデータをリクエストオブジェクトにマッピングし、ドメインオブジェクトに変換する。
<?php class BookRequest { public function toBook() { // BookRequestからBookオブジェクトを生成する } }
画面とドメインオブジェクトの設計を連動させる
関心事を分けて整理する
画面アプリケーションのコードが複雑になる要因は次の2つ。
- 画面そのものが複雑
- 画面の表示ロジックと業務ロジックが分離できていない
次の方針で関心事を整理すれば、画面アプリケーションの複雑さを改善し、変更を楽で安全にできる。
- さまざまな表示項目やボタンを詰め込んだ何でもできる汎用画面ではなく、用途ごとのシンプルな画面に分ける(タスクベースのユーザーインタフェース)
- 画面周りのロジックから業務ロジックを分離する
利用者の関心事に焦点を当てると、画面デザインとドメインオブジェクトの設計は連動する。この連動がうまく行けば行くほど、ソフトウェアの変更が楽で安全になる。
複雑な画面は異なる関心事が混ざっている
たとえば、注文画面は以下のような関心事の組み合わせである。
- 注文者を特定する情報(氏名や顧客番号)
- 注文した商品と個数
- 決済方法
- 配送手段と配送先
- 連絡方法
ここで注文に必要なデータをすべて持つOrderオブジェクトと、注文データ内容を確認するすべてのロジックを持つOrderService#register()などによって注文機能を実装することが考えられるが、こうした肥大化したクラスやメソッドは変更が大変である。
小さな関心事に分けて考える
注文に関するドメインオブジェクトと注文登録に関するメソッドが次のように分けて考えることができる。
実際の注文登録はこれらを組み合わせてOrderクラスとregisterメソッドを作ればよい。
対象 | ドメインオブジェクト | 登録メソッド |
---|---|---|
注文者 | Customer | register(Customer $customer) |
注文内容 | Items | register(Items $items) |
決済方法 | PaymentMethod | register(PaymentMethod $method) |
配送手段 | DeliverySpecification | register(DeliverySpecification $specification) |
連絡先 | ContactTo | register(ContactTo $contact_to) |
注文の確定 | Order | submit(Order $order) |
このようにドメインオブジェクトと登録メソッドを小さく分けることにより変更箇所の特定や変更の影響の範囲を狭い範囲に閉じ込めやすくなる。
画面とドメインオブジェクトを連動させる
画面はドメインオブジェクトを視覚的に表現したもの。その表現方法としては、
- ドメインオブジェクトをそのまま画面の表示にも使う
- 画面用のオブジェクトを別途用意する
- 画面表示用のデータを保持
- データを表示用に加工するロジックをまとめる
の2通りがあるが、画面の関心事とドメインオブジェクトの関心事は一致していることが基本なので、ドメインオブジェクトをそのまま使うことを考えるべき。ただし、
といったことが実際には起こる。そうした食い違いが起きているのであれば、ドメインオブジェクト側の設計を改善すべきかもしれない。
ドメインオブジェクトに書くべきロジック
ビューに書くべきこととドメインオブジェクトに書くべきことを整理する考え方は次の3つ。
論理的な情報構造をドメインオブジェクトで表現する
ビューの記述は次の2つに分かれる。
- 物理的なビュー:HTMLタグや改行コードを使って視覚的に表現する
- 論理的なビュー:「段落がいくつあるか」「最も長い段落の文字数のカウント」などの「構造」を表現する
このうち、ドメインオブジェクトでは論理的なビューを実現するためのロジックを持つべきであり、逆にHTMLタグを使ったり、段落の先頭を全角一文字で字下げするといった物理的な手段を持ってはいけない。
場合ごとの表示の違いをドメインオブジェクトで出し分ける
画面表示でif文を使っている場合はその条件判断をドメインオブジェクトに移動できないか検討する。
<?php class Items { private $items; public function found() { if(empty($items)) { return '見つかりませんでした'; } return "{count($items))件見つかりました"; } }
次のようにドメインオブジェクトで実装することで、ビュー側にif文の条件判断が不要になる。条件分岐を増やすときもドメインオブジェクトだけが変更の対象になる。
情報の文字列表現は利用者の関心事そのものなので、ドメインオブジェクトに記述することはむしろ自然。
HTMLのclass属性をドメインオブジェクトから出力する
ドメインオブジェクト側にclass属性を返すメソッドを用意すれば、画面の表示ロジックからif文をなくすことができる。
<p class="<?php $mail->readStatus(); ?>">
画面(視覚表現)とソフトウェア(論理構造)を関係付ける
画面とオブジェクトの項目や順番に不一致がある場合、ドメインオブジェクトが利用者の関心事を適切に表現できていない可能性が高い。つまり変更が難しくなる。
項目の並び順とドメインオブジェクトのフィールドの並び順
書籍の一覧画面で次のように項目が並んでおり、
- 書名
- 価格
- 発行年月日
- 著者
- 本の種類
<?php class Book { private int $id; private BookNumber $number; private Title $title; private Author $author; private Publisher $publisher; private BookType $type; private Price $unit_price; private LocalDate $published; private LocalDate $registered; }
このようなドメインオブジェクトが定義されているとすると、このクラスのフィールドと一覧画面の項目は内容も順番も一致していない。利用者の関心事よりもデータベースのテーブル構造に引きずられた内容になっている。
一覧画面の関心事をそのまま表現すれば、ドメインオブジェクトは次のようになる。
<?php class BookSummery { private Title $title; private Price $unit_price; private LocalDate $published; private Author $author; private BookType $type; }
これならば画面との対応が明確なため、変更の要求への対応箇所が直感的にわかる。
画面項目のグルーピング
画面デザインの4原則
- 近接:関連のある情報は近づける、関係のない情報は離す
- 整列:同じ意味のものは同じラインに揃える、意味が異なれば異なるラインに揃える
- 対比:意味の重みの違いを文字の大きさや色の違いで区別する
- 反復:同じ意味は同じパターンで視覚化する
近接
近接したグループはドメインオブジェクトの単位と一致するはず。関連のある情報ごとにドメインオブジェクトを作成し、関係のない情報は別のオブジェクトに分ける。デザイン上空白などで分離してある複数の情報が1つのドメインオブジェクトにまとまっているのは利用者の関心事の理解として問題があるといえる。
整列
特にインデントはグルーピングの良い手がかりになる。画面上でインデントされているということは意味として異なるということ。インデントされた部分を別のオブジェクトとしてくくりだすことで関心事の構造を適切に表現できるはず。
対比
小さなフォントや薄めのグレーなど画面上で弱く表現している情報は別のクラスを作ってその中に隠蔽することを検討する。
反復
反復で表現された情報は同じ型のオブジェクトとして表現する。1つのクラスの別々のオブジェクトということもあるし、インターフェース宣言で同一の型として扱っている複数のクラスのオブジェクトということもありうる。
オブジェクトの設計とテーブルの設計
オブジェクトとテーブルは似てくる
オブジェクトとテーブルはほぼ一対一に対応することがあるが、設計のアプローチや設計を変更する動機が本質的に異なる。
オブジェクトはプログラムのロジックを重複させないための仕組みで、データとロジックの組み合わせに注目しながら部分から段階的により大きなプログラムに組み立てていく。
テーブルは導出結果や加工結果ではない元データの記録と整理の手段。データの整合性を確保するために、関連するデータを全体的に洗い出して関係を明確に設計するトップダウンのアプローチをとる。
特性 | オブジェクト | テーブル |
---|---|---|
目的 | データとロジック特にロジックの整理 | データの整理 |
関心事 | 導出や加工のロジック | 加工や導出のもとになるデータ |
アプローチ | 部分から全体 | 全体から部分 |
設計変更のリズム | 頻繁 | ゆるやか |
オブジェクトとテーブルを自動的にマッピングするアプローチでは、どちらかの設計のアプローチやリズムに大きく制約される。たとえばテーブル設計を優先してオブジェクトをそれに合わせるアプローチではロジックの整理に失敗するし、オブジェクト設計にテーブル設計を合わせるアプローチではデータの正しい管理に失敗する。
オブジェクトはオブジェクトらしく、テーブルはテーブルらしく
オブジェクトとテーブルの間のマッピングはその両方の設計の進度に合わせながら明示的に定義する。そうすることで、お互いの設計変更の影響をマッピング定義に局所化できる。
ドメインモデルによる機能の実現
アプリケーション層のクラス
- アプリケーション層の役割は以下
- プレゼンテーション層の依頼を受ける
- 適切なドメインオブジェクトに処理を依頼する
- プレゼンテーション層に結果を返す
- データソース層に入出力を指示する
- アプリケーション層のクラスは業務サービスを提供するという意味で、サービスクラスという
- サービスクラスの実装は以下の要因によりごちゃごちゃしがちである
- ドメインオブジェクトが業務ロジックの置き場所として十分機能していない
- プレゼンテーション層の関心事に引きずられる
- データベースの都合に引きずられる
- そうならないために、以下のことを徹底する
- 業務ロジックはサービスクラスに書かずにドメインオブジェクトに任せる(サービスクラスで計算・加工などをしない)
- 画面の複雑さをサービスクラスに持ち込まない
- データベースの入出力の都合からサービスクラスを独立させる
プレゼンテーション層からの依頼を受け付ける
ロジックを小さく分ける
- 画面の多様な要求をサービスクラスの1つのメソッドに押し込めると、 そのメソッドはif文だらけになり、判断と分岐の流れを追うだけでも大変になる
- サービスクラスを小さく分ける
- 登録系:プレゼンテーション層からのデータを計算・加工し記録する
- 参照系:プレゼンテーション層からの依頼に基づきデータを生成し返す
- 意味のある最小単位で、かつ単独でテスト可能な単位にメソッドを分割するのがサービスクラス設計の基本
参照系サービスクラス
// 口座情報を参照する class BankAccountService { @Autowired BankAccountRepository repository; Amount balance() { return repository.balance(); } boolean canWithdraw(Amount amount) { Amount balance = balance(); return balance.has(amount); } }
登録系サービスクラス
// 口座の残高を更新する class BankAccountUpdateService { @Autowired BankAccountRepository repository; void withdraw(Amount amount) { repository.withdraw(amount); } }
小さく分けたサービスを組み立てる
- 小さく分けたサービス(参照系サービスクラス、登録系サービスクラス)を組み立てるため、組み立てる専用のシナリオクラスをつくる
- 基本サービスを提供するサービスクラス群と複合サービスを提供するシナリオクラス群の2層構造になる
- プレゼンテーション層のコントローラで組み立ててしまうと、プレゼンテーション層に業務の手順という業務知識が溢れ出すことになってしまう
- 業務ロジックがプレゼンテーション層に書かれ始めると、ロジックが重複し、調査箇所、修正箇所が広がってしまうことにつながる
- シナリオクラスにはコードの整理だけではなく次の2つの効果もある
- アプリケーション機能の説明(シナリオクラスを起点に関連する基本サービスを特定しやすくなる
- シナリオテストの単位
シナリオクラス
class BankAccountScenario { @Autowired BankAccountService queryService; @Autowired BankAccountUpdateService updateService; Amount withdraw(Amount amount) { if(! queryService.canWithdraw(amount)) throw new IllegalStateException("残高不足"); updateService.withdraw(amount); return queryService.balance(); } }
データソース層に入出力を依頼する
データベースではなく業務の関心事で考える
- 参照や記録という業務の関心事をデータベースのCRUDに勝手に変換してしまうと、データベースの入出力に引きずられたコード(データベース操作の手続きの羅列)が出来上がる
- 業務的な意図が読み取りにくくなる
- テーブル構造が複雑化すると、同じようなロジックが重複する
- 業務の関心事としての参照と記録を記述する
- サービスクラスの中からはリポジトリを利用して業務データの記録や参照を行う
interface BankAccountRepository { boolean canWithdraw(Amount amount); Amount balance(); void withDraw(Amount amount); }
実際のデータベース操作とリポジトリを組み合わせる
データソース層のクラスでデータベース操作を行う。このように具体的なデータベース操作はリポジトリインターフェイスによって隠蔽され、サービスクラスはデータベース操作と独立して、業務の関心事として参照と記録を行う。
class BankAccountDatasource implements BankAccountRepository { boolean canWithdraw(Amount amount) { // データベースに残高を照会して結果を返す } Amount balance() { // データベースに残高を照会して結果を返す } void withdraw(Amount amount) { // データベースの残高を変更する } }
ドメインモデルの設計
ドメインモデルの作り方
1 部分を作りながら全体を作っていく
- オブジェクト指向のアプローチとして、個々の部品を作り始め、それを組み合わせながら全体を構築していく
- ある部分に注目してその部分に必要なデータとそれを使ったロジックを一緒に考え、クラスとして設計していく。
2 部分と全体を行き来しながら作っていく 全体を俯瞰するツールとしては以下の2つがある。
- パッケージ図
- 個々のクラスを隠蔽し、パッケージ単位で全体の構造を俯瞰する
- パッケージ間の依存関係を表現する(オブジェクトを参照する方向に矢印でつなぐ)
- パッケージ間の参照関係は業務フロー図を描きながら業務の流れの前後関係として確認する
- 業務フロー図
- 業務の様々な活動を、時間軸に沿って図示したもの
- 活動の主体ごと(顧客/販売/出荷/経理など)にレーンを並べて情報のやり取りを明らかにする
3 全体を俯瞰したら、重要な部分(=間違いなく必要になる部分)からドメインオブジェクトを作っていく。
4 独立した部分を組み合わせて機能を実現する
ドメインオブジェクトの見つけ方
業務に必要なドメインオブジェクトを最初から網羅的に見つけることはできない。機能を組み立てていく中で、ドメインモデルに本来あるべきドメインオブジェクトが足りないことが明らかになっていく。不足しているドメインオブジェクトを見つけながら追加していくのが設計のやり方。
業務の関心事を分類する
業務の関心事をヒト/モノ/コトに分類する。
- ヒト:個人、企業など、業務の主体。
- モノ:商品、サービス、権利など、ヒトが業務を行うときの関心の対象。
- コト:予約、注文、出荷、キャンセルなど、業務活動で起こる事象。
コトに注目する
- それぞれの関心事の関係と重要性を明らかにするには、コトに注目して整理するのが効果的である
- コトに着目することで、次の関係が明らかになる
- だれの何についての行為か(コトはヒトとモノの関係として出現する)
- 前後関係(コトは時間軸に沿って明確な前後関係を持つ)
例:販売活動における一連のコト
- 受注
- 出荷
- 請求
- 入金
このうち、受注は他のコトと比べて以下の点で異なる。
- 発生源が外部のヒトである
- 将来に対する約束である
この売り手と買い手の間に成立した約束を適切に記録し、約束どおりに完了させることがアプリケーションの業務となる。
その際、以下の点で妥当性を検討する必要がある。
- 在庫はあるか
- 与信限度額を超えていないか
- 自社の販売方針に反していないか
これらを判定するためのデータとロジックからドメインオブジェクトを構築する。
さらに、約束相手や商品、時期などによって約束の内容やルール、妥当性が担保できなかった場合の手続きなどが異なるため、それらを実現するドメインオブジェクトを構築する。特に数量に関する業務ロジックは数量クラスにまとめることになる。
ドメインオブジェクトの設計パターン
ドメインオブジェクトの設計パターン
以下の4つのパターンがドメインオブジェクトの基本となる。
- 値オブジェクト:数値、日付、文字列等をラッピングしてロジックを整理する
- コレクションオブジェクト:配列やコレクションをラッピングしてロジックを整理する
- 区分オブジェクト:区分の定義と区分ごとのロジックを整理する
- enumの集合操作:状態遷移ルールなどをenumの集合として整理する
業務の関心事のパターン
業務ロジックは次の4つの関心事のパターンに分類できる。
- 口座パターン:現在の値を表現し、妥当性を管理する
- 銀行の口座、在庫数量の管理、会計などで使うパターン
- 関心の対象を「口座」として用意
- 数値の増減の「予定」を記録する
- 数値の増減の「実績」を記録する
- 現在の口座の「残高」を記録する
- 銀行の口座、在庫数量の管理、会計などで使うパターン
- 期日パターン:約束の期日と判断を管理する
- 約束を実行すべき期限を管理
- その期限までに約束が実行されるかを管理
- 期限切れの可能性について事前にアラートする
- 期限切れとその程度を検知
- 方針パターン:様々なルールが複合する、複雑な業務ロジックを表現する
- ルールの集合をもったコレクションを作成
- ひとつひとつのルールについて、Ruleインターフェースを持ったオブジェクトを作る
- ルールの集合に対していくつの条件が一致するかなどの判定を方針クラスに任せる
- 状態パターン:状態と、状態遷移のできる・できないを表現する
業務ロジックの整理 〜三層+ドメインモデルの構築〜
クラスとはデータとロジックを一つのプログラミング単位としてまとめるための仕組みであり、データをインスタンス変数として持ち、それに対するロジックをメソッドに書くのがオブジェクト指向におけるクラスの本来の使い方である。
データとロジックを別のクラスに分けるとわかりにくさが発生する
三層アーキテクチャを採用しても、データクラスと機能クラスを分ける手続き型の設計のままでは以下の状況が起こりやすくなる。
- 変更の対象箇所を特定するために、プログラムの広い箇所を調べる
- 一つの変更要求に対してあちこちの修正が必要
- 変更の副作用が起こっていないことを確認する大量のテストが必要
この要因としては、手続き型の設計では業務ロジックが入り組んでくると次の問題が顕著になるからである。
- プレゼンテーション層、アプリケーション層、データソース層がそれぞれデータクラスにアクセスできるため、
- 同じ業務ロジックがあちこちに書かれる
- どこに業務ロジックがか書いてあるか見通しが悪くなる
- 業務ロジックをアプリケーション層に集約したとしても
- 共通機能ライブラリを作ったとしても
データとロジックを一体化する
- メソッドをロジックの置き場所にする
- クラスにインスタンス変数を返すだけのゲッターメソッドだけがある状態ではデータクラスと何も変わらない
- 何かしらのロジックをもたせる(データをgetする側のクラスではそれを使って何かをしたいはず)
- その「何か」のロジックが重複することを防げる
- メソッドは必ずインスタンス変数を使う
- もし使わない場合、そのメソッドの置き場所は不適切
- データを持つクラスに移動させる
- クラスが肥大化したら小さく分ける
三層のそれぞれの関心事と業務ロジックの分離を徹底する
データとロジックをドメインオブジェクトとして小さな単位に分けて整理すると、クラスの数が膨大になるため、整理する必要がある。
- 様々なドメインオブジェクトを関心事の単位にグルーピングしてパッケージに分けることで整理
- パッケージの参照関係も含めて整理する
ドメインモデル方式
業務アプリケーションの対象領域(ドメイン)をオブジェクトのモデルとして整理したもの(ドメインオブジェクトの集合)をドメインモデルという。
三層+ドメインモデルの各レイヤーの役割は以下のようになる。
場合分けロジックの整理
場合分けロジックの望ましい書き方
- 判断や処理のロジックをメソッドとして独立させる
- elseをなくす(早期リターンする=ガード節にする)とコードが簡潔になる
- インターフェースを使って異なるクラスを同じクラスとして扱う
- クラスを区別するための場合分けの必要がなくなる
区分オブジェクト
インターフェイスによるポリモーフィズムは区分の一覧がわかりにくいという問題がある(個々のクラスを確認するしかない)。
enum
<?php interface Colorful { public function color(): string; } enum Suit: string implements Colorful { case Hearts = 'H'; case Diamonds = 'D'; case Clubs = 'C'; case Spades = 'S'; // インターフェイスの規約を満たすための実装 public function color(): string { return match($this) { Suit::Hearts, Suit::Diamonds => 'Red', Suit::Clubs, Suit::Spades => 'Black', }; } }