ドメイン駆動設計:バリューオブジェクトとエンティティフレームワークコアの実践

Habréでは、ドメインドリブンデザインに関する記事だけでなく、一般的なアーキテクチャと.Netの例の両方が執筆されています。 しかし同時に、Value Objectsのようなこのアーキテクチャの重要な部分については、ほとんど言及されていません。



この記事では、Entity Framework Coreを使用して.Net CoreにValueオブジェクトを実装することの微妙な違いを明らかにします。



猫の下にはたくさんのコードがあります。



理論のビット



ドメイン駆動設計のアーキテクチャの中核はドメインです。これは、開発中のソフトウェアが適用されるサブジェクト領域です。 通常、さまざまなデータとやり取りするアプリケーションのビジネスロジック全体を示します。 データには次の2つのタイプがあります。





エンティティオブジェクトは、ビジネスロジックでエンティティを定義し、常にエンティティを見つけたり別のエンティティと比較したりするための識別子を持っています。 2つのエンティティの識別子が同じ場合、これは同じエンティティです。 ほとんど常に変化します。

値オブジェクトは不変のタイプであり、その値は作成中に設定され、オブジェクトの存続期間を通じて変化しません。 識別子はありません。 2つのVOが構造的に同一である場合、それらは同等です。



エンティティには、他のエンティティとVOが含まれる場合があります。 VOには他のVOを含めることができますが、エンティティは含めることができません。



したがって、ドメインロジックはEntityおよびVOでのみ動作するはずです-これにより、その一貫性が保証されます。 文字列、整数などの基本的なデータ型。 多くの場合、ドメインの状態に違反するだけであるため、VOとして機能することはできません。これは、DDDのフレームワークではほとんど災害です。



例。 さまざまなマニュアルですべての人にうんざりしているPersonクラスは、しばしば次のように表示されます。



public class Person { public int Id { get; set; } public string Name { get; set; } public int Age { get; set; } }
      
      





シンプルで明確-識別子、名前、年齢、どこで間違いを犯すことができますか?



ここにはいくつかのエラーがあります。たとえば、ビジネスロジックの観点から、名前は必須です。長さ0または100文字を超えることはできず、特殊文字、句読点などを含めることはできません。 また、年齢は10歳未満または120歳を超えることはできません。



プログラミング言語の観点から見ると、5は完全に通常の整数で、同様に空の文字列です。 しかし、ドメインはすでに正しくない状態です。



練習に移りましょう



この時点で、VOは不変であり、ビジネスロジックに有効な値を含む必要があることがわかります。



耐性は、オブジェクトの作成時に読み取り専用プロパティを初期化することにより実現されます。

値の検証は、コンストラクターで発生します(Guard句)。 他のレイヤーがクライアント(同じブラウザー)から受信したデータを検証できるように、検証自体を公開することが望ましいです。



名前と年齢のVOを作成しましょう。 さらに、タスクを少し複雑にします-FirstNameとLastNameを組み合わせたPersonalNameを追加し、これをPersonに適用します。



お名前
 public class Name { private static readonly Regex ValidationRegex = new Regex( @"^[\p{L}\p{M}\p{N}]{1,100}\z", RegexOptions.Singleline | RegexOptions.Compiled); public Name(String value) { if (!IsValid(value)) { throw new ArgumentException("Name is not valid"); } Value = value; } public String Value { get; } public static Boolean IsValid(String value) { return !String.IsNullOrWhiteSpace(value) && ValidationRegex.IsMatch(value); } public override Boolean Equals(Object obj) { return obj is Name other && StringComparer.Ordinal.Equals(Value, other.Value); } public override Int32 GetHashCode() { return StringComparer.Ordinal.GetHashCode(Value); } }
      
      







個人名
 public class PersonalName { protected PersonalName() { } public PersonalName(Name firstName, Name lastName) { if (firstName == null) { throw new ArgumentNullException(nameof(firstName)); } if (lastName == null) { throw new ArgumentNullException(nameof(lastName)); } FirstName = firstName; LastName = lastName; } public Name FirstName { get; } public Name LastName { get; } public String FullName => $"{FirstName} {LastName}"; public override Boolean Equals(Object obj) { return obj is PersonalName personalName && EqualityComparer<Name>.Default.Equals(FirstName, personalName.FirstName) && EqualityComparer<Name>.Default.Equals(LastName, personalName.LastName); } public override Int32 GetHashCode() { return HashCode.Combine(FirstName, LastName); } public override String ToString() { return FullName; } }
      
      







年齢
 public class Age { public Age(Int32 value) { if (!IsValid(value)) { throw new ArgumentException("Age is not valid"); } Value = value; } public Int32 Value { get; } public static Boolean IsValid(Int32 value) { return 10 <= value && value <= 120; } public override Boolean Equals(Object obj) { return obj is Age other && Value == other.Value; } public override Int32 GetHashCode() { return Value.GetHashCode(); } }
      
      







そして最後に人:



 public class Person { public Person(PersonalName personalName, Age age) { if (personalName == null) { throw new ArgumentNullException(nameof(personalName)); } if (age == null) { throw new ArgumentNullException(nameof(age)); } Id = Guid.NewGuid(); PersonalName= personalName; Age = age; } public Guid Id { get; private set; } public PersonalName PersonalName{ get; set; } public Age Age { get; set; } }
      
      





したがって、フルネームまたは年齢なしでPersonを作成することはできません。 また、「間違った」名前や「間違った」年齢を作成することはできません。 優れたプログラマーは、Name.IsValid(“ John”)およびAge.IsValid(35)メソッドを使用して、コントローラーで受信したデータを確実にチェックし、データが正しくない場合、クライアントに通知します。



エンティティとVOのみを使用するようにモデル内のすべての場所でルールを作成すると、多数のエラーから身を守ることができます。誤ったデータは単純にモデルに入りません。



持続性



次に、データウェアハウスにデータを保存し、要求に応じて取得する必要があります。 Entity Framework CoreをORM、データウェアハウス-MS SQL Serverとして使用します。



DDDの明確な定義:永続性は、データアクセスの特定の実装を隠すため、インフラストラクチャレイヤーのサブセットです。



ドメインは永続性について何も知る必要がなく、これはリポジトリのインターフェースのみを決定します。



また、永続性には、特定の実装、マッピング構成、UnitOfWorkオブジェクトが含まれます。



リポジトリと作業単位を作成する価値があるかどうかについては、2つの意見があります。



一方で、いや、それは必要ありません。なぜなら、Entity Framework Coreではこれはすべて既に実装されているからです。 データストレージに基づくDAL->ビジネスロジック->プレゼンテーションという形式のマルチレベルアーキテクチャがある場合、EF Coreの機能を直接使用しないでください。



ただし、DDDのドメインは、データストレージと使用されるORMに依存しません。これらはすべて、永続性にカプセル化された実装の微妙なものであり、他の人には関係ありません。 DbContextを他のレイヤーに提供する場合、実装の詳細をすぐに公開し、選択したORMに緊密にバインドし、すべてのビジネスロジックの基礎としてDALを取得しますが、そうではありません。 大まかに言えば、ドメインはORMの変更や、レイヤーとしての永続性の喪失にさえ気付かないはずです。



ドメイン内のPersonsリポジトリインターフェース:



 public interface IPersons { Task Add(Person person); Task<IReadOnlyList<Person>> GetList(); }
      
      





および永続性での実装:



 public class EfPersons : IPersons { private readonly PersonsDemoContext _context; public EfPersons(UnitOfWork unitOfWork) { if (unitOfWork == null) { throw new ArgumentNullException(nameof(unitOfWork)); } _context = unitOfWork.Context; } public async Task Add(Person person) { if (person == null) { throw new ArgumentNullException(nameof(person)); } await _context.Persons.AddAsync(person); } public async Task<IReadOnlyList<Person>> GetList() { return await _context.Persons.ToListAsync(); } }
      
      





複雑なことはないように見えますが、問題があります。 すぐに使えるEntity Framework Coreは、基本型(string、int、DateTimeなど)でのみ機能し、PersonalNameとAgeについては何も知りません。 EF Coreにバリューオブジェクトを理解するように教えましょう。



構成



Fluent APIは、DDDでエンティティを構成するのに最適です。 ドメインはマッピングのニュアンスについて何も知る必要がないため、属性は適切ではありません。



基本構成PersonConfigurationを使用して、Persistenceにクラスを作成します。



 internal class PersonConfiguration : IEntityTypeConfiguration<Person> { public void Configure(EntityTypeBuilder<Person> builder) { builder.ToTable("Persons"); builder.HasKey(p => p.Id); builder.Property(p => p.Id).ValueGeneratedNever(); } }
      
      





DbContextにプラグインします。



 protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); builder.ApplyConfiguration(new PersonConfiguration()); }
      
      





マッピング



この資料が作成されたセクション。



現時点では、非標準クラスを基本型にマップするための2つの多かれ少なかれ便利な方法、値変換と所有型があります。



値の変換



この機能はEntity Framework Core 2.1に登場し、2つのデータ型間の変換を決定できます。



Ageのコンバーターを作成しましょう(このセクションでは、すべてのコードはPersonConfigurationにあります)。



 var ageConverter = new ValueConverter<Age, Int32>( v => v.Value, v => new Age(v)); builder .Property(p => p.Age) .HasConversion(ageConverter) .HasColumnName("Age") .HasColumnType("int") .IsRequired();
      
      





シンプルで簡潔な構文ですが、欠陥がないわけではありません:



  1. nullを変換できません。
  2. 1つのプロパティをテーブル内の複数の列に変換したり、その逆を行うことはできません。
  3. EF Coreは、このプロパティを持つLINQ式をSQLクエリに変換できません。


最後のポイントについて詳しく説明します。 特定の年齢のPersonのリストを返すメソッドをリポジトリに追加します。



 public async Task<IReadOnlyList<Person>> GetOlderThan(Age age) { if (age == null) { throw new ArgumentNullException(nameof(age)); } return await _context.Persons .Where(p => p.Age.Value > age.Value) .ToListAsync(); }
      
      





年齢には条件がありますが、EF CoreはそれをSQLクエリに変換できず、Where()に到達すると、テーブル全体をアプリケーションメモリにロードし、その後、LINQを使用して、条件p.Age.Value> age.Valueを満たします。 。



一般に、Value Conversionsはシンプルで迅速なマッピングオプションですが、EF Coreのこの機能に留意する必要があります。そうしないと、ある時点で大きなテーブルをクエリするときにメモリが不足する可能性があります。



所有タイプ



所有された型はEntity Framework Core 2.0に登場し、通常のEntity Frameworkの複雑な型を置き換えました。



所有タイプとして年齢を作成しましょう:



 builder.OwnsOne(p => p.Age, a => { a.Property(u => u.Value).HasColumnName("Age"); a.Property(u => u.Value).HasColumnType("int"); a.Property(u => u.Value).IsRequired(); });
      
      





悪くない。 また、所有タイプには、値変換のデメリット、つまりポイント2と3がありません。



2. 1つのプロパティをテーブル内の複数の列に、またはその逆変換することができます。



PersonalNameに必要なものですが、構文はすでに少しオーバーロードされています。



 builder.OwnsOne(b => b.PersonalName, pn => { pn.OwnsOne(p => p.FirstName, fn => { fn.Property(x => x.Value).HasColumnName("FirstName"); fn.Property(x => x.Value).HasColumnType("nvarchar(100)"); fn.Property(x => x.Value).IsRequired(); }); pn.OwnsOne(p => p.LastName, ln => { ln.Property(x => x.Value).HasColumnName("LastName"); ln.Property(x => x.Value).HasColumnType("nvarchar(100)"); ln.Property(x => x.Value).IsRequired(); }); });
      
      





3. EF Core 、このプロパティを持つLINQ式をSQLクエリに変換できます。

リストを読み込むときに、LastNameとFirstNameによる並べ替えを追加します。



 public async Task<IReadOnlyList<Person>> GetList() { return await _context.Persons .OrderBy(p => p.PersonalName.LastName.Value) .ThenBy(p => p.PersonalName.FirstName.Value) .ToListAsync(); }
      
      





このような式はSQLクエリに正しく変換され、並べ替えはアプリケーションではなくSQLサーバー側で実行されます。



もちろん、欠点もあります。



  1. nullの問題は解消されていません。
  2. 所有タイプのフィールドは読み取り専用にはできず、保護されたセッターまたはプライベートセッターが必要です。
  3. 所有タイプは通常のエンティティとして実装されます。つまり、次のことを意味します。

    • これらには識別子があります(シャドウプロパティのように、つまりドメインクラスに表示されません)。
    • EF Coreは、通常のエンティティとまったく同じように、所有タイプのすべての変更を追跡します。


一方で、これは、値オブジェクトのあるべき姿ではありません。 識別子を含めることはできません。 VOは変更を追跡すべきではありません-最初は不変なので、親Entityのプロパティは追跡すべきですが、VOのプロパティは追跡すべきではありません。



一方、これらは実装の詳細であり、省略できますが、忘れないでください。 変更の追跡はパフォーマンスに影響します。 単一のエンティティのサンプル(たとえば、Idによる)または小さなリストでこれが目立たない場合、「重い」エンティティ(多くのVOプロパティ)の大きなリストの選択では、追跡のためにパフォーマンスの低下が非常に顕著になります。



プレゼンテーション



ドメインとリポジトリに値オブジェクトを実装する方法を見つけました。 それをすべて使用する時が来ました。 リストPersonとPersonを追加するためのフォームの2つのシンプルなページを作成しましょう。



アクションメソッドのないコントローラーコードは次のようになります。



 public class HomeController : Controller { private readonly IPersons _persons; private readonly UnitOfWork _unitOfWork; public HomeController(IPersons persons, UnitOfWork unitOfWork) { if (persons == null) { throw new ArgumentNullException(nameof(persons)); } if (unitOfWork == null) { throw new ArgumentNullException(nameof(unitOfWork)); } _persons = persons; _unitOfWork = unitOfWork; } // Actions private static PersonModel CreateModel(Person person) { return new PersonModel { FirstName = person.PersonalName.FirstName.Value, LastName = person.PersonalName.LastName.Value, Age = person.Age.Value }; } }
      
      





アクションを追加して、個人リストを取得します。



 [HttpGet] public async Task<IActionResult> Index() { var persons = await _persons.GetList(); var result = new PersonsListModel { Persons = persons .Select(CreateModel) .ToArray() }; return View(result); }
      
      





表示する
 @model PersonsListModel @{ ViewData["Title"] = "Persons List"; } <div class="text-center"> <h2 class="display-4">Persons</h2> </div> <table class="table"> <thead> <tr> <td><b>Last name</b></td> <td><b>First name</b></td> <td><b>Age</b></td> </tr> </thead> @foreach (var p in Model.Persons) { <tr> <td>@p.LastName</td> <td>@p.FirstName</td> <td>@p.Age</td> </tr> } </table>
      
      







複雑なことはありません-リストをダウンロードし、それぞれにデータ転送オブジェクト(PersonModel)を作成しました



個人および対応するビューに送信されます。



結果




さらに興味深いのは、Personの追加です。



 [HttpPost] public async Task<IActionResult> AddPerson(PersonModel model) { if (model == null) { return BadRequest(); } if (!Name.IsValid(model.FirstName)) { ModelState.AddModelError(nameof(model.FirstName), "FirstName is invalid"); } if (!Name.IsValid(model.LastName)) { ModelState.AddModelError(nameof(model.LastName), "LastName is invalid"); } if (!Age.IsValid(model.Age)) { ModelState.AddModelError(nameof(model.Age), "Age is invalid"); } if (!ModelState.IsValid) { return View(); } var firstName = new Name(model.FirstName); var lastName = new Name(model.LastName); var person = new Person( new PersonalName(firstName, lastName), new Age(model.Age)); await _persons.Add(person); await _unitOfWork.Commit(); var persons = await _persons.GetList(); var result = new PersonsListModel { Persons = persons .Select(CreateModel) .ToArray() }; return View("Index", result); }
      
      





表示する
 @model PersonDemo.Models.PersonModel @{ ViewData["Title"] = "Add Person"; } <h2 class="display-4">Add Person</h2> <div class="row"> <div class="col-md-4"> <form asp-action="AddPerson"> <div asp-validation-summary="ModelOnly" class="text-danger"></div> <div class="form-group"> <label asp-for="FirstName" class="control-label"></label> <input asp-for="FirstName" class="form-control" /> <span asp-validation-for="FirstName" class="text-danger"></span> </div> <div class="form-group"> <label asp-for="LastName" class="control-label"></label> <input asp-for="LastName" class="form-control" /> <span asp-validation-for="LastName" class="text-danger"></span> </div> <div class="form-group"> <label asp-for="Age" class="control-label"></label> <input asp-for="Age" class="form-control" /> <span asp-validation-for="Age" class="text-danger"></span> </div> <div class="form-group"> <input type="submit" value="Create" class="btn btn-primary" /> </div> </form> </div> </div> @section Scripts { @{await Html.RenderPartialAsync("_ValidationScriptsPartial");} }
      
      







受信データの必須の検証があります:



 if (!Name.IsValid(model.FirstName)) { ModelState.AddModelError(nameof(model.FirstName), "FirstName is invalid"); }
      
      





これが行われない場合、間違った値でVOを作成すると、ArgumentExceptionがスローされます(VOコンストラクターのGuard Clauseを思い出してください)。 検証を使用すると、値の1つが間違っているというメッセージをユーザーに送信するのがはるかに簡単になります。



結果




ここでは、Asp Net Coreで属性を使用して、データ検証の通常の方法がある小さな余談をする必要があります。 しかし、DDDでは、この検証方法はいくつかの理由で正しくありません。





AddPerson()に戻ります。 データ検証の後、PersonalName、Age、Personが作成されます。 次に、リポジトリにオブジェクトを追加し、変更を保存します(コミット)。 EfPersonsリポジトリでCommitが呼び出されないことが非常に重要です。 リポジトリのタスクは、データに対して何らかのアクションを実行することです。 コミットは、厳密に言えば外部でのみ行われます-プログラマーが決定します。 そうしないと、特定のビジネスの反復の途中でエラーが発生したときに状況が発生する可能性があります。一部のデータは保存され、一部は保存されません。 「壊れた」状態のドメインを受け取ります。 コミットが最後に行われ、エラーが発生した場合、トランザクションは単にロールバックされます。



おわりに



バリューフレームワークの一般的な実装例と、Entity Framework Coreでのマッピングのニュアンスを示しました。 この資料が、ドメイン駆動設計の要素を実際に適用する方法を理解するのに役立つことを願っています。



完全なPersonsDemoプロジェクトのソースコード-GitHub



この資料は、PersonalNameまたはAgeがPersonの必須プロパティではない場合、オプションの(null可能)値オブジェクトと対話する問題を開示していません。 この記事でこれを説明したかったのですが、すでに多少の過負荷が生じています。 この問題に関心がある場合-コメントを書いてください、継続はそうなります。



一般に「美しいアーキテクチャ」、特にドメイン駆動型デザインのファンには、 Enterprise Craftsmanshipリソースを強くお勧めします。



.Netには、アーキテクチャの適切な構築と実装例に関する多くの有用な記事があります。 そこでいくつかのアイデアが取り入れられ、「戦闘」プロジェクトにうまく実装され、この記事に部分的に反映されました。



所有タイプ値の変換の公式ドキュメントも使用されました。



All Articles