Edited at

区分ごとにバリデーションが変わるマスタメンテナンス画面をドメイン駆動設計してみる


はじめに

区分ごとに入力項目のバリデーションが違うといった仕様のマスタメンテナンス画面に、

ドメイン駆動設計のパターンに当てはめたら、どんな風になるだろうか。と試してみた記事です。

記事に載せているコードは、すべてC#です。


ソフトウェアでよく見かける「区分」

ソフトウェアを作っているときに、「区分」という言葉をよく見かけます。(種別という言葉でもよく見かけます)


  • 社員区分

  • 取引区分

  • 料金区分

  • 仕入区分

これら区分によって、業務ロジックが変わるパターンはよくあると思います。

(料金区分であれば、大人、子供と分かれて料金計算が違うなど)

以前見た仕様で、マスタメンテナンスでの登録時のバリデーションを、区分ごとに変えてくれといったものがありました。

当時はif文、case文をネストしまくったコードを読んだり書いたりしていましたが、これもまた区分ごとにロジックを変えればいけそうだなーと思い、ちょっと試しにコードを書いてみたいと思います。


とある工場の部材管理画面

題材として取り扱うのは、工場で製品を作るための「部材」を管理するマスタメンテナンス画面です。

「部材」自体を管理するのではなく、どんな「部材」が存在するかを、工場の本社がマスタ登録するための画面です。

それらをもとに、工場では製品を作るのに必要な部材を選び、足りなければ発注をするということになります。

マスタ登録するだけであれば、単純に登録・参照・変更・削除ができれば問題なかったのですが、ここで厄介な問題が、以下のルール。


  • 部材には重さ、長さ、消費量などが登録できる。

  • ただし、部材には「部材区分」があり、ある区分にしか必要の無い項目や、登録・変更時の必須チェックが異なる。

  • 部材Aは、部材マスタに2件までしか登録できないようにする。

  • 部材Bの場合、対応する製品の種類とサイズの組み合わせが同じものを2件までしか登録できない。

部材Aがどの製品を作るときでも使う共通の部品。

部材Bが個々の製品で使用する部品

というイメージです。

image.png

あと、以下のプロジェクトでのルール。


  • マスタ画面とデータベースのテーブルは1:1となっている。
    → 部材マスタ画面には、部材テーブルというのが1つだけ存在。

バリデーションルールをまとめると、こんな感じです。

○…必須チェックあり

部材区分
部材ID
部材名
部材区分
消費量
重さ
長さ
対応製品種類
サイズ
特殊

部材区分A





 全体で2件までしか登録できない

部材区分B








同じ製品種類&サイズの組み合わせは2件までしか登録できない


簡単にモデル図を書いてみる

少し図に起こしてまとめてみます。

部材区分によってバリデーションする項目が違うということは、部材区分自体がそのルールを持つという捉え方ができそうなので、以下のような図となりました。

スクリーンショット 2018-12-21 8.31.30.png


とりあえず、素直に書いてみる

値オブジェクトやエンティティを作る前に、いったん上記のようなバリデーションを素直に実装してみようと思います。

if文のネストとか、雰囲気が伝わればいいので、細かい文法とかそのへんは目をつむってください。


public void Save(string id,
string name,
int materialTypeId,
string productType,
float? size,
float? consumption,
float? length,
float? weight)
{

// ここでId重複チェックを行うので、一回DBにアクセスをする。
if (repository.Find(id) != null)
{
throw new Exception("IDが重複しています");
}

// materialTypeIdの0が部材区分A、1が部材区分B
if (materialTypeId == 0)
{
if (id == null && name == null && consumption == null && length == null && weight == null)
{
throw new Exception();
}

// 部材区分Aの場合、何件あるかどうかここでまたDBに問い合わせをして件数を取得する。
List<Material> list = repository.Find(materialTypeId);

if (list.Count >= 2)
{
throw new Exception("部材区分Aは2件登録されています");
}
}
else if (materialTypeId == 1)
{
if (id == null && name == null && productType == null && size == null && length == null && weight == null)
{
throw new Exception();
}

// 部材区分Bの場合、今回の引数にある製品種類とサイズの組み合わせが何件あるかどうか、
// DBに問い合わせをして件数を取得する。
List<Material> list = repository.Find(productType, size);

if (list.Count >= 2)
{
throw new Exception($"{productType}{size}の組み合わせは2件登録されています");
}
}
}

ぱっと見、わかりづらいと思います。

ああ、いまnullチェックしているんだな。件数チェックしているんだな。ということは読んでいればわかってきますが、

ではなぜ件数チェックしているの? なぜnullチェックしているの? といった事がこのコードからだと読み取りにくいと思います。

詳細設計とにらめっこをしながら、このソースコードがやりたいことがようやく読み取れるので、保守のしにくいコードでしょう。

その詳細設計自体が無い現場も珍しくないため、出来る限り意図が読み取れるコードを書いていきたいです。


部材区分のオブジェクトを作る

ここからドメイン駆動設計っぽく進めていきます。

早速「部材区分」のオブジェクトを作ってみます。

区分というと、最初は列挙型(enum)を思い浮かべます。

ただ、今回は「部材区分」ごとにバリデーションが違うというルールがあります。

単なる列挙型のみだと、それを表現するのは難しいため、それ用の新しいクラスを作ります。


区分オブジェクト用の基底クラス


public abstract class Enumeration : IComparable
{
public string Name { get; private set; }
public int Id { get; private set; }

protected Enumeration() { }

protected Enumeration(int id, string name)
{
Id = id;
Name = name;
}

public override string ToString() => Name;

public static IEnumerable GetAll<T>() where T : Enumeration
{
var fields = typeof(T).GetFields(BindingFlags.Public |
BindingFlags.Static |
BindingFlags.DeclaredOnly);

return fields.Select(f => f.GetValue(null)).Cast<T>();
}

public override bool Equals(object obj)
{
var otherValue = obj as Enumeration;

if (otherValue == null)
return false;

var typeMatches = GetType().Equals(obj.GetType());
var valueMatches = Id.Equals(otherValue.Id);

return typeMatches && valueMatches;
}

public int CompareTo(object other) => Id.CompareTo(((Enumeration)other).Id);

public override int GetHashCode()
{
return 2108858624 + Id.GetHashCode();
}
}


参考:enum 型の代わりに Enumeration クラスを使用する


部材区分

public abstract class MaterialType : Enumeration

{
// 部材区分オブジェクト
public static MaterialType A = new MaterialTypeA();
public static MaterialType B = new MaterialTypeB();

protected MaterialType(int id, string name) : base(id, name){ }

// バリデーションメソッドを抽象メソッドとして用意しておく
public abstract bool ValidateConsumption(Consumption consumption);
public abstract bool ValidateLength(Length length);
public abstract bool ValidateName(MaterialName name);
public abstract bool ValidateTypeAndSize(TypeAndSize typesize);
public abstract bool ValidateWeight(Weight weight);

public static MaterialType GetMaterialType(int id)
{
var materialTypes = Enumeration.GetAll<MaterialType>().Cast<MaterialType>();

return materialTypes.FirstOrDefault(x => x.Id == id);
}

// 部材区分A
private class MaterialTypeA : MaterialType
{
public MaterialTypeA() : base(0, "部材A") { }

public override bool ValidateConsumption(Consumption consumption)
{
return consumption.Value != null;
}

public override bool ValidateLength(Length length)
{
return length.Value != null;
}

public override bool ValidateName(MaterialName name)
{
return name.Value != null;
}

public override bool ValidateTypeAndSize(TypeAndSize typesize)
{
// 部材Aでは特にチェックしない
return true;
}

public override bool ValidateWeight(Weight weight)
{
return weight.Value != null;
}
}

// 部材区分B
private class MaterialTypeB : MaterialType
{
public MaterialTypeB() : base(1, "部材B") { }
public override bool ValidateConsumption(Consumption consumption)
{
// 部材Bではチェックしない
return true;
}

public override bool ValidateLength(Length length)
{
return length.Value != null;
}

public override bool ValidateName(MaterialName name)
{
return name.Value != null;
}

public override bool ValidateTypeAndSize(TypeAndSize typesize)
{
bool validateOk = (typesize.Size.Value != null && typesize.Type.Value != null);
return validateOk;
}

public override bool ValidateWeight(Weight weight)
{
return weight.Value != null;
}
}
}


MaterialTypeクラスの中に抽象メソッドで、ValidateXXX()を用意することによって、

それを実装したMaterialTypeA、MaterialTypeBの方でバリデーションルールを変えて実装しています。

実際に区分を設定するときには、

// enum型っぽく設定

var type = MaterialType.A;

// 区分IDが分かっている場合
// 0を入れれば、MaterialTypeAが返ってくる
var type = MaterialType.GetMaterialType(0);

こんな感じで使っていきます。


部材区分のクラス図

スクリーンショット 2018-12-20 10.02.57.png


部材エンティティを作成する

部材区分を用意し、各項目の値オブジェクトを持つ部材エンティティを作成します。


部材エンティティ


public class Material
{
public MaterialId Id { get; private set; }
public MaterialName Name { get; private set; }
public MaterialType Type { get; private set; }
public TypeAndSize TypeAndSize { get; private set; }
public Consumption Consumption { get; private set; }
public Length Length { get; private set; }
public Weight Weight { get; private set; }

public Material(MaterialId id,
MaterialName name,
MaterialType type,
TypeAndSize typesize,
Consumption consumption,
Length length,
Weight weight)
{
// 先にMaterialTypeがnullで無いことをチェックしないと、
// 後続のValidationが出来なくなる。(Typeがnullなので、nullReferenceErrorとかになるはず)
if (id == null) throw new ArgumentException(nameof(MaterialId));
if (type == null) throw new ArgumentException(nameof(MaterialType));

this.Id = id;
this.Type = type;

if (!Type.ValidateName(name)) throw new ArgumentException(nameof(name));
if (!Type.ValidateLength(length)) throw new ArgumentException(nameof(Length));
if (!Type.ValidateWeight(weight)) throw new ArgumentException(nameof(Weight));
if (!Type.ValidateTypeAndSize(typesize)) throw new ArgumentException(nameof(typesize));
if (!Type.ValidateConsumption(consumption)) throw new ArgumentException(nameof(consumption));

this.Name = name;
this.Length = length;
this.Weight = weight;
this.TypeAndSize = typesize;
this.Consumption = consumption;
}

// 各プロパティの変更メソッド。変更の中にバリデーションも含めている
public void ChangeMaterilType(MaterialType materialType)
{
// 新しい部材区分に設定されているバリデーションルールに則って、
// いまのエンティティのプロパティのチェックをする
if(!materialType.ValidateName(this.Name)
&& !materialType.ValidateLength(this.Length)
&& !materialType.ValidateWeight(this.Weight)
&& !materialType.ValidateTypeAndSize(this.TypeAndSize)
&& !materialType.ValidateConsumption(this.Consumption))
{
throw new ArgumentException("値が不正です");
}

this.Type = materialType;
}

public void ChangeType(ProductType type)
{
var value = new TypeAndSize(type, this.TypeAndSize.Size);
if (!Type.ValidateTypeAndSize(value))
throw new ArgumentException(nameof(type)+"の値が不正です");

this.TypeAndSize = value;
}

public void ChangeSize(Size size)
{
var value = new TypeAndSize(this.TypeAndSize.Type, size);
if(!Type.ValidateTypeAndSize(value))
throw new ArgumentException(nameof(size) + "の値が不正です");

this.TypeAndSize = value;
}

public void ChangeConsumption(Consumption consumption)
{
if (!Type.ValidateConsumption(consumption))
throw new ArgumentException(nameof(consumption) + "の値が不正です");

this.Consumption = consumption;
}

public void ChangeName(MaterialName name)
{
if(!Type.ValidateName(name))
throw new ArgumentException(nameof(name) + "の値が不正です");

this.Name = name;
}

public void ChangWeight(Weight weight)
{
if (!Type.ValidateWeight(weight))
throw new ArgumentException(nameof(weight) + "の値が不正です");

this.Weight = weight;
}

public void ChangeLength(Length length)
{
if (!Type.ValidateLength(length))
throw new ArgumentException(nameof(length) + "の値が不正です");

this.Length = length;
}
}


コンストラクタで生成をするときに、バリデーション用のメソッドを呼び出しています。

this.Typeに格納されているのが、MaterilTypeAか、MaterialTypeBのインスタンスかによって、呼ばれるメソッドが変わり、それぞれのバリデーションメソッドを呼んでいます。

今回ChangeXXX()内に各バリデーションを含めていますが、もしかしたらこれは止めて、

アプリケーションサービスの方で、個別に呼んであげた方が良いのかもしれません。


ドメインサービスを用意する

これで部材区分ごとの必須項目チェックは出来るようになりました。

あとは以下の組み合わせによるチェックが残っています。


  • 部材Aは、部材マスタに2件までしか登録できないようにする。

  • 部材Bは、製品の種類とサイズの組み合わせが同じものを2件までしか登録できない。

このように全体で何件までしか登録できない、などはチェックは個々の値オブジェクトやエンティティの中でチェックするとなると、DBにアクセスするコードをエンティティ内に書く必要が出てくるので、それはDBとの依存関係を作ることになるので、よろしくなさそうです。

なので、ドメインサービスを用意し、全体で何件あるかといったチェックを行うようにします。


ドメインサービス


public class MaterialService
{
IMaterialRepository repository;

public MaterialService(IMaterialRepository repository)
{
this.repository = repository;
}

// 部材IDが重複しているか
public bool IsDuplicatedId(MaterialId id)
{
Material material = repository.Find(id);

return material != null;
}

// 部材区分Aが2件以上登録されているか
public bool IsOverAddedMaterialA()
{
var materials = repository.Find(MaterialType.A);

return materials.Count >= 2;
}

// 製品種類とサイズの組み合わせが2件以上登録されているか
public bool IsOverAddedTypeAndSize(TypeAndSize typeAndWidth)
{
// 製品種類とサイズの組み合わせが登録されているのは部材区分Bのみ
var materials = repository.Find(MaterialType.B);

var count = materials.Count(x => x.TypeAndSize.Equals(typeAndWidth));

return count >= 2;
}
}



アプリケーションサービス

最後に、ユーザーインターフェース側(MVCならControllerあたり)で触ることになるアプリケーションサービスを作成していきます。


public class MaterialApplicationService
{
private IMaterialRepository repository;

public MaterialApplicationService(IMaterialRepository repository)
{
this.repository = repository;
}

public void Save(string id,
string name,
int materialTypeId,
string productType,
float? size,
float? consumption,
float? length,
float? weight)
{
var Id = new MaterialId(id);
var Name = new MaterialName(name);
var Type = MaterialType.GetMaterialType(materialTypeId);
var ProductType = new ProductType(productType);
var Size = new Size(size);
var TypeAndSize = new TypeAndSize(ProductType, Size);
var Consumption = new Consumption(consumption);
var Length = new Length(length);
var Weight = new Weight(weight);

// 値個別のバリデーションは、エンティティを生成する時に行う
var target = new Material(Id, Name, Type, TypeAndSize, Consumption, Length, Weight);

var service = new MaterialService(repository);

if (service.IsDuplicatedId(target.Id))
{
throw new Exception("IDが重複しています");
}

if(Type.Id == MaterialType.A.Id && service.IsOverAddedMaterialA())
{
throw new Exception("部材区分Aが2件登録されています");
}

if(Type.Id == MaterialType.B.Id && service.IsOverAddedTypeAndSize(TypeAndSize))
{
throw new Exception($"{productType}{size}の組み合わせは2件登録されています");
}
repository.Save(target);
}
}

引数でそれぞれデータをもらい、内部でエンティティを作成してからリポジトリのメソッドに渡して保存をしています。

エンティティのコンストラクタ呼び出し時に、部材区分ごとのバリデーションをして、問題が無かったらエンティティを生成しています。

バリデーションで失敗した場合は、エラーが返ってきます。


var app = new MaterialApplicationService(repository);

// 引数3つ目が、部材区分のID
// 0が部材区分A。1が部材区分B。
app.Save("12345678", "mat1", 0, null, null, 55.591f, 40.1f, 30.0f);
app.Save("19878768", "mat4", 0, null, 0.0f, 55.591f, 40.1f, 30.0f);
// ↓3つ目の部材Aを登録しようとするので、エラーとなる。
app.Save("56789011", "mat5", 0, null, null, 55.591f, 40.1f, 30.0f);

app.Save("11112222", "mate3", 1, "M040", 23.92f, null, 9.2f, 20.0f);
// 部材区分B(1)のときに、製品種類がnullになっているので、エラーとなる
app.Save("11112222", "mate6", 1, null, 23.92f, null, 9.2f, 20.0f);


まとめ

区分オブジェクトを作ることによって、if文、case文のネストを排除することが出来てだいぶスッキリしました。

ネスト自体が減ってコードの見通しが良くなるのもそうですが、このコードの意図も読み取りやすくなったように思えます。

(ID重複や、2件以上の登録など)

ただ、書いているうちに気づきましたが、各項目のバリデーション自体は、部材エンティティのコンストラクタに隠れてしまっているので、「いつ項目チェックしているのだろう?」という疑問も湧いてきそうです。

エンティティを生成する時点で、バリデーションに引っかかるような値は入れさせないよう、このような実装にしましたが、

登録時のバリデーション自体は、ドメインサービスなどに移して、アプリケーションサービスクラスの中で呼び出した方が、意図が伝わりそうな気がしました。