Help us understand the problem. What is going on with this article?

設計要件をギッチギチに詰めたValueObjectで低凝集クラスを爆殺する

More than 1 year has passed since last update.

概要

関連のあるロジックが膨大なソースコードの中のあちこちに書き殴られ、散在し、追うのが大変な低凝集クラス。別名「スパゲッティコード」とも呼ばれ、無関係なロジックと絡み合い、変更を難しくしているアイツ。
そんな低凝集クラスの退治に有効な設計手法を紹介致します。

本記事の内容は ValueObject パターンと 完全コンストラクタ パターンを用いた品質向上の設計手法です。低凝集に徹底対抗するため、かなりギッチギチに設計要件を詰め込んでいます。

本設計手法による効果

  • 生産性向上
  • 修正漏れ低減
  • クローンコード低減
  • 未初期化状態の防止
  • 不正値の防止
  • 異なる概念の値混入抑止
  • インスタンス生成の安全性向上
  • リスト操作に関する複雑度低減
  • 理解容易性向上
  • コードのトレーサビリティ向上

よくあるダメな例

まずはValueObjectを用いていない、架空のコードを例に説明致します。
ここではなるべく分かりやすいように、今ホットな消費税を例にします。

税込み金額と消費税率を単に格納するだけの契約金額クラスがあったとします。
(※以下サンプルコードはC#にて記述)

ContractAmount.cs
/// <summary>契約金額</summary>
public class ContractAmount
{
    public int AmountIncludingTax;
    public decimal SalesTaxRate;
}

当然データの入れ物(以後データクラスと呼称)だけでなく、税込み金額を計算するロジックが必要です。ここであまり設計を考えないと、この手の演算ロジックはデータクラスとは別のクラスに実装されることが多いです。以下のようにControllerに実装されることが多いのではないでしょうか。

ContractController.cs
/// <summary>契約コントローラー</summary>
public class ContractController
{
    private ContractAmount _contractAmount;

    /// <summary>税込金額を計算する。</summary>
    /// <param name="amountExcludingTax">税別金額。</param>
    /// <param name="salesTaxRate">消費税率。</param>
    /// <returns>税込金額。</returns>
    public int CalculateAmountIncludingTax(int amountExcludingTax, decimal salesTaxRate)
    {
        return (int)(amountExcludingTax * (1.0m + salesTaxRate));
    }

    /// <summary>契約締結する。</summary>
    public void Conclude()
    {
        // 省略
        int amountIncludingTax = CalculateAmountIncludingTax(amountExcludingTax, salesTaxRate);
        _contractAmount = new ContractAmount();
        _contractAmount.AmountIncludingTax = amountIncludingTax;
        _contractAmount.SalesTaxRate = salesTaxRate;
        // 省略
    }
}

クラス図にすると以下の関係となります。

tax_uml_01.png

ごく小規模なアプリであれば、この設計は特に問題にはならないでしょう。しかし大規模になるにつれ、この設計は様々な問題をはらむようになってきます。
どんな問題が起こるのか順番に見ていきましょう。

課題

課題1:低凝集

アプリが大きくなってきても、相変わらずデータを保持する場所(データクラス)とデータを操作するロジックを別々箇所に実装しているとどうなるでしょうか。
例えば下記クラス図のように、消費税関連の演算メソッドが様々なContollerに定義されている場合はどうでしょう。

tax_uml_02.png

消費税関連のロジックが様々な箇所に分散してしまっていますね。
このように関連するデータやロジック同士が分散してしまっているのを 低凝集 と言います。低凝集なコードではどんな課題が発生するのか以下に列挙します。

課題1-1:生産性低下

上図はまだなんとか関連を把握できますが、これが何千何万行のソースコードに分散すると、全て探し出すだけで一苦労で、時間と精神力をいたずらに消耗することになります。

課題1-2:修正漏れ

また、関連コードを全て探し出せるとは限りません。漏れが生じる可能性もあります。仮に洗い出しに漏れが生じると、消費税関連のバグが発生します。

課題1-3:コードクローン(コピペコード)

あらゆる箇所に分散していると把握が困難になります。例えば既に実装済みの機能があるのに、別の開発メンバに「この機能は未実装だ」と誤解される恐れがあり、同じようなロジックが至るところに複数実装されることになります。意図せずコードクローンが量産されることになります。

課題2:不正状態

課題1で挙げた低凝集に付随して、データクラスContractAmountは以下に示す不正状態へ陥る可能性があります。

課題2-1:未初期化状態(生焼けオブジェクト)

ContractAmountクラスは、newされた直後はインスタンス変数の初期化が済んでいません。ContractControllerConclude()が実行されるまでは不完全な状態、即ち不正状態です。

Conclude()がコールされる前にContractAmountインスタンスが何かに利用されるとバグになります。
このように初期化が済んでおらず、使い物になってないオブジェクト、または未初期化状態を発生しうるオブジェクトを、アンチパターン 生焼けオブジェクト と言います。

課題2-2:不正値の混入

その他、消費税率に負数を代入するなど、不正値を与えることも容易に出来てしまいます。

var contractAmount = new ContractAmount();
contractAmount.SalesTaxRate = -0.10m;

課題まとめ

以上、低凝集であることにより以下の課題が発生します。

  • 生産性低下
  • 修正漏れ
  • コードクローン
  • 未初期化状態(生焼けオブジェクト)
  • 不正値混入

単にデータを保持するだけの、なんら処理ロジックを持たない データクラス は低凝集です。上記種々の問題を誘発させる アンチパターン「ドメインモデル貧血症」 であり、 なのです(※CQRSパターンのQuery層でDTOとして利用するのは別の話)。
こうした問題を発生させないための設計方法が、これから紹介する ValueObject 、及び 完全コンストラクタ です。

ValueObject

ValueObjectとはデータのラッパーであり、正確には

  • (原則的に)1個のインスタンス変数
  • インスタンス変数を初期化するコンストラクタ
  • インスタンス変数を正常に操作することを保証したメソッド

から構成されるクラスです。

tax_uml_03.png

値を単なる変数として実装するのではなく、クラスというひとつの型の単位として扱う、という考えに基づきます。
では税抜き金額をValueObjectとして扱うことを例に考えてみます。

/// <summary>税抜き金額</summary>
public class AmountExcludingTax
{
    private int _amount;

    public AmountExcludingTax(int amount)
    {
        // 初期値はコンストラクタで与える
        _amount = amount;
    }
}

// 税抜き金額1000円
var amountExcludingTax = new AmountExcludingTax(1000);

まずは基本形。ここから全てがスタートします。
税抜き金額の値をコンストラクタで与え、インスタンス変数に格納します。
しかしここまではアンチパターンである データクラス とあまり変わらないように見えますね。
事実、以下のように不正値を混入可能です。

// 負の金額、即ち不正値を代入できてしまう。
var amountExcludingTax = new AmountExcludingTax(-100);

どうすれば良いでしょうか。これを解決するのが 完全コンストラクタ です。

完全コンストラクタ

オブジェクトを「 newした時点で正しく利用可能な、即ち完全体 」となるよう適切な初期化ロジックをコンストラクタに実装する方法です。
税抜き金額のあるべき姿、要件を考えてみましょう。

  • 0以上の整数であること

となりますね。この要件を満たすもののみインスタンス変数に格納するようフィルタリングすれば良いのです。要件未達の値は例外を投げるよう実装します。

/// <summary>税抜き金額</summary>
public class AmountExcludingTax
{
    private int _amount;

    public AmountExcludingTax(int amount)
    {
        // コンストラクタで不正値を除外
        if (!IsValid(amount))
        {
            throw new ArgumentOutOfRangeException();
        }

        _amount = amount;
    }

    // 税抜き金額のバリデーションを用意
    private static bool IsValid(int amount)
    {
        // 正常値の要件を記述
        return 0 <= amount;
    }
}

これで正常値のみインスタンス変数に格納できるようになりました。

生まれながらにして完全体

完全コンストラクタは更に利点があります。不正値が渡されるとコンストラクタで例外をスローするので、 不正値を持ったAmountExcludingTaxのインスタンスが存在できなくなります 。常に安全で正常なインスタンスのみが存在し、利用できるようになります。
完全コンストラクタで設計したクラスのインスタンスは、 生まれながらにして完全体 なのです。誕生した瞬間 セル完全体 なのです。

一方不正値が混入すると例外をスローしAmountExcludingTaxのインスタンスが存在できなくなくなるので、 不完全な セル第1形態や第2形態は 誕生前に即死 することになります。これはスゴイ!!

ValueObjectと完全コンストラクタを利用せずに変数に対してバリデーションをかけることも当然可能ですが、ただの変数の場合、後から不正値を再代入できてしまう点において劣ります。

setterを用意しない

再代入を容易に許すと折角完全コンストラクタで確保した安全があっさり崩れてしまいます。従ってsetterを用意してはいけません。
getterのみ用意するようにしましょう(※より良い設計ではgetterすらない方が良い、後述)。

/// <summary>税抜き金額</summary>
public class AmountExcludingTax
{
    private int _amount;

    // getterのみ用意する(※より良い設計ではgetterすら用意しない)
    public int Value { get { return _amount; } }

immutable(不変)にする

内部のインスタンス変数が変更可能な状態にあると、並列処理などにおいていつの間にか値がすり替わっているなど意図しない副作用が発生する可能性があります。より安全に倒すため、ValueObjectは不変にしましょう。インスタンス変数にreadonly(Javaでいうfinal修飾子)を付与します。これでコンストラクタで初期化以後、変更できなくなります。

/// <summary>税抜き金額</summary>
public class AmountExcludingTax
{
    // これでコンストラクタ以後書き換えられなくなり、不変となる。
    private readonly int _amount;

【!超重要!】許可された操作のみメソッド化する

さて、不変にしたところで値を変更したい場合どうすればいいでしょう。
例えば税抜き金額同士で加算したいケースがよくあるでしょう。その場合、加算結果を格納した新たなValueObjectのインスタンスを生成するよう設計します。ここでは加算メソッドAddを用意してみました。

/// <summary>税抜き金額</summary>
public class AmountExcludingTax
{
    // 省略

    public AmountExcludingTax Add(AmountExcludingTax amountExcludingTax)
    {
        // 税抜き金額同士を加算する。
        // 引数には同じAmountExcludingTaxだけ渡せるよう設計。
        return new AmountExcludingTax(_amount + amountExcludingTax._amount);
    }

    // 省略
}

// 使用例
var amount1 = new AmountExcludingTax(100);
var amount2 = new AmountExcludingTax(10);
var total = amount1.Add(amount2);
Console.WriteLine(total.Value);  // 110が出力される

これにより元のインスタンスの不変を維持したまま変更値を用意可能です。

但し要注意です。
金額同士の加算は用途上考えられますが、乗算はないはずです。税抜き金額がValueObjectではなく単なるローカル変数である場合を考えてみて下さい。乗算どころかどんな計算でも制約なく出来てしまいます
金額ならば加算や減算だけなど、業務概念的に許可された操作だけをメソッドとして公開することで、不正な演算を許さない、頑強な設計となります。
この設計思想からするとgetterで値取得可能な構造は、取得先の外部で勝手な演算が出来てしまうので、可能な限りgetterを実装しないのが望ましい姿です。但し、getterがないとUI表示や永続化の際値が取れなくなるので、バランス取りが難しいところです。

全てのクラスに備わる「自己防衛責務」

「詳細な初期化処理や事前準備をしないと使い物にならない」……あなたはこんなクラスやメソッドを使いたいと思いますか?
そもそもの話として、ソフトウェアはメソッド、クラス、モジュール、どの粒度でも、それ 自体が単体で バグがなく いつでも安全に 利用できる品質が求められます。
クラスも各々が他に依存することなく単体で安全に利用できるよう設計されている必要があります。他のクラスに初期化して貰ったり、バリデートして貰っているようなクラスは未熟なクラスです。 自分の身は自分で守らせる自己防衛責務全てクラスが備える 、という考え方がソフトウェア品質を考える上で重要です。完全コンストラクタはその方針を設計に落としたもののひとつです。
構成部品であるクラスひとつひとつが品質的に完結していることにより、ソフトウェア全体の品質が向上するのです。

課題は解決されたか?

ValueObjectと完全コンストラクタの基本は以上になります。
さて、この記事の冒頭で低凝集による種々の課題を列挙しましたが、解決されたのでしょうか?
今一度AmountExcludingTaxのクラス図とソースコードを見てみましょう。

tax_uml_04.png

/// <summary>税抜き金額</summary>
public class AmountExcludingTax
{
    private readonly int _amount;

    public int Value { get { return _amount; } }

    /// <summary>コンストラクタ</summary>
    /// <param name="amount">税抜き金額</param>
    public AmountExcludingTax(int amount)
    {
        if (!IsValid(amount))
        {
            throw new ArgumentOutOfRangeException();
        }

        _amount = amount;
    }

    /// <summary>税抜き金額を加算する</summary>
    /// <param name="amountExcludingTax">税抜き金額</param>
    /// <returns>税抜き金額</returns>
    public AmountExcludingTax Add(AmountExcludingTax amountExcludingTax)
    {
        return new AmountExcludingTax(_amount + amountExcludingTax._amount);
    }

    /// <summary>有効な税抜き金額であるかを返す</summary>
    /// <param name="amount">税抜き金額</param>
    /// <returns>有効な場合true</returns>
    private static bool IsValid(int amount)
    {
        return 0 <= amount;
    }
}

税抜き金額の単なるデータだけでなく、許容値や演算メソッド(Add)など、関連するロジックが凝集しているのがお分かり頂けますでしょうか。このようにデータとそれを処理するロジックが分散せず、一箇所に集められカプセル化されているのを 高凝集 と呼びます。
では低凝集で生じる課題がどう解決されたか説明します。

課題 解決
生産性低下 税抜き金額に関する定義はAmountExcludingTaxのみを見れば良い。他を探し回る労苦から解放され、生産性が向上する。
修正漏れ 税抜き金額に関して仕様変更が発生した場合、原則的にAmountExcludingTaxのみを修正すれば良い。修正漏れが生じにくい。
コードクローン 「税抜き金額に関する仕様はAmountExcludingTax内にのみ実装されている」「税抜き金額はAmountExcludingTaxインスタンスとして扱う」と約束付けることでクローンが発生しにくくなる。
未初期化状態 完全コンストラクタによりnewした時点で正常に初期化。未初期化な状態がそもそも存在しなくなる。
不正値混入 完全コンストラクタのバリデーションにより不正値を持ったインスタンスが存在できなくなる。

更に発展応用させる

以上がValueObjectの基本となります。上記だけでもかなりの設計要件が登場しましたが、更に様々なパターンに対応し応用できるよう発展させていきます。

以下では、 税込み金額 の計算を題材に、ValueObjectパターンに関し更に深堀りしていきます。ぶっちゃけ税込み金額は「税抜き金額 x 消費税率」で算出できてしまうのですが、消費税率の適用ルール周りには様々な要件や業務知識が存在するので、そうした概念を踏まえた上で堅牢な設計をしていきます。

消費税率をValueObject化してみる

まずは消費税率をValueObjectとして設計してみます。
とりあえず税抜き金額AmountExcludingTaxと同様に、初期値をコンストラクタで与える形で作ってみます。

/// <summary>消費税率</summary>
public class SalesTaxRate
{
    private readonly decimal _rate;

    public SalesTaxRate(decimal rate)
    {
        if (!IsValid(rate))
        {
            throw new ArgumentOutOfRangeException();
        }

        _rate = rate;
    }

    private static bool IsValid(decimal rate)
    {
        return 0 <= rate;
    }
}

一応それらしいものができました。…ですがこれで良いのでしょうか?

再燃する課題…低凝集

消費税率の値は、商品を購入した日時によって決まります。また、その値も固定です(例えば2019/10/01より10%)。ですが、この設計ではコンストラクタから任意の税率を代入できてしまいますし、そもそも税率を決定するロジックが存在しません。そのロジックはどこに実装されるのでしょうか。前の例のようにControllerクラスに実装されるのでしょうか。するとまた低凝集な設計になり、生産性低下など様々な問題を生み出してしまいます。

凝集しよう - 完全コンストラクタのおさらい

振り返ってみましょう。「 newした段階で使い物になるようにコンストラクタ内で初期化処理を完結する 」というのが完全コンストラクタの主旨です。つまり、 日付により消費税率を決めるロジックを消費税率クラスのコンストラクタ内に定義する のが自然、と考えられます。

消費税率周りのドメイン分析

ではどのように消費税率の値が決められるのか、一度消費税率に関する業務知識を確認してみましょう。

  • 消費税は施行日がある。法によって定められた 消費税施行日 以降に 当該消費税率 が適用される。
  • 売買契約が締結した時点の消費税率を適用する。
  • つまり、 契約日 が所定の 消費税施行日 以降である場合、 当該税率 が適用される。
  • (※経過措置など難しい概念もあるが今回は割愛)

ここでいくつか重要そうな概念が洗い出されました。

  • 消費税施行日(固定)
  • 消費税率(固定)
  • 契約日(任意)

つまり契約日が決まってしまえば、あとは消費税施行日と照らし合わせて消費税率が決まる仕組みですね。「消費税率は契約日により一意に決まる」、ということが分かりました。
つまり、

  • コンストラクタで契約日を受け取る。
  • コンストラクタ内で契約日と消費税施行日を比較して消費税率を決定する。
  • 得られた消費税率の値をインスタンス変数へ格納する。

というロジックになれば良さそうです。

/// <summary>消費税率</summary>
public class SalesTaxRate
{
    private readonly decimal _rate;

    public SalesTaxRate(DateTime contractDate)
    {
        // 省略するがここで契約日と消費税施行日とを比較し、消費税率を決定する。
        _rate = // 決定した税率値を代入
    }

税抜き金額AmountExcludingTaxは、金額値をコンストラクタで受け取っていました。それはUIから任意金額値を入力するユースケースから考えても自然です。一方で、消費税率クラスのようにコンストラクタ引数の値が同一概念の値とは限りません。
何に依存してその値が決定するのか、しっかりドメイン分析することが肝要 です。

契約日の扱い

ところで消費税率クラスのコンストラクタ引数となる契約日は、ただのDateTime型で良いのでしょうか?ものによりますが、アプリで扱われる日時は、例えば生年月日、注文日など多種にわたるでしょう。例えば以下のようなコードは許されるのでしょうか。

DateTime contractDate = birthday;
var salesTaxRate = new SalesTaxRate(contractDate);

契約日に誕生日を代入してしまっており、どう見てもバグとなりますね。
ここでも役立つのがValueObjectです。
契約日をValueObjectとして設計し、コンストラクタ引数を契約日の型にすれば、異なる概念の値が混入してしまうのを防ぐことができます。

/// <summary>契約日</summary>
public class ContractDate
{
    private readonly DateTime _date;
    // 省略
}
/// <summary>消費税率</summary>
public class SalesTaxRate
{
    private readonly decimal _rate;

    public SalesTaxRate(ContractDate contractDate)
    {
        // 省略
var birthday = new Birthday(new DateTime(1990, 4, 2));
var salesTaxRate = new SalesTaxRate(birthday);  // 型が異なるのでコンパイルが通らない

あらゆる値をValueObjectとして設計する

ドメイン駆動設計のようにドメイン層を設ける設計であれば、ドメイン層では どんな値もなるべく全てValueObject で扱った方が良いです。そして プリミティブな型ではなくValueObject型 でやり取りし、 全てのValueObjectで異なる型の代入を弾く設計実装 すれば、 異なる概念の値混入に対して非常に強固な作り になります。
ちなみに私の以前の開発現場では、こうした異なる概念の値混入によるバグがたびたび発生していました。一度に多くのパラメータを取り扱うユースケースで特に起こっていました。こうしたつまらないバグにつまづかないよう、こまめにValueObject化することが肝要だと考えます。

ここまでの検討により、インターフェースはだいたい決まりました。
一度クラス図を起こします。

tax_uml_05.png

このクラス構成に基づき、設計実装の細部を詰めていきます。

契約日クラス

契約日クラスを設計していきます。
初期値を決めたいのですが、コンストラクタ引数で初期値となるDateTime型を渡してよいのでしょうか?低凝集の不安がありますね。一度ドメイン分析しましょう。

  • 売買契約が締結した日時である。
  • 永続化対象である。(※本来ドメイン分析で考慮すべきことではない)

まず締結日時ですが、締結したときに任意DateTimeを利用側から与えられるのではなく、契約日クラス側で決めてしまった方が良いですね。

/// <summary>契約日</summary>
public class ContractDate
{
    private readonly DateTime _date;

    public ContractDate()
    {
        _date = DateTime.Now;
    }
}

また、契約日は永続化対象なので、リポジトリから読み出した値を格納できるようにクチを設けておく必要があります。

/// <summary>契約日</summary>
public class ContractDate
{
    private readonly DateTime _date;

    // 契約締結時に呼び出す用
    public ContractDate()
    {
        _date = DateTime.Now;
    }

    // リポジトリからの読み出し用
    public ContractDate(DateTime date)
    {
        _date = date;
    }
}

ところでコンストラクタが複数あります。コメントで説明はしていますが、利用側からはどちらのコンストラクタが何の用途か非常に分かりにくいものなってしまいます。どうすれば良いのでしょうか。

Factoryメソッド+privateコンストラクタ

こういう場合、用途ごとのFactoryメソッドを用意します。各Factoryメソッドには、用途に相応しい命名をします。

/// <summary>契約日</summary>
public class ContractDate
{
    private readonly DateTime _date;

    public DateTime Value { get { return _date; } }

    // 制約を無視した勝手なインスタンス生成を利用側にされないようprivateにする
    private ContractDate(DateTime date)
    {
        _date = date;
    }

    // 契約締結時に呼び出す用
    public static ContractDate Conclude()
    {
        return new ContractDate(DateTime.Now);
    }

    // リポジトリからの読み出し用
    // リポジトリ以外からの生成に利用されないようinternalにする
    internal static ContractDate Reconstruct(DateTime date)
    {
        return new ContractDate(date);
    }
}

そしてコンストラクタはprivateにします。こうすることで契約日ContractDateの 生成方法を用途別に縛る ことができます。 クラス設計者の予想だにしない使われ方をさせないことも、安全性を高める上で重要です。また、リポジトリから読み出した場合に用いられるReconstruct()はinternalにすることでパッケージ内のみアクセス可能になり、アプリ層やView層などからコールされることがなくなります。

消費税率適用ルールの設計

先ほど「消費税率を決定するロジックを消費税率クラスのコンストラクタに定義する」と書きました。しかし「消費税施行日」など様々な概念が絡んでいそうです。単純に実装しただけでは 複雑度が増大し、保守が難しくなりそう なので、小分けにしてボトムアップで考えていくことにします。

以下のようなロジックを元に考えてみます。

  • 消費税施行日とその適用税率をリストで持っておく。
  • 消費税施行日が最新のものから順に契約日と比較する。契約日が消費税施行日以降であれば、その税率を適用する。

消費税クラス

消費税に関しては、施行日と税率がセットになっているので、一緒にしてしまって良さそうです。

/// <summary>消費税</summary>
internal class SalesTax
{
    /// <summary>施行日</summary>
    private readonly DateTime _enforcementDate;

    /// <summary>税率</summary>
    private readonly decimal _rate;

    internal SalesTax(DateTime enforcementDate, decimal rate)
    {
        if (!IsValidRate(rate))
        {
            throw new ArgumentOutOfRangeException();
        }

        _enforcementDate = enforcementDate;
        _rate = rate;
    }

    private static bool IsValidRate(decimal rate)
    {
        return 0m <= rate;
    }
}

名前がカブった??

ちょっと話が外れますが、先ほど消費税率クラスをSalesTaxRateと命名しました。一方、上述の消費税クラス、インスタンス変数に_rateがいます。SalesTax._rateSalesTaxRate、なんだか紛らわしいですね。どうも概念的に微妙に違っていそうです。命名をブラッシュアップしてみましょう。
そもそも消費税率クラスは一体何なのでしょうか。

/// <summary>消費税率</summary>
public class SalesTaxRate
{
    private readonly decimal _rate;

    // コンストラクタで契約日を受け取る
    public SalesTaxRate(ContractDate contractDate)
    {
        // ここで契約日と消費税施行日とを比較し、消費税率を決定する。

契約日により税率を決定しています。つまり完全コンストラクタの設計思想に基づけば、これは「契約日に 適用された税率 」なのです。すると冒頭の消費税率クラスは「 適用された消費税率 」と名付けた方がより適切ですね。SalesTaxRateからAppliedSalesTaxRate命名をブラッシュアップ します。

/// <summary>適用された消費税率</summary>
public class AppliedSalesTaxRate
{
    private readonly decimal _rate;

    // コンストラクタで契約日を受け取る
    public AppliedSalesTaxRate(ContractDate contractDate)
    {
        // ここで契約日と消費税施行日とを比較し、消費税率を決定する。

完全コンストラクタで設計したクラスの名前は「○○された△△」、「〜ed△△」というように、何かによって完成品 となった 命名をするのが望ましいです。

消費税適用ルールクラス

消費税クラスSalesTaxを用いて、消費税適用ルールSalesTaxApplyRuleを設計します。
上述の通り、「消費税の施行日と税率を消費税クラスとしてリストで持っておき、施行日が最新のものから順番に契約日と比較して、契約日が施行日以降であればその税率を適用する」としましょう。
下記コードのようにコンストラクタでリスト生成して、ApplyRule()で適用税率を返します。

/// <summary>消費税適用ルール</summary>
internal class SalesTaxApplyRule
{
    private readonly List<SalesTax> _salesTaxes;

    internal SalesTaxApplyRule()
    {
        _salesTaxes = new List<SalesTax>();
        // 最新の施行日から順に格納すること。
        // (開発者が順番を気にしなくても良いように設計するのがホントは望ましい)
        _salesTaxes.Add(new SalesTax(enforcementDate: new DateTime(2019, 10, 1), rate: 0.10m));
        _salesTaxes.Add(new SalesTax(enforcementDate: new DateTime(2014,  4, 1), rate: 0.08m));
        _salesTaxes.Add(new SalesTax(enforcementDate: new DateTime(1997,  4, 1), rate: 0.05m));
        _salesTaxes.Add(new SalesTax(enforcementDate: new DateTime(1989,  4, 1), rate: 0.03m));
    }

    internal decimal ApplyRule(ContractDate contractDate)
    {
        var corresponded = _salesTaxes.Find(tax => tax.EnforcementDate <= contractDate.Value);
        return corresponded != null ? corresponded.Rate : 0.00m;
    }
}

ちなみにこのSalesTaxApplyRuleの設計はValueObjectパターンの亜種で、リストに対しての業務ロジックをカプセル化する設計パターン「 ファーストクラスコレクション 」といいます。リスト操作は兎角ネスト構造になりがちでコードが 複雑化 しやすいので、是非覚えて下さい。

「適用された消費税率」クラスへ組み込み

ここまでくれば税適用ルールSalesTaxApplyRuleAppliedSalesTaxRateへ組み込むことができますね。

/// <summary>適用された消費税率</summary>
public class AppliedSalesTaxRate
{
    // 税適用ルールは1つあれば良く、複数生成させないようstatic readonlyとする。
    private static readonly SalesTaxApplyRule _salesTaxApplyRule = new SalesTaxApplyRule();
    private readonly decimal _rate;

    public decimal Value { get { return _rate; } }

    public AppliedSalesTaxRate(ContractDate contractDate)
    {
        _rate = _salesTaxApplyRule.ApplyRule(contractDate);
    }
}

税込み金額クラス

最後に税込み金額クラスです。
これもValueObject+完全コンストラクタで設計します。
税抜き金額クラスと適用された消費税率クラスのインスタンスを渡し、税込み金額を計算し格納します。

/// <summary>税込金額</summary>
public class AmountIncludingTax
{
    private readonly int _amount;

    public int Value { get { return _amount; } }

    public AmountIncludingTax(
        AmountExcludingTax amountExcludingTax,
        AppliedSalesTaxRate appliedSalesTaxRate)
    {
        _amount = (int)(amountExcludingTax.Value * (1m + appliedSalesTaxRate.Value));
    }
}

これで目標である税込み金額クラスを設計できました。

おさらい

最終的なクラス図とソースコードです。

tax_uml_06.png

AmountExcludingTax.cs
/// <summary>税抜き金額</summary>
public class AmountExcludingTax
{
    private readonly int _amount;

    public int Value { get { return _amount; } }

    /// <summary>コンストラクタ</summary>
    /// <param name="amount">税抜き金額</param>
    public AmountExcludingTax(int amount)
    {
        if (!IsValid(amount))
        {
            throw new ArgumentOutOfRangeException();
        }

        _amount = amount;
    }

    /// <summary>税抜き金額を加算する</summary>
    /// <param name="amountExcludingTax">税抜き金額</param>
    /// <returns>税抜き金額</returns>
    public AmountExcludingTax Add(AmountExcludingTax amountExcludingTax)
    {
        return new AmountExcludingTax(_amount + amountExcludingTax._amount);
    }

    /// <summary>有効な税抜き金額であるかを返す</summary>
    /// <param name="amount">税抜き金額</param>
    /// <returns>有効な場合true</returns>
    private static bool IsValid(int amount)
    {
        return 0 <= amount;
    }
}
ContractDate.cs
/// <summary>契約日</summary>
public class ContractDate
{
    private readonly DateTime _date;

    public DateTime Value { get { return _date; } }

    /// <summary>コンストラクタ</summary>
    /// <param name="date">契約日</param>
    /// <remarks>制約を無視した勝手なインスタンス生成を利用側にされないようprivateにしている。</remarks>
    private ContractDate(DateTime date)
    {
        _date = date;
    }

    /// <summary>契約締結時に呼び出す。</summary>
    /// <returns>契約日</returns>
    public static ContractDate Conclude()
    {
        return new ContractDate(DateTime.Now);
    }

    /// <summary>リポジトリから読み出した時に呼び出す。</summary>
    /// <param name="date">リポジトリから読み出した契約日</param>
    /// <returns>契約日</returns>
    /// <remarks>リポジトリ以外からの生成に利用されないようinternalにしている。</remarks>
    internal static ContractDate Reconstruct(DateTime date)
    {
        return new ContractDate(date);
    }
}
SalesTax.cs
/// <summary>消費税</summary>
internal class SalesTax
{
    /// <summary>施行日</summary>
    internal readonly DateTime EnforcementDate;

    /// <summary>税率</summary>
    internal readonly decimal Rate;

    /// <summary>消費税</summary>
    /// <param name="enforcementDate">施行日</param>
    /// <param name="rate">税率</param>
    internal SalesTax(DateTime enforcementDate, decimal rate)
    {
        if (!IsValidRate(rate))
        {
            throw new ArgumentOutOfRangeException();
        }

        EnforcementDate = enforcementDate;
        Rate = rate;
    }

    /// <summary>有効な税率かどうかを返す</summary>
    /// <param name="rate">税率</param>
    /// <returns>有効な場合true</returns>
    private static bool IsValidRate(decimal rate)
    {
        return 0m <= rate;
    }
}
SalesTaxApplyRule.cs
/// <summary>消費税適用ルール</summary>
internal class SalesTaxApplyRule
{
    private readonly List<SalesTax> _salesTaxes;

    internal SalesTaxApplyRule()
    {
        _salesTaxes = new List<SalesTax>();
        // 最新の施行日から順に格納すること。
        // (開発者が順番を気にしなくても良いように設計するのがホントは望ましい)
        _salesTaxes.Add(new SalesTax(enforcementDate: new DateTime(2019, 10, 1), rate: 0.10m));
        _salesTaxes.Add(new SalesTax(enforcementDate: new DateTime(2014,  4, 1), rate: 0.08m));
        _salesTaxes.Add(new SalesTax(enforcementDate: new DateTime(1997,  4, 1), rate: 0.05m));
        _salesTaxes.Add(new SalesTax(enforcementDate: new DateTime(1989,  4, 1), rate: 0.03m));
    }

    /// <summary>消費税ルールを適用する</summary>
    /// <param name="contractDate">契約日</param>
    /// <returns>適用された消費税率</returns>
    internal decimal ApplyRule(ContractDate contractDate)
    {
        var corresponded = _salesTaxes.Find(tax => tax.EnforcementDate <= contractDate.Value);
        return corresponded != null ? corresponded.Rate : 0.00m;
    }
}
AppliedSalesTaxRate.cs
/// <summary>適用された消費税率</summary>
public class AppliedSalesTaxRate
{
    private static readonly SalesTaxApplyRule _salesTaxApplyRule = new SalesTaxApplyRule();
    private readonly decimal _rate;

    public decimal Value { get { return _rate; } }

    /// <summary>コンストラクタ</summary>
    /// <param name="contractDate">契約日</param>
    public AppliedSalesTaxRate(ContractDate contractDate)
    {
        _rate = _salesTaxApplyRule.ApplyRule(contractDate);
    }
}
AmountIncludingTax.cs
/// <summary>税込金額</summary>
public class AmountIncludingTax
{
    private readonly int _amount;

    public int Value { get { return _amount; } }

    /// <summary>コンストラクタ</summary>
    /// <param name="amountExcludingTax">税抜き金額</param>
    /// <param name="appliedSalesTaxRate">適用された消費税率</param>
    public AmountIncludingTax(
        AmountExcludingTax amountExcludingTax,
        AppliedSalesTaxRate appliedSalesTaxRate)
    {
        _amount = (int)(amountExcludingTax.Value * (1m + appliedSalesTaxRate.Value));
    }
}

消費税の計算に関して、全て ValueObject(及びその亜種)+完全コンストラクタで設計 できました。
丁寧すぎるほど丁寧に設計しましたが、これらクラス図やソースコードをご覧になってどうでしょう、カンの良い方は以下4点に気づいたのではと思います。

  • 業務概念が見える化されている
  • クラスがその業務概念を説明している
  • クラスが小さい
  • internal(Javaでいうpackage private)の活用

それぞれ説明していきます。

業務概念が見える化されている

業務概念がひとつひとつクラス化されているため、どんな業務概念を取り扱っているのか見える化されています。
仮にこれがクラス化されていないことを想像してみて下さい。何千何万行ものソースコードの深い深い海の中のテキトーな変数として埋もれてしまい、 デバッグや仕様変更時のコード分析で多大な労苦を味わうことになる でしょう。

クラスがその業務概念を説明している

業務概念の名を冠したクラスひとつひとつが、その業務概念や仕様を説明するようにロジックが組まれています。このように、 クラス:業務概念:業務ロジック=1:1:1 となるように設計することが肝要です。
国語辞典を思い出してみて下さい。例えば「消費税」を引くと「消費に対して課される租税」と出てきます。それと同じように、ValueObjectのように業務概念を表したクラスには、「○○とは△△である」のように、 その言葉の定義を表すようロジックを実装 しましょう。 理解容易性 が格段に向上します。まさに 名は体を表す です。

クラスが小さい

ちゃんとドメイン分析してこまめに業務概念をクラス化すると、ひとつひとつのクラスが数十行程度の小さなものになります。
テスト容易性 が向上し、安全品質が更に堅牢になります。
また、「 チャンク 」でググると分かるかと思いますが、普通の人間が一度に知覚可能な概念の個数は 4±1個 だとされています。クラスを小さくし、 そのクラスに登場する概念が4±1個以内に設計する ことにより、やはり 理解容易性 の向上に貢献します。

こうして設計されたプログラムは、 トレーサビリティ が向上します。 どこに何が実装されてあるのか迅速に発見することができる ようになります。
例えば、消費税法では「経過措置」という概念がありますが、今回の設計では考慮されていません。「消費税率の適用には『経過措置』も考慮しなければならない。消費税適用ルールが定義されるコードはどこだ!?」という事態が発生したとします。「業務概念が全てクラスとして設計されていること」が前提となっていれば、何千何万行ものソースコードを舐める必要がなく、クラス一覧から消費税適用ルールであるSalesTaxApplyRuleクラスを見つけ出せばよいことになります。また、ルールロジックが他のクラスに実装されておらず、SalesTaxApplyRuleクラス内に閉じているので、SalesTaxApplyRuleクラスだけを経過措置に関してロジック変更すれば良いことになります。変更が高速化し、 生産性が向上 します。

internal(Javaでいうpackage private)の使用

税抜き金額AmountExcludingTaxや契約日ContractDateはView層での表示が想定されるため、可視性はpublicで設計しています。一方、消費税SalesTaxや消費税適用ルールSalesTaxApplyRuleは外部からアクセスされる必要性がないため、アクセスがパッケージ内だけで閉じるようinternalで設計しています。
何でもかんでもpublicで定義してるのをプログラミング入門書で頻繁に見受けられますが、よく考えずに真似してしまうとpublic定義されたクラスはあらゆるクラスと関係し始めて影響範囲が拡大し、低凝集密結合を誘発する大きな要因となります。
パッケージの粒度設計も然ることながら、クラスの可視性は基本的にinternalで設計し、パッケージ外に本当に公開が必要なクラスのみ、publicとして設計しましょう。(※なお、C#言語仕様では、クラスのアクセス修飾子を省略するとinternalになります。この意味をよく考えてみましょう。)

まとめ

ValueObject + 完全コンストラクタ 設計要件

設計要件 理由
値そのものをクラス化すること。 疎結合高凝集にするため。
クラス名は値の名称であること。 理解容易性向上のため。
値を格納するインスタンス変数を(原則的に)1個用意すること。 概念が異なる値と疎結合にするため。
インスタンス変数への代入はコンストラクタ引数を介して行うこと。更に、代入前に不正値チェックし、不正値の場合例外をスローすること。 正しい値を持ったインスタンスのみが存在できるようにするため。
インスタンス変数にはreadonlyを付与し、不変にすること。 並列処理に強くなるため。
値を変更したい場合は、変更値を持ったインスタンスを新たに生成すること。その場合業務概念的に許される操作のみメソッドとして公開すること。 不正な演算を防ぐため。
業務概念を表す値は、可能な限り全てValueObjectで設計すること。 概念の異なる値同士の取り違えを防ぐため。
インスタンス生成の用途が複数ある場合、用途ごとにFactoryメソッドを用意し、コンストラクタをprivateにすること。 利用側の不正なインスタンス生成を防ぐため。
概念的に複雑なものは、ボトムアップでの設計を検討すること。 トップダウンでは概念の洗い出しに漏れが生じやすい。概念を漏れなく洗い出して設計するため。
名前が競合した場合、更にドメイン分析を重ね、名前のブラッシュアップを検討すること。 理解容易性向上のため。
リスト操作するロジックにはファーストクラスコレクションパターンを適用すること。 リスト操作周りを単純化するため。
クラスに登場する概念が4±1個以内になるよう設計すること。 脳の負荷を下げ、混乱を避けるため。影響範囲低減のため。
internalなどのアクセス修飾子を適切に使用すること。 影響範囲低減のため。

本記事の手法よる効果

  • 生産性向上
  • 修正漏れ低減
  • クローンコード低減
  • 未初期化状態の防止
  • 不正値の防止
  • 異なる概念の値混入抑止
  • インスタンス生成の安全性向上
  • リスト操作に関する複雑度低減
  • 理解容易性向上
  • コードのトレーサビリティ向上

基本ほど遠い道のり、一歩ずつ極めていきませんか

ValueObjectは1個たかだか数十行程度の規模ですが、大真面目にやろうとするといくつもの要件を満たすよう設計する必要があります。不慣れな方にとって、これら全てを満たすのはかなり骨の折れることになるでしょう。
ですがValueObjectにはソフトウェア設計の基本が詰まっていると私は思っています。基本をひとつずつ踏み固め、一歩ずつ極めていくことで、高品質で高い開発生産性に繋がっていくことでしょう。

crowdworks
21世紀の新しいワークスタイルを提供する日本最大級のクラウドソーシング「クラウドワークス」のエンジニアチームです!
https://crowdworks.co.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away