こんにちは、ROBOT PAYMENTでエンジニアをやっております、河津です。
レガシーシステムからの脱却【予告編】の続きになります。
前回、自分が考えるクリーンアーキテクチャの問題点を挙げさせていただきましたが、具体的な問題が分かりづらいと思いますので、今回は先に実践編です!
実装解説
サンプルコードを見ながら、クリーンアーキテクチャとはどんなものか簡単に解説いたします。
今回は請求書発行を行うAPIを実装してみます!
クラス図
まずは例の同心円をもとにクラス図で表現してみました。
クリーンアーキテクチャの書籍にも登場するクラス図を参考により実際の現場で使える範囲に簡略化してます。
省略した部分はPresenterとそのinterfaceになりますが、一般的なwebフレームワークを使用すると大体コントローラーでreturnするような仕組みになっているため、不要と判断しました。
逆に無理やり実装しようとするとフレームワークを改造したり、意図しない動きになる可能性もここはフレームワークの力を借りるのがベストかと思います。
色分けは同心円の各レイヤーと同じにしています。
- 青:Frameworks & Drivers
- 緑:Interface Adapters
- 赤:Application Business Rules
- 黄:Enterprise Business Rules
また、矢印の向きを見ていただくと、外側の抽象度の低い上位レイヤーから内側の抽象度の高い下位レイヤーへ、依存の方向が制御されているのが分かると思います。
これはビジネスを中心に据えて、抽象が詳細に依存すべきでないというルールになっています。
このルールにはいろいろなメリットがあるとさまざまなところで言われているとは思いますが、個人的には前回記事の「クリーンに保つことの重要性」で挙げた「変更容易性」と「テスト容易性」が最大のメリットだと思います。
この辺の深掘りは、サンプルコードを見てからの方がわかりやすいと思いますので後述します。
と、こんな意図のクラス図になっていますので、早速クラス図を元に実装をしてみます。
動作確認は行っていないので、実際に動かない部分もありそうですが、雰囲気で読むぐらいでお願いします!
また、細かいバリデーションは行っていないので目を瞑ってください。
Controller
<?php namespace App\\Controllers\\Api; use DI\\Attribute\\Inject; class DemandController extends ApiController { /** @var BulkIssueBillUsecaseInterface 請求書発行ユーケースインターフェース */ #[Inject] private BulkIssueBillUsecaseInterface $bulk_issue_bill_usecase; public function bulk_issue_bill() { /** @var BulkIssueBillOutputData $output_data */ $output_data = $this->bulk_issue_bill_usecase->handle( new BulkIssueBillInputData( $this->rest->input("company_id"), $this->rest->input("demand_ids") ) ); $this->rest->response( array_map( fn($demand) => new BulkIssueBillResponseDemand( $demand->error_code, $demand->error_message, $demand->id, $demand->bill->number, $demand->bill->issue_date, ), $output_data->demands ) ); } }
Controllerの責務
- ユーザからのリクエストを受け取る
- アプリケーション(usecase)が求める入力データに変換
- アプリケーション(usecase)に入力データを渡して請求書発行してもらう
- 請求書発行処理の出力データをユーザーに返却する用のレスポンスデータに変換
- 変換したデータをユーザーに返却
ゲームのコントローラーと全く同じ概念ですね。
- ユーザーがボタンを押下
- ボタンが押下されたことを信号に変換
- ゲーム機本体に伝える
ちなみに、下記のようなjsonがレスポンスされる想定です。
{ "demand": [ { "error_code": null, "error_message": null, "id": "1", "bill": { "number": "201805-1001-1", "issue_date": "2018/05/01" } } ] }
InputData
<?php class BulkIssueBillInputData { /** * @param int $company_id 利用企業ID * @param int[] $demand_ids 請求情報IDs */ public function __construct( public readonly int $company_id, public readonly array $demand_ids ) {} }
クラス図のInput Data には < DS > という記号がついていますが、これは Data Structure をあらわしています。 要は、アプリケーション(usecase)に渡すための入力データオブジェクトです。 作りはシンプルなので説明不要かなと思います。
OutputData
<?php class BulkIssueBillOutputData { /** * @param array<BulkIssueBillOutputDemand> $demands */ public function __construct( public readonly array $demands ) {} } class BulkIssueBillOutputDemand { /** * @param int $id * @param array<BulkIssueBillOutputBill> $bill */ public function __construct( public readonly int $id, public readonly array $bill ) } class BulkIssueBillOutputBill { /** * @param string $bill_number * @param string $issue_date * その他諸々のプロパティ... */ public function __construct( public readonly string $bill_number, public readonly string $issue_date, ... ) {} }
続いてOutput Dataですが、こちらもシンプルですね。 Input Data と同じくData Structureで、アプリケーションの実行結果の出力用です。
UsecaseInterface
<?php interface BulkIssueBillUsecaseInterace { /** * いっぱい請求書発行します */ public function handle(BulkIssueBillInputData $input_data): BulkIssueBillOutputData; }
クラス図のUsecase Interface には < I > と記述していますが、ご覧の通りインターフェースです。 ユースケースが必要とする入力データと返却する出力データを明示しているのみです。 インターフェースを定義することで、上記で述べた変更容易性やテスト容易性を確保しています。
UseacseInteractor
<?php class BulkIssueBillUsecase implements BulkIssueBillUsecaseInterace { /** * constructor injection * * @param DemandRepositoryInterface $demand_repository * @param BillRepositoryInterface $bill_repository */ public function __construct( private DemandRepositoryInterface $demand_repository, private BillRepositoryInterface $bill_repository ) {} public function handle(BulkIssueBillInputData $input_data): BulkIssueBillOutputData { // 引数のIDから現在の請求情報を取得 $demands = $this->demand_repository->find_by_ids($input_data->company_id, $input_data->demand_ids); // 請求情報をもとに請求書を作成 $bills = BillFactory::create($demands); // 請求書を保存 $this->bill_repository->batch_save($bills); // 発行された請求書を紐づけた状態の請求情報を取得 $issued_demands = $this->demand_repository->find_bills_by_ids($input_data->company_id, $input_data->demand_ids); // outputdataのイメージ // demand => [ // id: int, 請求情報ID // bill => [ // bill_number: int, 請求書番号 // issue_date: string, 請求書発行日 // その他諸々のプロパティ... // ] // ] return new BulkIssueBillOutputData( array_map( fn(DemandEntity $issued_demands) => new BulkIssueBillOutputDemand( $issued_demands->id, array_map( fn(BillEntity $bill) => new BulkIssueBillOutputBill( bill_number: $bill->number, issue_date: $bill->issue_date, ... ), $issued_demands->bills ) ), $issued_demands ) ); } }
Usecase Interactor はユースケースを具現化したものになります。 ある問題に対して、Entityというオブジェクトを用いて、その問題を解決する処理を記述しています。 ざっくり、Entityを操作するクラスという感じです。
RepositoryInterface
<?php /** 請求情報リポジトリ **/ interface DemandRepositoryInterface { /** * idで請求情報を取得 * @param int $company_id * @param array $ids * @return DemandEntity */ public function find_by_ids(int $company_id, array $ids): DemandEntity; /** * idで請求書が紐づいた請求情報を取得 * @param int $company_id * @param array $ids * @return DemandEntity */ public function find_bills_by_ids(int $company_id, array $ids): DemandEntity; } /** 請求書リポジトリ **/ interface BillRepositoryInterface { /** * 請求書を一括保存 * @param array<BillEntity> $bills * @return void */ public function batch_save(array $bills): void; }
データの永続化を担当するオブジェクトのインターフェースです。 これもUsecaseInterfaceと同じく、変更容易性やテスト容易性を確保しています。
Repository
<?php class DemandRepository extends RPQueryBuilder implements DemandRepositoryInterface { /** * idで請求情報を取得 * @param int $company_id * @param array $ids * @return DemandEntity */ public function get_by_ids(int $company_id, array $ids): DemandEntity { $this->db->select('*') ->from('demand') ->where('company_id', $company_id) ->where_in('id', $ids); return DemandFactory::build($this->db->get()->result()); } /** * idで請求書が紐づいた請求情報を取得 * @param int $company_id * @param array $ids * @return DemandEntity */ public function get_bills_by_ids(int $company_id, array $ids): DemandEntity { $this->db->select('*') ->from('demand') ->join('bill', 'demand.id = bill.demand_id') ->where('company_id', $company_id) ->where_in('id', $ids); return DemandFactory::build($this->db->get()->result()); } } class BillRepository extends RPQueryBuilder implements BillRepositoryInterface { /** * 請求書を一括保存 * @param array<BillEntity> $bills * @return void */ public function batch_save(array $bills): void { $this->db->_insert_on_duplicate_key_update_batch($bills); } }
Repository はデータの永続化を実際に行うオブジェクトです。 サンプルコードは架空のクエリビルダーとして、RPQueryBuilderを使用しています。 よって、Repositoryはこのクエリビルダーを使用して、永続化を行っていることになります。
Entity
<?php class DemandEntity { public function __construct( private readonly DemandId $id, private readonly DemandCode $code, private readonly DemandTax $tax, private readonly DemandQuantity $quantity, private readonly DemandPrice $price, ... ) { $this->id = $id; $this->code = $code; $this->tax = $tax; $this->quantity = $quantity; $this->price = $price; } public function change_tax(DemandTax $tax): void { $this->tax = $tax; } public function change_quantity(DemandQuantity $quantity): void { $this->quantity = $quantity; } public function change_price(DemandPrice $price): void { $this->price = $price; } } class BillEntity { public function __construct( private readonly BillId $id, private readonly BillNumber $number, private readonly BillIssueDate $issue_date, private readonly BillAddress $adress, private readonly BillTotalAmount $total_amount, private readonly BillBillingSourceName $billing_souce_name, private readonly BillBillingName $billing_name, private readonly BillMemo $memo, ... ) { $this->id = $id; $this->number = $number; $this->issue_date = $issue_date; $this->adress = $adress; $this->total_amount = $total_amount; $this->billing_souce_name = $billing_souce_name; $this->billing_name = $billing_name; $this->memo = $memo; } public function change_memo(BillMemo $memo): void { $this->memo = $memo; } }
Entity は「ドメインの概念を表現」したオブジェクトです。
ここで強調したいのは、「ドメインの概念を表現」というところで、DTOみたいなDBと関連のあるデータモデルとは異なります。
データモデルは、値を取得することも、値を変更することも何も制限されません。(DBにselectやupdateをしているのと同じです)
ただし、Entityはアプリケーションの仕様そのものになるため、値同士の整合性を担保したり、仕様を表現する仕組みでなければなりません。
BillEntityを見ていただくと、電子帳簿保存法に則って発行された請求書の編集は制限しています。
Repositoryを使用しての請求書の保存方法も、BillEntityを引数で渡す必要があり、アプリケーションの仕様に沿った請求書しかできない状態を作り出しています。
Factory
ここは割愛します。
巷にいろんなFactoryパターンでの実装方法はありますので、そのプロダクトにあった方法で取り入れてみてください。
要は、Entityを生成するためのクラスで、生成方法をラップする目的です。
メリデメを考えてみる
クラス図とサンプルコードを用いて解説を行いましたが、ここで一旦メリットとデメリットを整理したいと思います。
メリット
メリットに関しては、前回の記事で3つ記載しており、それをより具体的にしてみます。
可読性
クラスごとに責任が分割され、コードリーディングの際に不要なコードを見なくて済みます。
従来のMVCの場合、どうしてもcontrollerがfatになりがちです。
現在の請求管理ロボではMVCを採用していますが、controllerがfatなってきたため、logicというレイヤーを作って責任を分割していますが、さらにlogicがfatになるという現象が発生しています。。。
logicはusecaseと似たような部類ですがルールが異なります。
既存のlogicクラスでは、Billという大きな括りでクラスが作られていて、その中に大量のメソッドが定義されている状態です。これだと、コードリーディングの際に不要なノイズが目に入ったり、他の処理に影響がないかを注意深く確認する必要があります。
変更容易性
interfaceを用いてレイヤーを分離したことにより、各レイヤーが独立性を保ち、変更が一箇所に集中することで他の部分への影響を最小限に抑えることができます。
また、今回のusecaseについては、SOLID原則でいうところの「単一責任の原則」が守られています。
interface BulkIssueBillUsecaseInterace { /** * いっぱい請求書発行します */ public function handle(BulkIssueBillInputData $input_data): BulkIssueBillOutputData; }
この原則に則った場合にバグが発生しても、影響がそのユースケースのみに限定され、他の処理に影響ができないことを目的としています。
逆にクラスに多くの責任があると、その責任の1つに変更を加えただけで、知らないうちに他の責任に影響を与える可能性があり、予期しないバグを生む可能性が高くなります。
テスト容易性
各レイヤーでinterfaceを定義しているため、各レイヤーの責任を意識したテストができます。
またDIパターンで実装しているので、簡単にモックを注入でき、テスト対象クラスが外部のクラスに直接依存しません。
モックを使用してテストが行えるため、テストケースごとにスタブを使って挙動を変えることも容易になります。
テスト容易性が上がることで何が嬉しいかというと下記が考えられます。
- 開発者がテストコードを書くことが簡単になり、カバレッジの向上することができる
- モックを入れていることで、本来の具象クラスの処理より速く、開発体験が向上できる
- 独立したレイヤーごとのテストにより、バグを早期に発見しやすく、修正も容易になる 個人的には、ここのテスト容易性が上がることが最大のメリットと考えます。 もし、実際に上記の通りに様々なことが向上した場合、「ユーザーに素早く、高い品質でプロダクトを提供できる」ようになるため、クリーンアーキテクチャを取り入れる目的としては十分すぎるかと思います。
デメリット
では、次にデメリットに関しては、前回の記事で4つ記載しています。
過度な抽象化による可読性の低下
サンプルコードでは、interfaceは2つでUsecaseInterfaceとRepositoryInterfaceです。
interfaceを作成する目的としては、「各レイヤーの責務を分割」して、「テスタビリティを向上」させるためです。
例えば、すべてのクラスに対してそれぞれのinterfaceを継承することも可能ですが、目的もなくinterfaceを使って、ただただ疎結合にすると逆に可読性は落ちます。
コードを読む際に、IDEで定義元や参照先にジャンプしたりすると思います。
過度な抽象化を行うと、ジャンプした先が毎回interfaceとなり、結局どこのコードを改修すべきなのか把握するまでに時間を要します。
最終的に自分で具象クラスを探すはめになりとてもストレスになること間違いなしです。
過度な分割による複雑性の増加
今回のサンプルコードではレイヤーは4つですが、プロジェクトによってレイヤーを増減させることは許容されています。
少ない分にはそこまで問題はないとは思いますが、あまりに細分化すると、結局ほとんど何もしていないみたいなクラスができたり、その設計自体の管理コストが増えてしまいます。
個人的にはこの4つが適切だと考えており、これ以上増える場合は今一度そのレイヤー分割は適切か考え直すことをお勧めします。
初学者の学習コストが高い
ある程度の開発経験のあるエンジニアであれば、この設計の意図や目的は把握して、機能追加や改修を行うことができると思います。
ですが、まだ開発経験の浅いエンジニアの場合、意図や目的が理解できず、改修箇所の特定やコードをどう書くべきなのか分からないということもあり得ます。(自分もたまにどうあるべきなのか分からなくなる瞬間があります...)
その点MVCは直感的で誰でも簡単にスピーディーに開発を行えます。
アーキテクトの維持コストが高い
誰かが本来の設計から逸脱したコードを書いた場合、それをまた誰かが踏襲したりして、上記に記載したメリットは半減もしくは、ゼロになる可能性もあります。
一応、コードレビューや開発チーム内での勉強会などで多少は防げるかもしれませんが限界があります。
今回のまとめ
サンプルコードがあったのでクリーンアーキテクチャのイメージがつきましたか?
今回はサンプルコードをもとにメリデメを考えてみましたが、いくらメリットがあっても、それを上回るデメリットがあれば導入は難しいですね...
そこで次回はデメリットを減らすために仕組みやルールを用いて、問題を解決する方法を記事にする予定です!
ちなみに、請求管理ロボには軽量クリーンアーキテクチャ的に部分的に導入している最中で、全体に適応した時にさらなるメリットやデメリットが出てくるかもしれません。その場合はまた追加で記事にしようと思いますので、このテーマに関してはto be continuedという感じです。
では、ここまで読んでいただきありがとうございました!!
We are hiring!!
ROBOT PAYMENTでは一緒に働く仲間を募集しています!!!