🐕

約 3 年半の DDD 実務経験を締め括る

2023/03/06に公開

ドメイン駆動設計 ( Domain Driven Design: DDD ) を実践している企業で約 3 年半開発してきましたが、このたび転職をすることになり、DDD を卒業 ( ? ) することになりました。約 3 年半の DDD Life を振り返って「うまく実践できていないな」と感じた点について、その事例と解決案を紹介します。

この記事は下記 2 つの記事の締め括り ( 最終回 ) になります。まだ読んでいないという方はぜひ一度読んでみて下さい[1]

https://style.biglobe.co.jp/entry/2020/01/15/130000

https://style.biglobe.co.jp/entry/2021/01/13/110000

想定読者

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

  • DDD に興味がある方
  • DDD の実践例や体験談を聞きたい方
  • 前述した記事で紹介したモデルのその後が気になっている方

この記事の目的

この記事は「DDD とは何か?」について、DDD の実務経験を経て辿り着いた私なりの理解を できる限り平易な言葉で説明すること を目的としています。

例えば Wikipedia にあるような、正しいかもしれないが分かりにくい説明を自分なりに咀嚼できずに「DDD わからない」となっている方が、「DDD わかった」状態になってくれたら嬉しく思います。

ドメイン駆動設計(英語: domain-driven design、DDD)とは、ドメインの専門家からの入力に従ってドメインに一致するようにソフトウェアをモデル化することに焦点を当てるソフトウェア設計手法である[1][2]。

結論

DDD を噛み砕くと以下 2 つを実践することになります。

  • 運用フェーズまで見越して機能拡張や保守のしやすい設計および実装をしている
  • 投資すべきサービスを見極めている

DDD の説明で「業務を中心に据える」というようなフレーズをよく目にしますが、あまり難しく考えず、上記の実践中に出てくる以下疑問に対する回答根拠の一つとして使えば良いと思っています。

  • 機能拡張しやすい設計や実装とはどのようなものか?
  • 開発対象のサービスは投資 ( 運用フェーズまで見越して保守性の高い設計および実装 ) をする価値があるサービスか?

それではサンプルのモデルとコードを示しながら、上記について具体的な説明をしていきます。

仕様の説明

この記事では DDD の課題である 鉄道料金計算 の内「運賃の割引」を扱います。課題全体の仕様から運賃の割引に関する内容を以下に抜粋します。

  • 東京から新大阪までの運賃は 8910 円で、姫路までの運賃は 10010 円
  • 東京から新大阪までの営業キロは片道 553km で、姫路までの営業キロは 644km
  • 片道の営業キロが 601km 以上ある場合は「ゆき」と「かえり」の運賃がそれぞれ 10% 割引になる ( 往復割引 )
  • 同一工程を旅行する人数に応じて運賃が以下の通り割引になる ( 団体割引 )
    • 8 人以上が 12/10 - 1/10 に出発する場合は 10% 割引になる
    • 8 人以上が 12/10 - 1/10 以外の期間で出発する場合は 15% 割引になる
    • 31-50 人の普通団体の場合は 1 人分の運賃が無料になる
    • 51 人以上の場合は 50 人増えるごとに 1 人分の運賃が無料になる

この記事を読み進める前に、皆さんならどのようなモデルや実装にするか、ぜひ考えてみてください。

2021 年のモデル紹介

運賃の割引について、モデルおよび実装を考えてみた方ならわかると思いますが、このシステムでは「出発点となる運賃 ( 基本運賃 )」「往復割引を適用した運賃」「団体割引を適用した運賃」「最終的な運賃 ( 運賃 )」など、数多くの「運賃」が登場します。そして 2021 年 1 月に執筆したブログ 「BIGLOBE で 1 年間業務をすると、どれだけ DDD のスキルが向上するか」 では、これらの運賃を別の値オブジェクト ( Value Object: VO ) としてモデリングしました。

2021-fare-model

各運賃が次の運賃のファクトリになり、割引率を適用すると次の ( 割引を適用した ) 運賃を生成するイメージです。適用する割引率はドメインサービスである割引サービスが算出します。以下がこのモデルの実装になります。

public class DiscountApplicationDomainService {
  public static DiscountAppliedFare apply(
      DiscountNotAppliedFare discountNotAppliedFare,
      TripType tripType,
      BusinessKilometer businessKilometer,
      Group group,
      DepartureMonthDay departureMonthDay) {
    GroupDiscountNotAppliedFare groupDiscountNotAppliedFare =
        applyRoundTripDiscount(discountNotAppliedFare, tripType, businessKilometer);
    return applyGroupDiscount(groupDiscountNotAppliedFare, group, departureMonthDay);
  }

  private static GroupDiscountNotAppliedFare applyRoundTripDiscount(
      DiscountNotAppliedFare discountNotAppliedFare,
      TripType tripType,
      BusinessKilometer businessKilometer) {
    if (tripType.isOneWay()) {
      return GroupDiscountNotAppliedFare.from(discountNotAppliedFare.getAmount());
    } else if (businessKilometer.isGreaterThanOrEqual(BusinessKilometer.from(601))) {
      return discountNotAppliedFare.applied(Percent.ten());
    } else {
      return GroupDiscountNotAppliedFare.from(discountNotAppliedFare.getAmount());
    }
  }

  private static DiscountAppliedFare applyGroupDiscount(
      GroupDiscountNotAppliedFare groupDiscountNotAppliedFare,
      Group group,
      DepartureMonthDay departureMonthDay) {
    return groupDiscountNotAppliedFare.applied(
        calculateGroupDiscountPercentage(group, departureMonthDay));
  }

  private static Percent calculateGroupDiscountPercentage(
      Group group, DepartureMonthDay departureMonthDay) {
    if (group.meetGroupDiscountAppliedCondition()) {
      if (GroupDiscountTenPercentApplicationTerm.include(departureMonthDay)) {
        return Percent.ten();
      } else {
        return Percent.fifteen();
      }
    } else {
      return Percent.zero();
    }
  }
}

さて、この実装に対する皆さんの印象はどのようなものでしょうか?「予想通り」や「思っていたよりも複雑」など人によって様々かと思いますが、私が 2 年ぶりに読み直した感想は 読み難いなぁ です。

特に VO の変換処理で Getter を多用している箇所 が良くないと感じており、これは「DDD ではプリミティブ型の利用は禁止」という DDD に対する誤解から来ています。加えて VO の粒度が細かい ( 抽象化レベルが適切でない ) ため、新しい割引が追加される仕様変更が発生した場合、複数箇所に変更が入ることになります。

これが DDD の目指す「業務ルールが反映されて機能追加や保守がしやすいコード」なのでしょうか?

2023 年のモデル紹介

上述した 2021 年のモデルの反省点を踏まえて再モデリングした結果が以下になります。

主な変更点は割引率を算出する責務を割引率ファクトリに委譲したのと、運賃の抽象化レベルを「団体割引前の運賃」から「計算途中の運賃」に上げた 2 点ですが、ドメインサービスの実装はかなりスッキリしたものになりました。

public class DiscountService {

  public Fare apply(BasicFare basicFare, TripType tripType, BusinessKilometer businessKilometer,
      Group group, DepartureMonthDay departureMonthDay) {
    return basicFare
        .startDiscount()
        .discounted(RoundTripDiscountPercentageFactory.create(tripType, businessKilometer))
        .discounted(GroupDiscountPercentageFactory.create(group, departureMonthDay))
        .endDiscount();
  }
}

このモデルおよび実装では新たな割引が追加された場合も discounted メソッド呼び出しを追加するだけで対応可能になります。また、往復旅行の割引率ファクトリは以下のような実装になり、2021 年の実装 ( applyRoundTripDiscount ) と比べてスッキリしたものになっています。

public class RoundTripDiscountPercentageFactory {

  public static Percentage create(TripType tripType, BusinessKilometer businessKilometer) {
    return switch (tripType) {
      case OneWay -> Percentage.zero();
      case Round -> {
        if (businessKilometer.isDiscountable()) {
          yield Percentage.ten();
        } else {
          yield Percentage.zero();
        }
      }
    };
  }
}

トレードオフ

完璧なモデルは無くて、どのモデルにもメリットとデメリットがあります。例えば 2 つのモデルのデメリットとしては以下があります。

  • 2021 年のモデルの実装は読み難い
  • 2021 年のモデルは新しい割引が追加 ( または既存の割引が廃止 ) された場合の影響範囲が 2023 年のモデルと比べて広い
  • 2023 年のモデルは型レベルで割引を適用する順序 ( 業務ルール ) を制御できない

どちらのモデルにもデメリットはありますが、以下の理由で 2023 年のモデルの方が良いモデルと言えます。

  1. 2023 年のモデルの方が読みやすく保守性も高い
  2. 割引はそれなりの頻度で変更が入る ( 気がする ) ので、既存クラスに影響を与えない設計にした方が良い
  3. モデルおよび実装はユビキタス言語に従うべき ( 普段の会話で「割引を適用する前の運賃」や「団体割引を適用する前の運賃」などの単語は使わない )
  4. 割引順序が正しく適用されているかはテストコードのような別の手段で担保することができる

ここで挙げた 3 つ目の理由が「業務を中心に据えたアプローチ」で、DDD 的にも 2023 年のモデルの方が良いと言えますね。

投資はコアドメインに限定する

モデルと実装が綺麗になってめでたしめでたし ... ではありません。2021 年のモデルには 全てのドメインに対して DDD を適用している という別の反省点もあります。

鉄道会社に勤務したことはないので推測になりますが、例えば以下が鉄道会社のコアドメインにあたると思います。

  1. 価格の安さ
  2. 乗り換えの便利さ
  3. 車内サービスの良さ ( 新幹線の場合 )

この記事で扱った「割引」は 1 に相当するのでコアドメインですが、割引した結果を使って最終的な料金を得る処理[2]はコアドメインではありません。したがって、割引以外の処理はトランザクションスクリプト的な実装で十分なのです。

public class Main {

  public static void main(String[] args) {
    // 指定席のひかりで新大阪まで大人 2 人と子供 1 人で往復旅行する
    // パラメータの準備
    BasicFare basicFare = new BasicFare(Amount.from(8910));
    BasicSuperExpressSurcharge basicSuperExpressSurcharge = new BasicSuperExpressSurcharge(
        Amount.from(5490));
    TripType tripType = TripType.Round;
    DepartureMonthDay departureMonthDay = new DepartureMonthDay(MonthDay.of(Month.FEBRUARY, 23));
    BusinessKilometer businessKilometer = new BusinessKilometer(553);
    Group group = GroupFactory.create(2, 1);

    // 運賃と特急料金から料金を計算する
    Fare fare = new DiscountService().apply(basicFare, tripType, businessKilometer, group,
        departureMonthDay);
    SuperExpressSurcharge superExpressSurcharge = new DiscountService().apply(
        basicSuperExpressSurcharge, SeatType.Reserved, group, departureMonthDay);
    Price price = fare.plus(superExpressSurcharge);

    // 料金
    System.out.println(price.forAdult().getValue() * group.charged().adults());
    System.out.println(price.forChild().getValue() * group.charged().children());
  }
}

成果物

この記事で紹介した 2021 年モデルと 2023 年モデルの実装は以下で公開しています。この記事では紹介しませんでしたが、複雑になりがちな団体割引に関する実装も 2021 年モデル2023 年モデル とで大きく変わっています。興味ある方はぜひ見比べてみてください。

2021 年モデル
https://github.com/n-ono/discount

2023 年モデル
https://github.com/n-ono/discount-renewal

まとめ

私が関わったプロダクトコードでは Getter しかない VO だったり、せっかく作った VO に対して何らかの処理をする際にドメインサービスでプリミティブ型を取り出して処理する ようなコードがチラホラ見られました。また、グループ標準の設計手法として DDD を採用する という考え方も根付いており、ドメイン分析が適切に行われているのか疑問に感じることもありました。

このような経験を経て辿り着いた「DDD をやっている」に対する私の結論は、投資すべきサービスを見極めて、投資すると決めたサービスに対しては運用フェーズまで見越して機能拡張や保守のしやすい設計および実装をしている状態 です。

DDD が目指す事業価値を生み出し続けることができるソフトウェア、つまり、保守性が高く成長し続けられるソフトウェアの開発に最適な設計および実装手法を選択して実践できれば良いのではないか、と思う今日この頃です。

脚注
  1. この記事自体は 2 つの記事を読まなくても理解できるように執筆してあります。 ↩︎

  2. 例えば「3 人での旅行だから運賃を 3 倍する」といった処理を指します。 ↩︎

Discussion