shimapapa.io

.NET,VB,C#,AzureなどMS関連中心の技術ブログ

【DDD練習】「JR 新幹線 料金ルールを実装してみよう」にチャレンジ(その3)

前置き

以下シリーズ化してきているDDD練習の第3回です。

rikupapa-shima.hatenablog.com

rikupapa-shima.hatenablog.com

練習課題

github.com

今回対応する要件

団体割引(group discount)

8人以上が同一行程で旅行する場合に適用されます。

  • 12月21日〜1月10日 10%
  • それ以外 15%

31人以上の割引

  • 31〜50人の普通団体は、1人分の運賃と特急料金が無料になります
  • 51人以上の場合は、50人増えるごとに、1人ずつ運賃・特急料金が無料になります。

実装

年末年始の期間は団体割引の率が下がります。
そもそも適用期間内かどうかを判定するために計算に出発日(BoardingDate)が必要だったことがわかりました。
出発日(BoardingDate)を値オブジェクト(ValueObject)で表現し、「12月21日〜1月10日」の期間を「NewYearHolidaysPeriod」と表現しました。
NewYearHolidaysPeriod」は、もしかしたら毎年日付が変わるかもしれないので、年ごとに履歴管理するべきかもしれません。
実戦においてはこうした要件だけから読み取れない部分は、ヒアリングする必要があると思います。
今回はとりあえず定数的に管理することにします。

public class BoardingDate
    {
        public readonly DateTime value;

        public BoardingDate(DateTime value)
        {
            this.value = value;
        }

        public bool duringNewYearHolidaysPeriod()
        {
            return NewYearHolidaysPeriod.during(this);
        }

    }
public class NewYearHolidaysPeriod
    {
        private const int StartMonth = 12;
        private const int StartDay = 21;
        private const int EndMonth = 1;
        private const int EndDay = 10;

        public static bool during(BoardingDate boardingDate)
        {
            if (boardingDate.value.Month == StartMonth && boardingDate.value.Day >= StartDay) { return true; };
            return boardingDate.value.Month == EndMonth && boardingDate.value.Day <= EndDay;
        }

    }

続いて、料金計算には人数が必要だったことも判明したので、利用人数をNumberOfPeopleクラスで表現します。
団体人数の区分をこのクラスで判定します。
要件の中で31〜50人は「普通団体」と記載されていたので、

  • 普通団体(31〜50人)= OrdinaryGroup
  • 大規模団体(51人以上)= LargeScaleGroup

と定義。団体割引の適用可否の判定も実装しました。

public class NumberOfPeople
    {
        public readonly int value;

        public NumberOfPeople(int value)
        {
            this.value = value;
        }

        public bool isGroupDiscountNotApplicable()
        {
            return this.value < 8;
        }

        public bool isGroupDiscountApplicable()
        {
            return this.value >= 8;
        }

        public bool isOrdinaryGroup()
        {
            return this.value >= 31 && this.value <= 50;
        }

        public bool isLargeScaleGroup()
        {
            return this.value >= 51;
        }

    }

続いて団体割引額を表現するクラスGroupDiscountを実装。
引数のTripクラスはその1〜2で実装した運賃、特急料金と片道/往復など各割引を計算するクラスです。
割引率は値オブジェクトGroupDiscountRateで定義しました。

public class GroupDiscount
    {
        private readonly Trip trip;
        private readonly BoardingDate boardingDate;
        private const double roundDownNumber = 0.1;

        public GroupDiscount(Trip trip, BoardingDate boardingDate)
        {
            this.trip = trip;
            this.boardingDate = boardingDate;
        }

        public int value()
        {
            GroupDiscountRate groupDiscountRate = boardingDate.duringNewYearHolidaysPeriod() ? new GroupDiscountRate(0.1) : new GroupDiscountRate(0.15);
            var discounted = (int)Math.Floor((this.trip.value() * groupDiscountRate.value) * roundDownNumber) * 10;
            return discounted;
        }
    }
public class GroupDiscountRate
    {
        public readonly double value;

        public GroupDiscountRate(double value)
        {
            this.value = value;
        }

    }

ここまでは「団体割引」の実装で、ここから「31人以上の割引 」時の無料になる人数の計算の実装になります。
インターフェイスGroupTypeに団体の区分クラスを生成するstaticメソッドを実装。
NumberOfPeopleで実装した判定メソッドから、区分クラスを生成します。
無料となる人数が発生しない区分(30名以下)を、小規模団体SmallScaleGroupと表現しました。

public interface GroupType
    {
        NumberOfPeople groupDiscountApplicableNumber();
        public static GroupType valueOf(NumberOfPeople numberOfPeople)
        {
            if (numberOfPeople.isLargeScaleGroup()) { return new LargeScaleGroup(numberOfPeople); };
            if (numberOfPeople.isOrdinaryGroup()) { return new OrdinaryGroup(); };
            return new SmallScaleGroup();
        }
    }
public class SmallScaleGroup : GroupType
    {
        public NumberOfPeople groupDiscountApplicableNumber()
        {
            return new NumberOfPeople(0);
        }
    }
public class OrdinaryGroup : GroupType
    {
        public NumberOfPeople groupDiscountApplicableNumber()
        {
            return new NumberOfPeople(1);
        }
    }
public class LargeScaleGroup : GroupType
    {
        private readonly NumberOfPeople numberOfPeople;
        public LargeScaleGroup(NumberOfPeople numberOfPeople)
        {
            this.numberOfPeople = numberOfPeople;
        }
        public NumberOfPeople groupDiscountApplicableNumber()
        {
            return new NumberOfPeople(this.numberOfPeople.value / 50);
        }
    }

最後に、ここまでで定義してきたクラスを利用する計算処理ですが、新たにTicket(きっぷ)クラスを定義。
その中に実装することにしました。
ここに来てようやく出てきたTicketという概念。
そもそもこのクラスが今回の集約ルートになりそうな気もします・・・。

public class Ticket
    {
        private readonly Trip trip;
        private readonly BoardingDate boardingDate;
        private readonly NumberOfPeople numberOfPeople;
        public Ticket(Trip trip, BoardingDate boardingDate, NumberOfPeople numberOfPeople)
        {
            this.trip = trip;
            this.boardingDate = boardingDate;
            this.numberOfPeople = numberOfPeople;
        }

        public int Amount()
        {
            if (numberOfPeople.isGroupDiscountNotApplicable())
            {
                return trip.value() * numberOfPeople.value;
            }
            return groupDiscountedAmount();
        }

        private int groupDiscountedAmount()
        {
            GroupDiscount groupDiscount = new GroupDiscount(trip, boardingDate);

            GroupType groupType = GroupType.valueOf(numberOfPeople);
            NumberOfPeople discountApplicableNumber = groupType.groupDiscountApplicableNumber();

            var discountedFare = trip.value() - groupDiscount.value();
            return (discountedFare * numberOfPeople.value) - (discountedFare * discountApplicableNumber.value);
        }

    }

サービスクラスの前回からの差分は以下になります。
サービスクラスから見ると、既存の他クラスを修正せずに団体割引を追加できたことがよくわかります。
つまり、安全に要件追加を出来たことになるのではないかと思います。

f:id:rikupapa-shima:20200128052625p:plain

まとめ

団体割引の要件を文章にするとたった7行ですが、そこから抽出されたクラスは10クラスになりました。
短い要件のなかから、如何にドメインを抽出していけるかを考える。
ソフトウェア設計の面白いところだと思いました。

残る要件は

季節(season)による特急指定席料金の変動

のみです。次回でラストにしたいです。

参考書籍

現場で役立つシステム設計の原則 ~変更を楽で安全にするオブジェクト指向の実践技法

現場で役立つシステム設計の原則 ~変更を楽で安全にするオブジェクト指向の実践技法