TL; DR.
世は戦乱。 #チケット料金モデリング というハッシュタグにてアプリケーション設計を営みとする猛者たちに火蓋が切り落とされました。ワイも参戦すべく張り切っていったのですが、女子高生の無駄遣いを見たり女子高生の無駄遣いを見たり、女子高生の無駄遣いを見たり、iPad Proを買いに行っていたら2週間くらい経っていました。
さすがにこれでちょっとしたコードを晒すだけなのはアレなので、どうせならドメインモデリングだけにとどまらず、簡単なcliっぽい形にしてクリーンアーキテクチャを実現してみることにしました。言うならば 実践クリーンアーキテクチャチケット料金モデリングといったところでしょうか(ただ単に大きな話題に乗っかっただけともいう)。お題を提供してもらったかとじゅんさんに多謝です。
github repository
サンプルコードだけ先に見せろよ、まずは! という漢の皆さんには https://github.com/jupemara/ddd-ticketよりどうぞです。(github側のREADMEにも前提条件など、詳細を書いておきました。)
経緯
かとじゅんさんにより、https://cinemacity.co.jp/ticket/ よりシネマシティのチケット料金をモデリングせよというお告げがありました。
前提条件
ワイはこれ、最初にオンライン決済するのか、オフライン決済するのか悩ましかったですが、とはいえドメインエキスパートがいないので、オフライン決済だけにフォーカスして実装することにしました。
ユースケース(ワイ的解釈)
映画館受付スタッフが映画、顧客の種類と人数を指定すると合計金額が返ってくる
- 上映スケジュールによって金額が変わるので、上映スケジュールも何らかの方法でインプットする
- 映画館受付スタッフが上映スケジュールも一緒に入力するのかなりダルいと思うので、上映時間から上映スケジュール(レイトショーなのか、映画の日なのか)を算出できる
ユビキタス言語になりそうなキーワード
- 顧客
- 顧客の種類
- 合計金額
- 映画
- 時間
- 上映スケジュール
- レイトショー
- 映画の日
ドメインモデリング(チケットと注文という概念)
ここでシネマシティのサイトを見に行った人は 同伴者1名様も同額。手帳をご提示下さい。
というキーワードを見たはずです。.。o○ (つまり同伴者が2名以上は許可されないというドメインロジックが必要であると...ゴクリ)
これどういうふうに実現すればいいかなぁというのかなり悩みました。そもそもワイの中では当初
- 顧客, 映画, 上映スケジュールを渡すと料金が返ってくるドメインサービス
- 映画モデルに
割引を適用する
メソッドを定義して顧客の種類による割引、上映スケジュールによる割引を適用する - 2.の顧客がベース板
- 2.の上映スケジュールがベース板
という選択肢だったのですが、よくよく考えるうちに、そもそも一人あたりのチケット料金を求めるわけではないので、注文の合計金額がドメインロジックに則したモノで且つ1人あたりのチケット料金を算出する必要があるという結論に至りました。
ここでお気づきの方もいらっしゃると思いますが、 注文, チケット, チケット料金 という新しいユビキタス言語になりそうなキーワードが出てきました。ワイの中では 同伴者1名様も同額
は重要なビジネスロジックのひとつなのでこれを無視することはできませんでした。
ドメイン層のコード解説
ドメイン層には
src/domain/model
にドメインモデル、つまり顧客, 顧客の種類, 映画, 映画の種類, 上映スケジュール に関するモデリングのコードがあります。また src/domain/service
には日付から上映スケジュールを算出するサービスを定義してあります。
src/domain/model/Price.ts
if else
もしくはswitch
を使って表現するの辛すぎだったので、三次元のテーブル構造を使ってPriceを表現しました。
{
"映画の種類": {
"上映スケジュールの種類": {
"顧客の種類": 1800
}
}
}
のような形で、keyを指定していくと金額が得られます。
src/domain/model/Ticket.ts
ここには上記のPrice.tsで設定した料金を顧客, 映画, 上映スケジュールの種類を指定してチケット1枚あたりの金額を取得するコードが入ってます。
Ticket.calculatePrice()
このメソッドはチケットの料金を計算するだけ。対応するものがなかった場合は組み合わせがおかしい内容のエラーを吐きます。
Ticket.validate()
ではドメインロジックが暗黙的になるのを防ぐために
- エムアイカードユーザで且つ映画の日の対象は存在しない
- 駐車場パーク80割引で且つ映画の日の対象は存在しない
特別興行の場合は上記の限りではございません。
を明示的に定義するために、validateメソッド内で使用されるprivateなメソッドを定義しています。
src/domain/model/Order.ts
チケット金額の集合を注文と考えてこの Order
を集約ルートに近い形にしました。候補としては
- OrderIdをつける
- 考慮しましたが、今回のユースケースでは注文を保存したり、追跡したりという要件はないので、一旦追跡可能な同一性については考慮外としました
- MovieIdとしてしか持たない
- Movieをインスタンスとして持つのではなくMovieIdだけで参照するというアイデアもないわけではなかったですが、遅延ローディングをやるとしても
this.movie = await new Movie(movieId).load()
的なことをやると思ったので、今回はMovieインスタンスを直接持つことにしました
- Movieをインスタンスとして持つのではなくMovieIdだけで参照するというアイデアもないわけではなかったですが、遅延ローディングをやるとしても
src/domain/service/ScreenScheduleTypeDetector.ts
その名の通り、上映スケジュールの種類を判断するサービスです(平日レイトショー, 平日通常, 土日祝日レイトショー, 土日祝日通常, 映画の日)。ここでは
- 平日か土日か
- 祝日か否か
- 映画の日か否か
- レイトショーか否か
というドメインロジックが必要なのですが、これを愚直に実装してしまうと文字列から日付のパーシング、祝日かどうかの判断という技術的詳細に立ち入ってしまうので、DIPを使って技術的詳細はadapter層に(使用するアーキテクチャによってはinfrastructure層といったほうがいいかもしれません)逃しました。これで何が起こっているかと言うと
- 日付のパーシングという技術的詳細をadapter層に逃す
- つまり例えば今回使用しているライブラリはdayjsというモノですが、のっぴきならない理由でこれを違うライブラリに差し替える場合はadapter層のコードののみの変更だけでよいということです
- ビジネスロジックを暗黙的なルールにしない
- ワイとしては平日や土日や映画の日というのはドメインロジックなので、これが
if (parsedDatetime.getDay()) === 1
みたいな形で暗黙的になったり、ましてやadapter層側にかかれていたりすることは許せなかったです
- ワイとしては平日や土日や映画の日というのはドメインロジックなので、これが
ユースケース層
今回のユースケースは与えられた情報からチケットの合計金額を算出することなのでユースケースには src/usecase/CalculateAmountOfTicketsUsecase.ts
しか存在していません。この中で先程のvalidateをメソッドを呼び出したり、チケットの合計金額を算出したりしています。
クリーンアーキテクチャ
ここで唐突にクリーンアーキテクチャですが、ドメインモデリングをやってた時点でコードのインターフェイスは割と出来上がってたのですが、女子高生の無駄遣いがおもしろすぎてなかなか前にすすめることができず、しかしこのままでは出遅れた感しかないので、クリーンアーキテクチャで簡単なcliを実装して解説するところまでをゴールにしようという趣が生まれました。
有名な図を拝借すると
ですが、これを今回のチケット料金モデリングで実現していきます。
Entities
ここが今回のコードで言うところの src/domain
配下のコードになります。絶対に譲れないビジネスルールをここに書く!!何があってもこれだけはぶれない!!今回のケースでは
- 顧客の種類, 映画の種類, 上映スケジュールの組み合わせでチケット一枚あたりの料金が決まる
- ただし障害者の同伴者は1名まで
- 上映スケジュールの区分分けは以下が存在する
- 平日通常
- 平日レイトショー
- 土日祝通常
- 土日祝レイトショー
- 映画の日
- 極上爆音上映ではレイトショー適用外
- 特別興行は通常とは違うフローになる(今回は単純にエラーとしました)
というのが譲れないところですね。
Use Cases
ここではEntitiesを使って実現したいユースケースについて書いていきます。
src/usecase/CalculateAmountOfTicketsUsecase.ts
の中身を見たほうが手っ取り早そうですが、気をつけたところは
- プリミティブ型から値オブジェクトやドメインモデルへの変換はcontrolerに任せる
- ここでプリミティブ型の組み合わせとしてのDTO(しばしばDPOと呼ばれたりしますが)は使わない
- 処理の流れを日本語で考えたときに不自然じゃないようにする。ここでは
- 上映スケジュールを判断する
- 注文を作成
- 注文内容がおかしくないかチェック
- 注文内容からチケットを算出
- 各チケットがおかしくないかチェック
- チケットを返す
Interface Adapters
ここにはデータの変換処理(DTOからドメインモデルへの変換, 逆にドメインモデルからビューモデルへの変換)、または永続化としてのrepository, 技術的詳細について書かれたりします。
Controller/Presenter
しばしば処理が小さい時は、DTOを用いずにControllerに直接例えばリクエストパラメータからドメインモデルへの変換などを書いたりしますが、今回はあえてDTOを定義しました。これは例えばcliだけでなく、REST APIも作りたいとなったときは、json payloadから直接ドメインモデルへの変換を行うのではなく、最も外側のレイヤーではプリミティブ型からDTOへの変換にとどめておきたかったからです。
またPresenterは今回ひとつしかありませんが、例えばこれをXMLで返したい際にはXML用の別のビューモデルを定義して異なるpresenterを外側のHTTP Handlerなどに渡してあげる形に仕上がります。
余談ですが、僕の観測範囲では、ビューモデルをプリミティブ型のオブジェクトとするパターンと、ここでXMLとして組み立てて、最も外側ではそれをレンダリングするだけのパターンがあるのかなと感じているのですが、ビジネス要件に合わせて依存関係がすっきりする方を選ぶのがいいんじゃないかなと思ったりしています。
External Interfaces
やっと一番外側まで来ました。一番外側では例えばwebの薄いフレームワークやcli, GUIなど技術的詳細について書きます。ここではcliのハンドラーとしてsrc/external_interface/cli/CalculateAmountOfTicketsHandler.ts
内にUIを定義しています(UIっつっても、console.logに適当な文字列吐き出すだけやけどな)。またこのcliへのDIを src/Main.ts
で行ってます。
またこのcliは標準入力がjson文字列を受け取って実行されます。例えば
$ echo '{"movie": "general", "customerTypes": ["general", "handicapped", "handicappedPartner"], "datetime": "2019-07-31T01:35:29+09:00"}' | node scripts/start.js
指定されたお客様のチケットの合計金額は3800円です。
内訳は
一般: 1800円
障がい者(学生以上): 1000円
障がい者(学生以上)同伴者: 1000円
となります。
ってな感じです。UIは変わりやすいと言いますが、例えばアウトプットの表現を変更したいときもここの変更だけでなんとかなりそうな気がしてきたんじゃないスカね??
処理の流れ
ここまでの処理の流れを整理すると
- 標準入力からデータを受け取る
- External Interfaceであるcliのhandlerが文字列をDTOにしてcontrollerを実行する
- controllerはDTOをドメインモデル, もしくは値オブジェクトにしてusecaseを実行する
- usecaseはドメインモデルを使ってビジネス的なユースケースを実行してドメインモデルを返す
- presenterはusecaseが返すドメインモデルを受け取ってビューモデルを作成する
- cli handlerはpresenterから返ってきたビューモデルを使ってUIを構成、ターミナルに出力する
となります、つまり途中でadapterに寄り道したり、ドメインモデルを外側にさらしたりってことがなくなってるってことですね。
まとめ
実は前に社内でDIPは大事だって話と、開発初期のタイミングでビジネスロジックにどれだけ集中できるかでスプリント0の仕上がりと開発が中/後半に差し掛かって運用フェイズに入った時の開発スピードの劣化を防げるって話を雑談レベルでやってたんですけど、"サンプルコードありますか??"って聞かれて、IDDDのサンプルしか出せなかったのでわかりやすい命題で且つうまく技術的詳細はなんとでもなるというのを表現したかったという背景があります。
今回はDTOとPresenterを使って、ワイが普段個人開発で書いてる内容を書き出してみました。最後になりましたが!"いやいや、ここはこうじゃね"ってのがあったらぜひともご指摘くださいませ。それでは良いチケット料金モデリングライフを!!