BIGLOBEで1年間業務をすると、どれだけDDDのスキルが向上するか

f:id:biglobe-editor2:20200110114138j:plain

基盤本部(開発部門)の小野田です。

私は 2019 年に中途採用で BIGLOBE に入社して以来、主に既存システムのリニューアル案件に関わり、その中で、モデリングの経験を多く積んできました。本記事では業務で得たモデリングの知見を基に 鉄道料金計算問題 を再モデリングした結果と 1 年前のモデリング結果とを比較して、1 年間でどれだけスキルアップしたかを紹介したいと思います。ここで紹介する内容は、同じ名前のオブジェクトでも性質が異なれば別の値オブジェクト ( Value Object: VO ) としてモデリングしたほうが良いことを示す実例となります。

1 年前のモデリング結果は DDD くらいできるようになりたいよねって話 をご覧ください。

style.biglobe.co.jp

なお、この記事の内容やプログラムは教育用に作成した架空のものであり、実在のサービスや団体などとは一切関係ありません。

想定読者

本記事は以下のような方を読者として想定しています。

  • ドメイン駆動設計 ( Domain Driven Design: DDD ) に興味がある方
  • BIGLOBE への入社に興味がある方

記事の途中でマイクロサービスに関する話題も出ますが、本記事はあくまで DDD のモデリングや実装に関する話題が主であり、マイクロサービスに関する技術的な話はしません。

システム構成

今回のモデルは下図に示す通り 5 つのマイクロサービスで構成されます ( 右端の catalogue-db は DB なので、マイクロサービスにカウントしていません ) 。各マイクロサービスの責務については GitHub リポジトリ をご覧ください。

f:id:biglobe-editor2:20201225190610p:plain
システム構成

実を言うと元々はマイクロサービスに興味があり、鉄道料金計算問題を学習用のマイクロサービスとして作ったところ、モデリング結果が 1 年前と大きく変わったので、その知見を記事としてまとめることになりました。

スキルアップした点

本記事ではスキルアップした点として、以下の 3 点を紹介します。

  1. 業務知識を反映したモデルを作成できるようになった
  2. コンテキストの境界を意識してモデルを作成できるようになった
  3. 品質の高い単体テストを作成できるようになった

それでは以下で詳細を説明したいと思います。

なお、以降では 1 年前のモデリング・実装結果を Before 、今回のモデリング・実装結果を After と呼びます。

業務知識を反映したモデルを作成する

鉄道料金計算問題において、「大人 1 人が 12/25 に東京から新大阪まで、ひかり ( 指定席 ) で片道移動する」場合の特急料金は 5690 円 です。これは以下の流れで計算できます。

f:id:biglobe-editor2:20201225190658p:plain
特急料金計算の流れ

さて、私たちは「特急料金はいくらですか?」と質問されると上記計算の 3 で得られた 5690 円を回答しますが、では、1, 2 の計算結果である 5490 円と 5690 円は何者でしょうか?

3 者の比較

3 者は 同じ / 別の ものなのかをはっきりさせるために以下の観点で比較をしてみましょう。

No 季節による変動料金を調整できるか? 団体割引を適用できるか?
1 Yes No
2 No Yes
3 No No

季節による変動料金の調整は 1 度のみなので 2, 3 に対して 200 円を加算することは許されません。同じ理由で 3 に対して団体割引を適用することは許されません。

また、季節による変動料金の調整と団体割引を適用する順序を入れ替えると最終的に得られる金額が変更になるため、1 に団体割引を適用することは許されません。

この比較結果から 3 者は許可される操作が異なるため 別者である ことがわかります。

業務知識が反映されていないモデル

Before では 1, 2, 3 は全て同じもの ( 特急料金 ) としてモデリング・実装して、以下のような VO を作りました。言語は Kotlin です。

data class SuperExpressSurcharge(private val value: Int) {
    fun variedBySeason(season: Season) =
        SuperExpressSurcharge(value + season.value)

    fun forChild() = value / 2

    fun forAdult() = value

    fun discounted(discountRate: DiscountRate): SuperExpressSurcharge {
        val discountedValue = value - (value * discountRate.value / 100)
        return SuperExpressSurcharge((discountedValue / 10) * 10)
    }
}

ご覧の通りこの VO は

  • 季節による変動料金を n 回調整することができる ( n は 0 以上の整数 )
  • 団体割引を n 回適用することができる ( n は 0 以上の整数 )
  • 季節による変動料金の調整と団体割引を適用する順序を制御できない

という点で業務知識を反映できていません。実装者が業務ルールを把握した上で、適切に実装をする必要があります。

業務知識を反映したモデル

After のモデルでは 1, 2, 3 の料金をそれぞれ以下のように別々の VO としてモデリングしました。

No クラス名
1 季節変動料金調整前の指定席特急料金
2 割引適用前の指定席特急料金
3 特急料金

季節変動料金調整前の指定席特急料金割引適用前の指定席特急料金 の factory にすることで、Before のモデルでは表現できていなかった業務知識を表現できるようになりました。

このように VO をたくさん作ると「後で見返した時や途中から参加したメンバが各 VO の違いを把握できず、かえって混乱するのでは?」という意見もあるかと思います。本プロジェクトではこの問題を解決するため、ドメイン層にあるクラスには簡易的な Javadoc コメントを付けてあります。

業務知識を型で表現するメリット

After のようなモデルにすることで以下のメリットが得られ、結果として堅牢なサービスを開発することができるようになります。

  1. 計算ミスが起こらない

    • 割引回数や計算フローが型レベルで制限されるため、計算ミスが起こらなくなります
    • 「いやいや自分はそんな凡ミスしないから」と思っていても、疲れなどでうっかりミスをしてしまうことは誰の身にも起こり得ます ( 実は私も同じようなミスをしてレビュー指摘された経験があります )
  2. コードレビューの質が上がる

    • 割引回数や計算フローは型で制御されているため設計の良し悪しなど、他の観点にレビューの時間を割くことができるようになります

コンテキストの境界を意識してモデルを作成する

特急料金の計算に焦点を当てると、下図のようなコンテキストマップを描くことができます。

f:id:biglobe-editor2:20201225190755p:plain
コンテキストマップ

ご覧の通り目的地や列車区分、座席区分や乗客など、同じ名前の VO が複数コンテキストに存在しますが、これら同名の VO は共通化 すべき / すべきではない のどちらでしょうか?

ここでは赤枠で囲んだ乗客 ( Passenger ) を例に、共通化すべきか否かの判断基準を示したいと思います。

2 者の比較

各コンテキストにおける Passenger の位置付けを整理すると以下のようになります。

  • 料金計算コンテキスト

    • Passenger は料金を計算する際に必要な情報であり、子供料金 x 子供の乗客数大人料金 x 大人の乗客数 のような形で利用する
    • 誤って 子供料金 x 大人の乗客数大人料金 x 子供の乗客数 のような計算ミスをしないように型で制御したい
  • 特急料金コンテキスト

    • Passenger は割引コンテキストに渡す情報であり、特急料金コンテキストでは使用しない
    • したがって 子供の乗客数大人の乗客数 を型で区別する必要はない

この比較結果から 2 者は性質が異なるため 別者である ことがわかります。

別々の道を歩む

今回は上記理由から 2 者は別者であり共通化しない戦略を取りました。最終的に出来上がった 2 者の差分は以下の通りです。言語は Java です。

赤でハイライトした行が特急料金計算コンテキストでの Passenger で、緑でハイライトした行が料金計算コンテキストでの Passenger です。

public class Passenger {
-  @Getter private final int children;
-  @Getter private final int adults;
+  @Getter private final Children children;
+  @Getter private final Adults adults;
+
+  public ChargedPassenger exclude(ComplimentaryPassenger complimentaryPassenger) {
+    return new ChargedPassenger(
+        children.minus(complimentaryPassenger.getChildren()),
+        adults.minus(complimentaryPassenger.getAdults()));
+  }
}

Before のように共通化する戦略をとった場合、無料になる乗客 ( ComplimentaryPassenger ) や 課金対象の乗客 ( ChargedPassenger ) のような特急料金計算コンテキストに不要な概念が入り込み、コンテキスト間の依存関係が強くなってしまいます。

コンテキストの境界を意識してモデルを作成するメリット

After のようなモデルにするメリットは「コンテキスト間の依存度を低く保つことで変化に強くなること」です。

1 年経って大きくモデルが変わったように、今後もモデルが変更になる可能性は大いにあります。その際コンテキスト間の依存度が高いと変更範囲が広くなり、モデルの変更は大変な作業になりますが、After のように依存度を下げることで容易にモデルを変更できるようになります。

品質の高い単体テストを作成する

皆さんは単体テストをどの粒度で作成しますか?

料金計算などミスが許されないロジックは単体テストを書くとか、カバレッジが 100% になるように単体テストを書くとか、様々な考え方があるかと思います。

Before では以下の方針で単体テストを書きました。

  • ドメイン層のみ単体テストを書く
  • 料金計算の単体テストを書くことで運賃や特急料金を計算するロジックも実行されるため、運賃や特急料金を計算するロジック自体の単体テストは書かない

その結果 Before の単体テストは 料金計算ドメインサービス しか書いていません。

一方 After は以下の方針で単体テストを書きました。

  • ドメイン層のみ単体テストを書く
  • ロジックの妥当性を担保すべき単位で単体テストを書く

    • 例えば 料金計算ドメインサービス の単体テストを書くとそこで使われている運賃や特急料金などの VO のロジックも実行されますが、運賃に特急料金を加算したら適切な金額を持った料金を算出することは運賃が担保すべき責務であるため、運賃の単体テストも書く

その結果 After では ご覧の通り ドメインサービスの他、VO の単体テストも書きました。

担保するロジック単位で単体テストを作成するメリット

  • テストが失敗した時に失敗個所を特定しやすくなる

  • 各クラスの責務が明確になる

まとめ

BIGLOBE で 1 年間 DDD の実務経験を積んだ結果、業務知識を反映した深いモデルや質の高い単体テストを作成するスキルが身につきました。本記事で紹介したスキルの他にも AWS 認定ソリューションアーキテクトアソシエイトの資格を取得するなど、エンジニアとして大きく成長できたと感じています。

最後に、本記事では触れませんでしたが冒頭で述べた通り、元々は学習用のマイクロサービスを作成する目的で鉄道料金計算問題をマイクローサービス化しました。学習用のサービスが完成したので今後は

  • 各サービスを別々の言語で実装して Polyglot 構成にする
  • Apache Kafka のような分散メッセージキューを使って非同期型のシステム構成にする
  • Zipkin のような分散トレーシングシステムを導入してシステムのトレーサビリティを向上させる

などを試し、マイクロサービスに関する知見をためていきたいと考えています。

参考図書

本記事を読んでドメイン駆動設計に挑戦したいと思った方、BIGLOBE で一緒に取り組んでみませんか?

興味のある方はぜひカジュアル面談にいらしてください。

hrmos.co

※ Amazon Web Services、AWSロゴおよびかかる資料で使用されるその他のAWS商標は、米国その他諸国における、Amazon.com, Inc.またはその関連会社の商標です。