初期ユースケースビューの設計
ユースケースとは、利用者にとってシステムを利用する価値を表し、1つ以上の機能の組み合わせによって提供されます。
ユースケースと機能は明確に異なります。たとえば注文を発注する際に、発送方法をプルダウンで選択するとします。これは明確に「機能」ですが、利用者の最終的な価値にはなりません。ユースケースとはあくまでも利用者に価値を提供するための、1つ以上の機能の集合を表すものとします。
私たちが実現すべきものは機能ではなく、ユーザーに提供する価値だと考えています。そのため機能ビューではなく、ユースケースビューとして扱います。
ユースケースビューは、ユースケース図を完成させることが目的ではありません。ユースケースは、ユースケース仕様書(一般的な機能定義書のレイヤーに類するドキュメント)で作成されているものとします。
ユースケースは、アーキテクチャ的な視点から見ると類似したものが多く、すべてのユースケースで、個別に実現アーキテクチャを設計する必要はありません。そのため、アーキテクチャ的な視点から同一のユースケースを、同一のアーキテクチャパターンとしてまとめます。そして同一パターンの中から、アーキテクチャ設計に用いる代表的なユースケースを選定します。
初期ユースケースビューの設計では、代表的なユースケースの選定までを行います。アーキテクチャ設計は、要件定義と並行で行われることが多いため、すべてのユースケースが揃っている必要もありません。ドメイン内でとくに重要なユースケースが導出されていれば問題ありません。重要なユースケースのアーキテクチャを設計しているうちに、揃ってくるでしょう。
ユースケースビューは、次のような表を用いると表現しやすいかと思います。
No. | ユースケース | アーキテクチャパターン | 代表 | 選定理由 |
---|---|---|---|---|
1 | 発注する | 基本パターン | ||
2 | 再発注する | 基本パターン | ✅ | ユースケース固有の複雑な参照処理とそれを表示する高機能なグリッド、エンティティのCRUDが含まれるため。実際にはUとDは含まれないが、アーキテクチャ的にはCと差異がない。 |
3 | ・・・ | ・・・ |
ユースケースを一覧として記載し、それぞれのユースケースにアーキテクチャパターンを割り当てます。代表的なユースケースは、パターンに必要な要素が含まれた最小のユースケースを選定することが好ましいでしょう。あまり単一のパターンに含める要素が多くなりすぎると、設計が難しくなるため、パターンを小さめにして数を多くした方が良いでしょう。
たとえば「再発注する」ユースケースには、複雑なクエリーやそれを表示する高機能なグリッド、発注データのCRUDが含まれます。実際には、CRUDのうちCreate(C)とReference(R)は含まれていますが、Update(U)とDelete(D)は含まれません。ただしUDはアーキテクチャ的にCと相違ないため、エンティティのCRUDはこのケースで満たすこととします。
しかし理想的にはこのパターンは大きすぎるとは思います。ユースケース関連と、エンティティ関連の2つに分けたほうが良いかもしれませんが、設計編の後編では「再発注する」ユースケースの実装を詰めていくことでアーキテクチャを設計します。
初期非機能要件ビューの設計
非機能要件ビューはユースケースビュー同様、非機能要件を定義するものではありません。非機能要件定義書などで定義された非機能要件のうち、アーキテクチャ上で考慮が必要な要件を明確にし、その実現方式を設計します。
非機能要件には、運用時の要件など、アーキテクチャ的に考慮が必要ないものも多く含まれます。たとえば障害発生時の対応可能時間(9時~17時)などです。そのため非機能要件から、アーキテクチャ的に考慮が必要なものと、必要ないものを非機能要件ビューで選定します。
アーキテクチャ上での考慮が必要かどうか検討した結果と、そう判断した理由を記載するのが非機能要件ビューの目的となります。
ここで非機能要件の定義について、詳しく記載することはかないませんが、たとえばIPAの公開している「非機能要求グレード」をベースに不足があれば追加していくと、扱いやすいのではないでしょうか。
非機能要件のアーキテクチャ上の考慮要否を制する場合、次のような表を用いると表現しやすいかと思います。大項目から指標までは非機能要求グレードのフォーマットに則っています。
大項目 | 中項目 | 小項目 | 指標 | 考慮 | 理由 |
---|---|---|---|---|---|
可用性 | ・・・ | ・・・ | ・・・ | ||
運用・保守性 | 通常運用 | 運用監視 | 監視情報 | 要 | WPF上でのエラーとトレース情報を適切にサーバーサイドにログとして保管する。例外時にも抜け漏れのないログ出力を実現する。 |
障害時運用 | システム異常検知時の対応 | 対応可能時間 | 不要 | 運用保守の体制にて実現し、アーキテクチャに影響はないため。 | |
セキュリティ | アクセス・利用制限 | 認証機能 | ・・・ | 要 | WPFアプリケーションの利用者を認証する。認証機能はドメイン共通のカスタマー・サプライヤー関係として提供する。そのため、本来は全機能共通のアーキテクチャ設計上で実施するが、便宜上本稿に記載する。 |
・・・ | ・・・ | ・・・ | ・・・ |
上記は非機能要件の一部の抜粋です。
考慮列には、非機能要件ごとにアーキテクチャ上の考慮が必要かどうか記載し、その理由を理由列に記載します。考慮の要否以上に、なぜ決定したかその理由の方が後々重要になるため、しっかり書き残しておきましょう。後編では、例外処理を含めたログ出力と、認証の実現方法を設計します。
初期の配置ビュー
予算編で記載したように、購買システムはクライアント・Web API・データベースの三層アーキテクチャを採用します。また購買システムは販売ドメインと製造ドメインに依存するため、それらのノードも意識する必要があります。
購買ドメインの開発が開始しているということは、販売ドメインと製造ドメインの予算も決定されているはずで、購買ドメインの予算編と同等のアーキテクチャは決定されていると想定します。
それらを配置図に起こすと次の通りです。
購買・販売・製造パッケージがあり、それぞれに論理的なノードと、ノード上に配置されるコンポーネントを記載しています。販売ドメインも製造ドメインも、購買ドメインと同様に、データの永続化にはSQL Serverを利用するものとします。
この時点でもう少し設計を詰められそうなのは次の2点でしょうか?
- 購買APIのアーキテクチャ
- 販売・製造ドメインとの関係性
購買APIのアーキテクチャ
購買APIをRESTでつくるのか?それとも別のものを利用するのか?といったアーキテクチャの選択は、この時点でできることが多いでしょう。逆にいうと、個別のユースケースによって変わるようなものではないとも言えます。
Web API実装の選択肢として、次のものを候補として検討します。
- REST
- gRPC
- GraphQL
結論としては、今回はgRPCを利用する想定で設計を進めます。理由はいくつかあります。
- 購買ドメインでは、「発注する」ようなRPC(Remote Procedure Call)のようなスタイルのメッセージがあり、RESTのようなリソース要求スタイルか、RPCスタイルかのどちらか一方に寄せることで設計を簡略化するとした場合、gRPCに寄せたほうが素直な設計に感じる
- 購買ドメインのWeb APIを直接外部に公開する想定はなく、RESTほどの相互接続性は必要ない(gRPCの相互接続性が低いわけでもないが、RESTほど一般的でもない)
- 同様にGraphQLほどの柔軟性も必要ない
- RESTとgRPCでは単純にgRPCの方が軽くて速いことが多い
- .NETにおけるgRPCではMagicOnionというOSSを利用することで、C#のインターフェイスベースでの設計・実装が可能で、RPCスタイルのデメリット(エンドポイントが分かりにくい)を解消できる
gRPCの実装にはMagicOnionを利用する想定ということで、モデルに追加します。
MagicOnionは、クライアントとサーバーでそれぞれモジュールが異なるので、そのとおり記載しています。
なお初期論理ビューの設計より先にこちらを記載したのは、Web APIを決定しておくことで、初期論理ビューで設計できる部分が増えるためです。
販売・製造ドメインとの関係性
購買ドメインでは、販売・製造ドメイン上のオブジェクトを、購買ドメインに同期する必要があります。たとえば購買数を決定するにあたって、販売実績を参照するといった内容を実現するためです。
販売・製造ドメインとの関係性からアーキテクチャを設計する際、次の点を考慮する必要があります。
- 販売・製造ドメインが上流である
- 販売・製造ドメインとの関係性は腐敗防止層である
販売・製造ドメインは購買ドメインの上流にあたります。そのため販売・製造コンテキストのオブジェクトを購買ドメイン側で解釈し、購買コンテキストのオブジェクトに変換して取り込みます。それらを販売・製造それぞれの腐敗防止層コンポーネントとして実装することにします。腐敗防止層コンポーネントはひとまず購買データベース上に配置しましょう。
腐敗防止層の実装の詳細は、ユースケースビューでユースケースを設計することで詳細化します。ただ腐敗防止層の実装はWPFのアーキテクチャから乖離しすぎるため、「2022年版実践WPF業務アプリケーションのアーキテクチャ」内では取り扱いません。
初期論理ビューの設計
ドメイン駆動とクリーンアーキテクチャ
さて、アプリケーション全体の構成を考えたとき、ドメイン駆動設計単独では、どのように論理レイヤーを構成し、どの役割のオブジェクトをどのレイヤーに配置するのか規定されていません。そこで活用したいのがクリーンアーキテクチャです。ドメイン駆動設計とクリーンアーキテクチャは非常に相性が良い設計手法です。
クリーンアーキテクチャではなく、通常の垂直型のレイヤーアーキテクチャを用いた場合、概ね次のようなレイヤー構成になります。
ドメインがインフラへのアクセス層に依存する形となっています。
基本的に依存関係は、重要度の低い方から、高い方に向いていることが好ましいです。これは重要度が低い箇所の変更影響を、重要度の高い箇所が受けないようにするためです。このあたりの課題は筆者のクリーンアーキテクチャの解説を一読いただければ、ご理解いただけます。
ドメイン駆動設計でもっとも重要なのはドメインになります。垂直レイヤーアーキテクチャを用いると、ドメインとリポジトリー実装の依存関係が、理想とは逆の方向になっているのが見て取れます。
下図は有名なクリーンアーキテクチャの図です。
クリーンアーキテクチャを採用してEntityをドメインに置き換えて見てください。
サークルの左から右に向かっている矢印が依存の方向です。ドメインがもっとも内側にあって、ドメインはいずれにも依存しておらず、垂直型より良い設計になっているのがわかります。
さてクリーンアーキテクチャの詳細は先の記事を見ていただくとして、クリーンアーキテクチャについて誤解されがちな、いくつかの点についてここでも補足しておきたいと思います。
クリーンアーキテクチャでもっとも大切なことは、アーキテクチャ上もっとも重要な要素を中央のレイヤーに配置して、依存関係はすべて外から内に向けるという点にあります。
ただしそうすると、ドメインからリポジトリーの呼び出しのような、処理の流れが内側から外側にながれる部分の実現が困難になります。そのため、右下の実装例のように制御の逆転を使います。処理の流れとしては内側から外側にながれますが、依存性は一貫して外から内側へ向かうようにしましょう、というのが上記の図になります。レイヤー数や登場要素を、上記の図の通りにしましょうというアーキテクチャではありません。
ドメインレイヤーの分割
ここで、あらためて境界付けられたコンテキストを見なおしてみましょう。
購買ドメインの上位にAdventureWorksドメインが存在します。AdventureWorksドメインは、認証・製造・販売ドメインからも利用される汎用的なドメインです。そのためEntityの部分を単純にドメインに置き換えるのではなく、AdventureWorksドメインと購買ドメインの2層に分けたほうが良さそうです。
また、最も外側のFrameworks & Driversレイヤーの要素は、実際に今回のドメインで必要となるものを記載しましょう。それらを反映した現在のレイヤーモデルは次の通りです。
レイヤーアーキテクチャにおける選択
さてレイヤーアーキテクチャには2つの選択肢があります。
- 厳密なレイヤーアーキテクチャ
- 柔軟なレイヤーアーキテクチャ
厳密なアーキテクチャを選択した場合は、直下への依存しか許可しません。柔軟なレイヤーアーキテクチャを選択した場合は、相対的に下位のレイヤーであれば依存(利用)を許可します。厳密なレイヤーアーキテクチャを採用した場合、レイヤーをまたいだ内側を利用したい場合、1つ外側がそれをラップして隠ぺいする必要があります。たとえば今回であれば、プレゼンテーションがドメインを利用する場合、つねにユースケースでラップして隠ぺいすることになります。
厳密なレイヤーアーキテクチャの方が、内側の影響を受けにくくなるため保守性が向上し、柔軟なレイヤーアーキテクチャは内側を隠ぺいするコードが必要ないため、生産性が高くなります。
結論から言うと、今回は柔軟なレイヤーアーキテクチャを選択します。大きな理由が2つあります。
- AdventureWorksドメインをラップしてしまうと、生産性や品質に対する影響が大きい
- リポジトリーの実装などを考慮すると厳密なレイヤーアーキテクチャでは実現できない
AdventureWorksドメインをラップしてしまうと、生産性や品質に対する影響が大きい
前述しましたが、AdventureWorksドメインには次のようなオブジェクトを定義します。
オブジェクト | 説明 |
---|---|
Date | 時刻を持たない年月日 |
Days | 日数 |
Dollar | 通貨(日本企業の場合はYenなど) |
Gram | 重量グラム |
DollarPerGram | グラム当たりの料金 |
AdventureWorksドメインにおけるプリミティブな型をValue Objectとして実装するため、これらを一々ユースケースでラップすると生産性が低下しますし、ラップミスの発生もあり得るため、品質も低下します。
そもそも厳密にした場合、AdventureWorksコンテキストを共有カーネルにした意味が無くなってしまいます。
リポジトリーの実装などを考慮すると厳密なレイヤーアーキテクチャでは実現できない
たとえば購買ドメインには購買先を表すVendorエンティティと、そのリポジトリーであるIVendorRepositoryインターフェイスを定義することになるでしょう。そして、VendorRepositoryクラスはゲートウェイに実装されます。
VendorRepositoryクラスからIVendorRepositoryインターフェースへの依存は、ユースケース層を跨いでいますが、さすがにここをユースケース層でラップするのは助長過ぎます。
というわけで今回は柔軟なレイヤーアーキテクチャを選択します。
Frameworks & Driversレイヤー
最も外側のFrameworks & Driversレイヤーは、アプリケーションから利用するフレームワークやミドルウェアのレイヤーで、購買ドメイン外のレイヤーです。
ユーザーインターフェースはWPF上に構築し、永続仮想としてはSQL Serverを利用します。またクライアントとデータベースの間にWeb APIを挟んだ三層アーキテクチャとしたいため、Web APIをMagicOnionで実現します。
クリーンアーキテクチャ本にも記載されていますが、抽象化とは具体化を遅延させるための手段でもあります。そのためこの時点で具体的なFrameworkやDriverを決定する必要はありません。
ただ現実的な話、開発がスタートしてアーキテクチャを設計する段階では、Frameworks & Driversレイヤーの実体は決定しているものが多いです。なぜなら見積に影響するため、見積時のアーキテクチャ設計で多くの場合、十分に検討した上で決定しているからです。
すでに実体が決定しているなら遠慮せずWPFやSQL Server、MagicOnionのように具体的な要素をプロットしましょう。そのことはアーキテクチャ設計を容易にする面もあるからです。
一番分かりやすいのはWPFでしょうか。WPFで実装する場合、とくに理由がなければMVVMパターンを採用するでしょう。MVVMパターンを前提に設計されたUIフレームワークだからです。
このように外側の詳細の決定によって、内側の設計が容易になることがあります。そのため最外周が決定した段階で具体的な名称を記載しておくと良いと思います。
繰り返しますが。これは抽象化を利用して具象の決定を遅らせることを否定するものではありません。
初期オブジェクトのプロット
さて、前述のレイヤーモデルではさすがにオブジェクトが少なくて、初期の実装ビューを作成(つまりコンポーネント分割)することも難しいです。これ以上は代表的なユースケースを設計してみないと設計できないかというと、そうでもありません。
- ドメイン駆動設計
- クリーンアーキテクチャ
- Web APIを挟んだ三層モデル
- UIはWPF
- Web APIはMagicOnion
ここまでは決まっています。となると、ユースケースに関係なく、ある程度はクラスを導出できそうです。
おそらくユーザーが何らかの操作をした場合、次のような振る舞いになるはずです。
- ユーザーがViewを操作する
- ViewはViewModelを呼ぶ
- ViewModelはリポジトリーを呼び出してエンティティの取得を試みる
- クライアントからgRPCを利用してサーバーサイドを呼び出す
- サーバーサイドはリポジトリーの実装を呼び出してエンティティを取得する
これが正しいとして代表的なオブジェクトをレイヤーモデル内にプロットしてみたのがこちらです。
青の破線は呼出し経路です。おおむね、先の手順の通りとなっているのが見て取れます。ポイントが何点かあります。
データベース操作はWeb APIノード上で実施されて、クライアントからは行われません。そのためViewModelからIVendorRepositoryを呼び出した場合、実際に呼び出されるのはVendorRepositoryではなくて、VendorRepositoryClientです。
VendorRepositoryClientは、内部でIVendorRepositoryServiceを呼び出します。IVendorRepositoryServiceはMagicOnionでgRPCを実装するためのインターフェイスです。VendorRepositoryClientが呼び出すIVendorRepositoryServiceの実体はVendorRepositoryServiceではなくて、MagicOnionによって動的に生成されたオブジェクトになります。ちょっと分かりにくいので、VendorRepositoryClientの抜粋コードを見てみましょう。
public class VendorRepositoryClient : IVendorRepository { private readonly MagicOnionConfig _config; public VendorRepositoryClient(MagicOnionConfig config) { _config = config; } public async Task<Vendor> GetVendorByIdAsync(VendorId vendorId) { var server = MagicOnionClient.Create<IVendorRepositoryService>(GrpcChannel.ForAddress(_config.Address)); return await server.GetVendorByIdAsync(vendorId); } }
GetVendorByIdAsyncメソッドを呼び出すと、コンストラクターでインジェクションされたMagicOnionConfigからIVendorRepositoryServiceを作成し、GetVendorByIdAsyncメソッドを呼び出すことで、ネットワーク経由でサーバーサイドを呼び出します。
IVendorRepositoryServiceとIVendorRepositoryをまとめて1つにしたくなりますが、IVendorRepositoryService側のインターフェイスがMagicOnionにガッツリ依存するため、それはできません。実際のコードを見比べてみましょう。
public interface IVendorRepository { Task<Vendor> GetVendorByIdAsync(VendorId vendorId); } public interface IVendorRepositoryService : IService<IVendorRepositoryService> { UnaryResult<Vendor> GetVendorByIdAsync(VendorId vendorId); }
戻り値がTask<Vendor>とUnaryResult<Vendor>で異なります。UnaryResultはMagicOnionで定義されている構造体です。これを統合してしまうと、ドメイン層がMagicOnionに依存する形となってしまうため、今回はあえて分けました。もちろんMagicOnionにガッツリ依存するリスクをとって実装を減らすことで生産性を上げるという選択もあります。ただそれをするなら、コード生成を活用して分けるが実装はしないという、良いとこどりを狙うほうが個人的には好みではあります。
なお、この時点で完全に正しい必要はありません。おおよそ正しそうな状態にもっていって、あとは後続の設計の中で精度を高めていきます。完全に正しいモデルでなくても、このレベルのモデルがあると後続の検討が容易になります。
さて、これ以上は実際のユースケースを設計しながら進めたほうが良いでしょう。ということで、初期の論理ビューとしては、いったんこの辺りとしておきます。