shimapapa.io

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

【MSTest】Assert.ThrowsExceptionを使用した例外のテスト

前置き

rikupapa-shima.hatenablog.com

前回読了記事を書いた「ドメイン駆動設計入門 ボトムアップでわかる! ドメイン駆動設計の基本」のサンプルコード内で、 以下のようなテストコードを見かけました。

https://github.com/nrslib/itddd/blob/master/Layered/SnsApplication.Tests/Users/UserRegisterTest.cs#L52

[TestMethod]
public void TestInvalidUserNameLengthMin()
{
    var userFactory = new InMemoryUserFactory();
    var userRepository = new InMemoryUserRepository();
    var userService = new UserService(userRepository);
    var userApplicationService = new UserApplicationService(userFactory, userRepository, userService);
    bool exceptionOccured = false;
    try
    {
        var command = new UserRegisterCommand("12");
        userApplicationService.Register(command);
    }
    catch
    {
        exceptionOccured = true;
    }
    Assert.IsTrue(exceptionOccured);
}

UserRegisterCommand内でユーザー名が3文字以上で無い場合ArgumentExceptionが発生することを検証するテストコードです。
上記のコードでは、例外が発生する箇所をtry〜catchで補足し検証する形式とされています。

MSTestで例外をテストする(ExpectedExceptionAttribute)

前述のテストコードを見たときに、MSTestで例外をアサーションする場合、ExpectedExceptionAttributeを使用するパターンも考えられるのではと感じました。

[TestMethod]
[ExpectedException(typeof(ArgumentException))]
public void TestInvalidUserNameLengthMin_old()
{
    var userFactory = new InMemoryUserFactory();
    var userRepository = new InMemoryUserRepository();
    var userService = new UserService(userRepository);
    var userApplicationService = new UserApplicationService(userFactory, userRepository, userService);
    var command = new UserRegisterCommand("12");
    userApplicationService.Register(command);
}

MSTestで例外をテストする(Assert.ThrowsException)

最新のMSTestのバージョンではAssert.ThrowsExceptionを使うのが良いようです。

docs.microsoft.com

[TestMethod]
public void TestInvalidUserNameLengthMin()
{
   var userFactory = new InMemoryUserFactory();
   var userRepository = new InMemoryUserRepository();
   var userService = new UserService(userRepository);
   var userApplicationService = new UserApplicationService(userFactory, userRepository, userService);
   var ex = Assert.ThrowsException<ArgumentException>(() => 
        {
            var command = new UserRegisterCommand("12");
            userApplicationService.Register(command);
        }
    );
    Assert.AreEqual("ユーザ名は3文字以上です。 (Parameter 'value')", ex.Message);
}

アノテーションよりもAssert.ThrowsExceptionの方が、例外が発生する箇所も明確になるし、発生したexceptionを更に検証できるのも良いですね。

Assert.ThrowsExceptionはVS2017の時代にリリースされた「MSTest v2」で追加された機能のようです。
そもそもMSTest v2の存在自体把握できていませんでした・・・

devblogs.microsoft.com

【読了】「ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本」

前置き

今回はこちらの書籍の読了記事です。

ドメイン駆動設計入門 ボトムアップでわかる! ドメイン駆動設計の基本

ドメイン駆動設計入門 ボトムアップでわかる! ドメイン駆動設計の基本

  • 作者:成瀬 允宣
  • 発売日: 2020/02/13
  • メディア: 単行本(ソフトカバー)

ドメイン駆動設計の実装に関するパターンがサンプルコードを多数用いて紹介されており、
タイトル通りにボトムアップにドメイン駆動設計を学べる内容となっておりました。

読書メモ

第1章 ドメイン駆動設計とは

感想

ドメインモデルとドメインオブジェクトの違いが明確になった。
ASP.NET MVC等のフレームワークでは実質Modelの区分とするのはDTO=Data Transfer Objectだったりするので、
DTOなどのモデルとドメインモデルの区別も今後は明確に線引きすることが出来そう。

第2章 システム固有の値を表現する「値オブジェクト」& 第3章 ライフサイクルのあるオブジェクト「エンティティ」

エンティティは以下のような特徴を持つ。

  • 可変である
  • 同じ属性であっても区別される(例:UserオブジェクトならUserIdのような識別子で区別されるべき)
  • 同一性を持つ

感想

これらの章を読み、モデル、オブジェクト、エンティティの違いをきちんと理解できていなかったと感じた。
エンティティという概念も、O/Rマッパー上でのエンティティとドメインにおけるエンティティを区別しておく必要がありそう。

第4章 不自然さを解決する「ドメインサービス」 & 第6章 ユースケースを実現する「アプリケーションサービス」

例えば「ユーザー名の重複を許可しない」ドメインのルールを、Userオブジェクト自身に表現するのは不自然。
その不自然さを解決させるために「ドメインサービス」を使う。

「アプリケーションサービス」は、ユースケースを実現する「オブジェクト」。
(「サービス」も「オブジェクト」という捉え方、これまで出来ていなかった。)

アプリケーションサービスの処理結果を、ドメインオブジェクトをそのまま戻り値とするかは重要な分岐点。
ドメインオブジェクトを戻り値とした場合、アプリケーションサービスを利用する側 = クライアントがドメインオブジェクトを操作できてしまうことになる。

ドメインオブジェクトを外部に向けて公開する選択肢は処理自体を単純なものにしますが、その代償として多くの危険性を内包してします。

「6.2.3 ユーザー情報取得処理を作成する」

ドメインオブジェクトを直接公開しない方針がお勧め。
非公開とする場合はDTOにデータを移し替えて公開する。

更新処理においてある特定の項目だけ更新したいといったケースに対応する場合、引数にデータを渡すか渡さないかで挙動を制御できるようにする。
この戦略を取る場合、情報が追加されるたびにアプリケーションサービスのメソッドのシグネチャが変更されることになる。
それを避ける方法としてコマンドオブジェクトを用いる戦略がある。

感想

サービスというとこれまでアプリケーションサービスの方しか認識できていなかったので、
新たにドメインサービスという概念を得られたのは良かった。

また、クライアント側でもそのままドメインオブジェクト(値オブジェクトやエンティティ)のまま扱えたほうが良いと思いこんでいたので、そうではないという方針が存在する事も学べた。

第12章 ドメインのルールを守る「集約」

オブジェクトは複数のオブジェクトがまとめられ、一つの意味を持ったオブジェクトが構築される。 オブジェクトのグループには維持されるべき不変条件が存在する。

「デメテルの法則」はオブジェクト同士のメソッド呼び出しに秩序をもたらすガイドライン。
「デメテルの法則」ではメソッドを呼び出すオブジェクトは次の4つに限定される。

集約の内部データを外部から隠蔽するためのアプローチとして「通知オブジェクト」を使う方法がある。

第13章 複雑な条件を表現する「仕様」

「仕様」はあるオブジェクトがある評価基準に達しているかを判定するオブジェクト。
(「仕様」を「オブジェクト」として捉える = 「コト」をオブジェクトとして捉えるのは、オブジェクト指向を理解する上でかなり重要なポイントと感じる)

仕様はれっきとしたドメインオブジェクトであり、その内部でリポジトリを使用することを避ける考えもある。
その場合は「ファーストクラスコレクション」を利用することが選択肢に挙げられる。

第14章 アーキテクチャ

ドメイン駆動設計と同時に語られることが多いアーキテクチャ

ヘキサゴナルアーキテクチャとクリーンアーキテクチャはコンセプトは同じ。
クリーンアーキテクチャはコンセプトを実現する実装方法が明示されている。

第15章 ドメイン駆動設計のとびらを開こう

開発者はドメインエキスパートと協力して、ドメインにおいて有益な概念を作り上げなくてはならない。
開発者にはドメインエキスパートとの対話を手引し、システムにって役立つ概念や知識を引き出す使命がある。

  • ユビキタス言語
    • プロジェクト内で認識の齟齬や翻訳にコストをかけないようにするための共通言語
    • ドメインエキスパートの言葉をそのまま扱うことではない
    • 開発者とドメインエキスパート間で双方向に改良し合うことで、開発者はドメインの理解を、ドメインエキスパートは開発者が欲する知識がどういったものかの感覚を養っていく
    • ユビキタス言語はコードの表現として使われる
  • 境界付けられたコンテキスト
    • ドメインの国境のようなもの
    • 変化に対する摩擦を防ぐためには、モデルに対する捉え方が異なる箇所でシステムを分割する
    • 例えば「ユーザー」というオブジェクトに、「ドメインにおけるユーザー」と認証等の機能に用いる「システムとしてのユーザー」が必要となるケース
    • 別のコンテキストに別のモデルとして定義すればよい
    • 出来上がったそれぞれの領域ごとに言語の統一を目指す → 領域を分けること = 境界を分ける
    • コンテキスト同士の関係を定義し、ドメイン全体を俯瞰できるようなものとしてコンテキストマップを作る必要がある

Appendix ソリューション構成

感想

レイヤードアーキテクチャの構成として、

  • プレゼンテーション
  • アプリケーション
  • ドメイン
  • インフラストラクチャ

といった形に構成するのは把握していて、ドメイン(Domain)の中は「Model」「Services」と区切っていくのはイメージしていたが、
そこから「Circles」「Users」といったよう意味の単位で分割し、その単位内にオブジェクトとリポジトリやファクトリを同居させるという方針は目から鱗だった。
常にそれが正しいとは限らないが、パッケージを分ける際はリポジトリやファクトリといった属性ではなく、意味的なまとまりを意識すると良い。 書籍の例ではアプリケーション、インフラストラクチャも意味の単位で分割している。

mybatisで値オブジェクト(Value Object)を扱う場合のポイント

前置き

現場で mybatis を使い始めたのですが、値オブジェクト(Value Object)とマッピングさせる際に少しハマったので整理しました。

環境

  • Spirng Boot
  • mybatis
  • h2 DataBase

SELECT の結果をオブジェクト内の Value Object にマッピングさせる

以下のようなUserNameという Value Object クラスがあったとします。

package com.example.demo.domain.model;

public class UserName {
    private final String value;

    public UserName(String value) {
        this.value = value;
    }

    public String getValue() {
        return this.value;
    }
}

UserクラスがUserNameを保持します。
他にUserNameとRegisterDateというクラスも保持しています。

package com.example.demo.domain.model;

import lombok.Data;

@Data
public class User {
    private UserId userId;
    private UserName userName;
    private RegisterDate registerDate;
}

schema.sqlにDB定義を書きます。

CREATE TABLE users (
  id int NOT NULL
  , user_name VARCHAR(50)  
  , register_date DATE
);

テスト用のデータ挿入用のdata.sqlです。

INSERT INTO users VALUES (1, 'Nocchi', '2020-02-01');
INSERT INTO users VALUES (2, 'Kashiyuka', '2020-02-02');
INSERT INTO users VALUES (3, 'A-Chan', '2020-02-03');

UserRepositoryにIDからUserを取得するためのメソッドfindByIdを定義します。

package com.example.demo.domain.repository;

import com.example.demo.domain.model.User;
import com.example.demo.domain.model.UserId;

import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.springframework.stereotype.Repository;

@Mapper
@Repository
public interface UserRepository {
    User findById(@Param("userId") UserId userId);
}

Mapper は以下のような内容になります。

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
  PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
  "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.demo.domain.repository.UserRepository">
  <select id="findById" resultMap="UserMap" parameterType="map">
    select id, user_name, register_date from users where id = #{userId.value}
  </select>

  <resultMap id="UserMap" type="com.example.demo.domain.model.User">
      <association property="userId" javaType="com.example.demo.domain.model.UserId">
        <constructor>
          <arg name="value" column="id"/>
        </constructor>
      </association>
      <association property="userName" javaType="com.example.demo.domain.model.UserName">
        <constructor>
          <arg name="value" column="user_name"/>
        </constructor>
      </association>
      <association property="registerDate" javaType="com.example.demo.domain.model.RegisterDate">
        <constructor>
          <arg name="value" column="register_date"/>
        </constructor>
      </association>
  </resultMap>

</mapper>

ポイント

  • Value Object を selectの Parameter に使用したい場合@Paramアノテーションをつける
  • @Paramを使用する場合、selectのオプションにparameterType="map"を付与する

上記を行わないと、where id = #{userId.value} のように Value Object の値をselect内で使用できませんでした。

  • SELECT結果のオブジェクト内の Value Object インスタンスを生成するには、associationを使用する
  • Value Object の コンストラクタに値をマップさせるためにconstructorを使用する
  • argのオプションnameで、引数名を指定する

以下の部分です。

<association property="userId" javaType="com.example.demo.domain.model.UserId">
        <constructor>
          <arg name="value" column="id"/>
        </constructor>
</association>

結果

テストコードを書いてブレークさせた結果は以下のように。
Value Object のインスタンスも生成されて、値もマップされています。

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

コード

GitHub に公開しました。

github.com

「Visual Studio Code for Java」で Spring Boot の開発環境を整える【インストール編】

前置き

今年は現場で Spring Boot の開発案件に携わることになりそうなので、勉強中です。
しかし、いかんせん Eclipse が苦手なのでなんとかならないかな・・・というところに、
Visual Studio Code で Java の開発環境向けのインストーラーが公開されていることを知りました。

code.visualstudio.com

インストールしてみて、使えるようであれば現場でも薦めて見ようかなと思います。

手順

※環境はWindows 10 (※ Amazon WorkSpaces 上の環境なのでやや特殊な環境かもです)
上記のページの「Download Visual Studio Code Java Pack Installer」からインストーラーをDLします。

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

手順通りに進めていきます。

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

上記の画面のあとでエラーが発生。
どうやらJDKのインストールでエラーが発生したようです。

[18:33:06] Download JDK
[18:33:38] Download JDK ... [Completed]
[18:33:38] Install JDK
[18:33:40] Error occurred
[18:33:40] Error: Command failed with exit code 1625: C:\Windows\System32\msiexec.exe /i "D:\Users\HOGE\AppData\Local\Temp\1\1WBni7p1RXz2l2Ly16XoZJ5Ip8B\resources\binaries\jdk\11\x64\OpenJDK11U-jdk_x64_windows_hotspot_11.0.6_10.msi" MSIINSTALLPERUSER=1 INSTALLDIR="D:\Users\HOGE\AppData\Local\Programs\AdoptOpenJDK" ADDLOCAL=FeatureJavaHome,FeatureEnvironment,FeatureJarFileRunWith /passive

おそらく管理者権限系のエラーかなと思い、インストーラーを右クリック「管理者として実行」したところ・・・

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

良かった!インストーラー出来ました。
拡張機能に、Java関連の機能が一式インストールされた状態になっています。

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

Spirng Boot でWebアプリケーションを開発したい場合は、以下の拡張機能もインストールしておきます。

  • Spring Boot Tools
  • Spring Initializr Java Support
  • Spring Boot Dashboard

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

Spring Initializr をインストール後、コマンドパレットから Initializr を呼び出せるようになります。

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

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

エクスプローラーに Spring-Boot Dashboard が表示されるようになります。
プロジェクトを右クリック→Start すればもうアプリケーションが起動します。

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

ソースコード上の任意の行をクリックして、ブレークポイントを設定し、debug でアプリケーションを起動すればブレークできます。

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

eclipse同様、パッケージのエラーも表示して自動修正してくれます。

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

もちろん IntelliSense も。

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

感想

以上、インストールとさわりの部分だけですが、やはりずっと .NET を Visual Studio で開発してきた人間には elipse より VSCode の方がしっくりくる感じがします。
今後は Java + Spring Boot 関連の記事もアップしていきたいと思います。

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

前置き

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

rikupapa-shima.hatenablog.com

rikupapa-shima.hatenablog.com

rikupapa-shima.hatenablog.com

練習課題

github.com

今回対応する要件

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

季節の区分

  • 通常期(regular)
  • 閑散期(off-peak)
  • 繁忙期(peak)

の三種類

12月から1月の繁忙期と閑散期

  • 繁忙期: 12月25日〜1月10日(年末年始)
  • 閑散期: 1月16日〜30日

料金の変動

通常期の指定席特急券に対して、

  • 閑散期は -200円
  • 繁忙期には +200円

自由席特急券

季節によって変動しない。 通常期の指定席特急券より530円を引いた金額で年間固定。

実装

まずは変動費(-200円、+200円)を表現する値オブジェクトを実装します。

public class ReservedSeatSeasonVariable
    {
        public readonly int value;

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

季節(Season)をインターフェイスで実装します。

public interface Season
    {
        int variableAmount();
    }

続いて、Seasonインターフェイスを実装した季節ごとクラスを作成します。

  • 通常期(regular)
  • 閑散期(off-peak)
  • 繁忙期(peak)

季節の期間は毎年変動すると見越して、DBで定義された値から取得する形が良いかもしれません。
今回は取り急ぎ定数的に実装します。

public class PeakSeason : Season
    {
        public readonly ReservedSeatSeasonVariable reservedSeatSeasonVariable = 
            new ReservedSeatSeasonVariable(200);

        public int variableAmount()
        {
            return reservedSeatSeasonVariable.value;
        }

        public static bool during(BoardingDate boardingDate)
        {
            if (boardingDate.value.Month == 12 && boardingDate.value.Day >= 25) { return true; };
            return boardingDate.value.Month == 1 && boardingDate.value.Day <= 10;
        }

    }
public class OffPeakSeason : Season
    {
        public readonly ReservedSeatSeasonVariable reservedSeatSeasonVariable = 
            new ReservedSeatSeasonVariable(-200);

        public int variableAmount()
        {
            return reservedSeatSeasonVariable.value;
        }

        public static bool during(BoardingDate boardingDate)
        {
            if (boardingDate.value.Month == 1 && boardingDate.value.Day >= 16) { return true; };
            return boardingDate.value.Month == 1 && boardingDate.value.Day <= 30;
        }

    }
public class ReqularSeason : Season
    {
        public int variableAmount()
        {
            return 0;
        }
    }

そして、出発日(BoardingDate)から季節を判定するクラスSeasonTypeです。

public class SeasonType
    {
        public static Season valueOf(BoardingDate boardingDate)
        {
            switch (boardingDate)
            {
                case BoardingDate boarding when PeakSeason.during(boarding):
                    return new PeakSeason();
                case BoardingDate boarding when OffPeakSeason.during(boarding):
                    return new OffPeakSeason();
                default:
                    return new ReqularSeason();
            }
        }
    }

季節ごとの変動は指定席の特急料金だけに対してなので、実装済み(第1回)の指定席(ReservedSeat)クラスに渡せばよいだけです。

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

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

以上で実装完了です。
サービスクラスの差分は以下のようになりました。

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

振り返り

今回の練習課題は業務アプリケーションの開発の実戦に近く、非常に有意義なものであったと思います。
数人でモブプロやライブコーディングしながらやったら面白そうです。
ベテラン+初学者のグループでやったりすると、いろいろな気付きが出てきたりしそうです。

第2回まではクラスの表現に結構悩んだところもあったのですが、第3回、4回では慣れてきたのかスムーズにクラスの表現が出来たと思いました。
間を開けて実装してしまっているので、表現方法に若干統一感がないのと、クラスをもっと集約しても良いのかな、とも思います。
この辺りは今後リファクタリングにも取り組んでみたいです。
あと、リポジトリにはView側のプロジェクトも作っているのにそちらは力尽きてしまっているので・・・

今回の練習結果のリポジトリ

github.com

参考

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

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

【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)による特急指定席料金の変動

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

参考書籍

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

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

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

前置き

前回からの続きです。
ようやく着手できました・・・
rikupapa-shima.hatenablog.com

嬉しかったこと

『現場で役立つシステム設計の原則 〜変更を楽で安全にするオブジェクト指向の実践技法』の著者の増田さんから、リアクションいただけました。

今回実装する要件

往復割引 (round trip discount)

片道の営業キロが601km以上あれば、「ゆき」と「かえり」の運賃がそれぞれ1割引になります。

東京からの営業キロ

  • 新大阪 553km
  • 姫路 644km

例:東京-姫路の往復料金

片道の割引額 10,010円 × 90% = 9,009円 → 10円未満の端数切り捨て 9,000円

割引後の往復運賃 : 9,000円 × 2 = 18,000円

2,020円の割引になる

まずは「営業キロ(RailWayDistance)」を値オブジェクトとして実装。
「601km以上の場合に割引になる」要件をisDiscountDistance関数で表現しました。

public class RailWayDistance
    {
        public readonly int value;

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

        public bool isDiscountDistance()
        {
            return this.value >= 601;
        }

    }

往復割引の要件から、運賃(BasicFare)は割引が存在することが判明しました。
割引後の運賃を表現するRoundTripDiscountedOnewayBasicFareクラスを実装。
元になる運賃(BasicFare)と、割引のロジックを実装したクラスです。

public class RoundTripDiscountedOnewayBasicFare : BasicFareWithTripType
    {
        private readonly BasicFare basicFare;
        private const double discountRate = 0.9;
        private const double roundDownNumber = 0.1;

        public RoundTripDiscountedOnewayBasicFare(BasicFare basicFare)
        {
            this.basicFare = basicFare;
        }

        public int value =>  (int) Math.Floor((this.basicFare.value* discountRate) * roundDownNumber) * 10;

    }

割引が適用されない運賃は「片道運賃(OnewayBasicFare)」としました。
BasicFareを内包するだけのクラスです。

public class OnewayBasicFare : BasicFareWithTripType
    {
         public readonly BasicFare basicFare;

        public OnewayBasicFare(BasicFare basicFare)
        {
            this.basicFare = basicFare;
        }

        public int value => basicFare.value;
    }

実装クラスの説明が先になりましたが、運賃と特急料金から料金を計算するFareクラスから両者を扱うために、BasicFareWithTripTypeインターフェイスを定義しました。

public interface BasicFareWithTripType
    {
        int value { get; }
    }

そして、文字列「OnewayTrip(片道)/ RoundTrip(往復)」の判定と、往復だった場合は営業キロが割引適用かを判定し、割引適用後の運賃クラスを生成する「運賃種別(BasicFareType)」クラスを実装しました。

public class BasicFareType
    {
        private readonly BasicFare basicFare;
        private readonly RailWayDistance railWayDistance;

        public BasicFareType(BasicFare basicFare, RailWayDistance railWayDistance)
        {
            this.basicFare = basicFare;
            this.railWayDistance = railWayDistance;
        }

        public BasicFareWithTripType Oneway()
        {
            return new OnewayBasicFare(basicFare);
        }

        public BasicFareWithTripType RoundTrip()
        {
            return createBasicFareForRoundTrip();
        }

        private BasicFareWithTripType createBasicFareForRoundTrip()
        {
            if (this.railWayDistance.isDiscountDistance())
            {
                return new RoundTripDiscountedOnewayBasicFare(this.basicFare);
            }
            return new OnewayBasicFare(this.basicFare);
        }

        public BasicFareWithTripType valueOf(string name)
        {
            var method = typeof(BasicFareType).GetMethod(name);
            return method.Invoke(this, null) as BasicFareWithTripType;
        }
    }

ここまで往復時の運賃割引のロジックは完成ですが、さらに「片道料金」「往復料金」を表現するクラスを実装します。
型はTripとし、「片道OnewayTrip」「往復RoundTrip」クラスとして表現しました。
「往復」は「行き(departingFare)」と「帰り(returningFare)」の金額を合計した額を返します。
(※Fareの参照渡しになってしまっているのが若干モヤモヤしますが)

public interface Trip
    {
        public int value();

    }
public class OnewayTrip : Trip
    {
        private Fare fare;

        public OnewayTrip(Fare fare)
        {
            this.fare = fare;
        }

        public int value() => fare.value();
    }
public class RoundTrip : Trip
    {
        private Fare departingFare;
        private Fare returningFare;

        public RoundTrip(Fare fare)
        {
            this.departingFare = fare;
            this.returningFare = fare;
        }

        public int value() => this.departingFare.value() + this.returningFare.value();

    }

割引運賃と同じく、区分オブジェクトでTripTypeクラスを表現しました。

public class TripType
    {
        private readonly Fare fare;

        public TripType(Fare fare)
        {
            this.fare = fare;
        }

        public OnewayTrip Oneway()
        {
            return new OnewayTrip(fare);
        }

        public RoundTrip RoundTrip()
        {
            return new RoundTrip(fare);
        }

        public Trip valueOf(string name)
        {
            var method = typeof(TripType).GetMethod(name);
            return method.Invoke(this, null) as Trip;
        }

    }

ここまで実装し、最終的にServiceクラスでの前回からの差分は以下のようになりました。
運賃BasicFareクラスを参照していたFareインターフェイスおよびFareの実装クラスは、BasicFareWithTripTypeを参照するように修正が必要となりました。

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

今のところ、ほぼ区分オブジェクトだけで要件を実現できているような形です。
以下のスクショはデバッグでブレークしたところですが、このように保持しているクラスの型で要件をそのまま表現できているかと思います。

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

見ていて思ったのですが、value(金額)はyen(円)という値オブジェクトで表現してもよいかもですね。

前回からの差分コミット

github.com

感想

思ったより時間かかっています・・・第5回くらいまで必要になりそう・・・
牛歩で最後まで頑張りたいと思います。

続きは以下

rikupapa-shima.hatenablog.com