レイヤとデータからみるDCIアーキテクチャの実装

DCIアーキテクチャに従ってCRUDアプリケーションを実装する際のポイントを整理する。

導入

Trygve Reenskaug氏によって提唱され、主にJames O. Coplien氏によって理論的な確立と普及が進められているDCIアーキテクチャですが、まだ実装事例はそれほど多くありません。このブログではこれまで「割り勘」を題材にDCIアーキテクチャに基づくCRUDアプリケーションを実装しつつ、アプリケーションの全体像について考察してきました。今回は割り勘アプリケーションをふりかえりつつ、もう少し実装の詳細について考えていきます。

アプリケーションレイヤ

アプリケーションレイヤとはDDDにおいて説明されているレイヤの1つで、ドメインレイヤを薄くラップし、タスクに応じて適切なドメインロジックの呼び出しを行うという責務を与えられています。

アクションクラス

具体例として、割り勘アプリケーションにおいて各参加者の割り当てを計算するロジックのシーケンス図を見てみましょう。

ここでは、Actionがアプリケーションレイヤのコンポーネントとなっています。見ていただければ分かる通り、コンテキストのメソッドを呼び出すことでドメインロジックを実行した上で、結果を画面に表示するための変換ロジックを走らせています。ソースコードを掲載します。

   /**
     * Execute calculation.
     * 
     * Execute adjust in Accounting context and create paymentListItems from party.
     * Result of this method is not committed.
     * 
     * @param partyId ID of Party
     * @param slopeId ID of Slope to be used
     * @return PaymentListItems reflecting allots in Party entity 
     */
    def calculate(partyId:String, slopeId:String):java.util.List[PaymentListItem] = {
        
        val accounting:Accounting = new Accounting(partyId)
        
        accounting.adjustBy(slopeId)
        
        createPaymentListItemsOfParty(accounting.party)
    }

    private def createPaymentListItemsOfParty(party:Party):java.util.List[PaymentListItem] = {
        val paymentListItems = new ArrayList[PaymentListItem]()
        party.participants.values.foreach { participant =>
            val paymentListItem:PaymentListItem = convertToPaymentListItem(participant, party.paymentOf(participant.userName))
            paymentListItems.add(paymentListItem)
        }
        paymentListItems
    }

この場合は、ドメインモデルとビューモデルの変換処理がアクションクラスの責務として与えられています。付け加えれば、まだ実装されていないバリデーションロジックも、ドメインレイヤへの入力を保証するタイプのものはここに実装されることになります。


ドメインモデルがユーザのメンタルモデルと整合していたとしても、Viewとして表示するためにはそれを操作しやすいように切り取る必要があります。これは3次元の立体を切断して2次元の平面を見せるようなものと考えると分かりやすいかもしれません。そういった「見せ方」はドメインモデルの変化とは別の水準で変化する可能性がある部分であるため、レイヤとしても分けておくべきでしょう。

トランザクションスクリプト

ドメインモデルを説明するためのシンプルなサンプル」には、実はドメインロジックと呼べるようなものがほとんど存在せず、「これならトランザクションスクリプトで良いのでは」ということになりがちというのは、普遍的な悩みなのかもしれません。「割り勘アプリケーション」にも同じパターンに陥っており、「傾斜付き割り勘の計算」を除けば、ドメインロジックと呼べるようなものはありません。こういう場合には、どこまでロールオブジェクトを作るべきなのでしょうか。


割り勘アプリケーションでは、暫定的にCQRSのアイデアを借り、検索に限ってはActionクラスから直接リポジトリクラスにアクセス可能、としています。しかし、これだけではまだすっきりしておらず、Actionから直接データオブジェクトを操作しても良いのではないかと思われる処理も少なくありません。逆に、分析系の処理であれば検索にビジネスロジックが入ってくる可能性も十分にあり得ます。割り切り方としては「データオブジェクトに準備された基本的なAPIを操作するだけで事が足りるならばトランザクションスクリプトを貫き、なんらかのビジネスロジックが発生したタイミングでロールとコンテキストを作る」という方針の方が適しているのかもしれません。この点においても、ドメイン層とのファサードとしてアプリケーションレイヤを準備しておく事には意味があります。

データ設計

ここでは、DCIで実装する場合のデータ設計方針について考えたいと思います。まずは、割り勘アプリケーションで使っているデータのクラス図を示します。

エンティティとバリューオブジェクト

DDDの用語に従えば、図中青く塗られているのがエンティティ、グレーで塗られているのがバリューオブジェクトになります。DDD流の実装であれば、「隠れている概念をバリューオブジェクトとして抽出し、そこにふるまいをもたせる」ということになりますが、DCIのパラダイムではふるまいをロールクラスに集中させるため、バリューオブジェクトにふるまいは持たせません。


ならば、例えばUserNameのように、わざわざフィールドが1つだけのクラスを作る必要があるのか、という疑問が浮かびますが、ふるまいを持たせなかったとしても、ソースコードの可読性が格段に向上するというメリットがあります。バリューオブジェクトを作る前後のコードを比較してみましょう。

class Party(val id:String) {
	
	var name:String = null
	
	var schedule:Date = null
	
	var location:String = null
	
	var sum:Int = 0
	
	var participants = Map[String, Participant]() // userName, Participant
	
	var allots = Map[String, Allot]() // userName, Allot

participantsの宣言箇所では、キーの型しか見えないため、「String」と言われても何か分かりません。それを補うためにコメントを書いているのですが、「コメントを書いて補うならば、リファクタリングせよ」です。

@serializable
class Party(val id:String) {

    var info:PartyInformation = _
    
    var sum:Int = 0

    private var _participants = Map[UserName, Participant]()

    private var _allots = Map[UserName, Allot]()

この違いはクライアントコードにおいてさらに顕著に現れます。

    planning.addPerticipant("A", "a")
    planning.addPerticipant("B", "b")
    planning.commit

バリューオブジェクトを作らなかった場合、ここだけ見ると意味が理解できません。planning.addPerticipant("太郎", "部長")などと書くべきなのかもしれませんが、任意の値が持つ意味でコードを解釈させることが危険であることには変わりありません。バリューオブジェクトを使えばこうなります。

    planning.addPerticipant(UserName("A"), Role("a"))
    planning.addPerticipant(UserName("B"), Role("b"))
    planning.commit

planning.addPerticipant(UserName("太郎"), Role("部長"))の方はやや冗長に見えるかもしれませんが、解釈の安定感は格段に向上しています。


ここから分かる通り、DCIを採用したとしてもデータの設計指針には特別な変化はありません。メンタルモデルに忠実に、概念を抽出しながらモデリングしていけば良いことになります。

集約ルートとロールオブジェクト

DCIではデータはビジネスロジックに関わるようなふるまいは持たず、その役割はロールオブジェクトが担います。そこで問題となるのが、「どのデータオブジェクトに対してロールオブジェクトを注入すれば良いのか」です。これについてはいろいろやり方はあると思いますが、そこで注意しなければいけないポイントがあります。

  1. ロールを注入しているデータオブジェクトの構造が変わると、実装へのダメージが大きい
  2. ロールを細分化しすぎてしまうと、アルゴリズムを集約するというDCIのそもそもの目的が失われてしまう。

DCIアーキテクチャがシステムを「構造」と「ふるまい」とに分けたのは、「ふるまい」という変化しやすい部分を「構造」という安定した部分から切り離すためでした。したがって、ロールの注入は、ある程度大きな粒度を持ち、安定したポイントに対して行う必要があるのです。


割り勘アプリケーションでは、ここでも再びDDDを参考にし、「集約ルート(Aggregate Root)」という概念を借りています。集約ルートに関する説明を一部引用します。

Cluster the ENTITES and VALUE OBJECTS into AGGREGATES and define boundaries around each. Choose one ENTITY to be the root of each AGGREGATE, and control all access to the objects inside the boundary through the root.


エンティティとバリューオブジェクトを集約(AGGREGATE)内に集め、その周りに境界を定義してください。各集約に対するルートにはエンティティを1つ選び、境界内のオブジェクトに対するアクセスはすべてルートを通じてコントロールしてください。

集約ルートとなるエンティティは、そのドメインにおける主要な概念であり、リファクタリングによってモデルに多少の変化があったとしても、ルートにまで影響をおよぼすものはそれほどないと考えられます。また、境界内のオブジェクトのコントロールを仲介するという意味でも、ロールの注入先としては適しています。したがって、「ロールの注入は集約ルートに対して行う」ということを1つの指針にすることができます。(今回の例で言えば、「飲み会(Party)」と「傾斜(Slope)」が集約ルートになっています。)

まとめ

DCIアーキテクチャに基づいたWebアプリケーションを作る上での実装上の考慮点について考察してきました。気がついた方もいらっしゃると思いますが、実は「DCIだからやらなければいけないこと」は特になく、アプリケーションを作成する上でどうレイヤを切るか、またどうやってデータを設計するのかという話がベースになっています。「Scalaを使う」あるいは「Traitを使う」という新しさとは別の次元で、基本は基本であり続けるということですね。


割り勘アプリケーションはこちらで公開しています。