dotnet-domain-modeling について
永続化技術に依存しない純粋なドメインモデルを対象とする、C# の戦術的なドメイン駆動設計ガイドです。集約、値オブジェクト、ドメインイベント、リポジトリ契約を扱い、リッチモデルと貧血モデルの違いも説明します。
主な機能
- 01集約ルートとエンティティの設計
- 02値オブジェクトによる概念の表現
- 03ドメインイベントの定義とディスパッチ
- 04ドメインイベントと統合イベントの区別
- 05ドメインサービスとリポジトリ契約の設計
- 06リッチモデルと貧血モデルの比較
活用シーン
- 01複雑なルールに合わせた集約境界の設定
- 02モデルで制約と値セマンティクスを表現
- 03ドメインイベントと副作用処理の計画
- 04永続化から分離したリポジトリインターフェースの設計
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ wshaddix/ dotnet-skills --skill dotnet-domain-modelingインストール後、Claude Code で/dotnet-domain-modelingを入力して読み込まれたか確認します。
詳細
dotnet-domain-modeling
C# でのドメイン駆動設計の戦術的パターンを扱います。集約ルート、エンティティ、値オブジェクト、ドメインイベント、統合イベント、ドメインサービス、リポジトリコントラクトの設計、およびリッチドメインモデルと貧血ドメインモデルの違いを取り上げます。これらのパターンは、永続化技術に依存しない、ビジネスルールをカプセル化する純粋な C# モデルであるドメイン層自体に適用されます。
対象外: EF Core の構成と集約の永続化マッピングについては、[skill:dotnet-efcore-architecture] を参照してください。EF Core の戦術的な使用法(DbContext のライフサイクル、マイグレーション、インターセプター)については、[skill:dotnet-efcore-patterns] を参照してください。API 境界での入力検証については、[skill:dotnet-validation-patterns] を参照してください。EF Core、Dapper、ADO.NET の選択については、[skill:dotnet-data-access-strategy] を参照してください。垂直スライスアーキテクチャとリクエストパイプラインのパターンについては、[skill:dotnet-architecture-patterns] を参照してください。メッセージング基盤とサーガのオーケストレーションについては、[skill:dotnet-messaging-patterns] を参照してください。
相互参照:[skill:dotnet-efcore-architecture] では EF Core を使った集約の永続化とリポジトリの実装、[skill:dotnet-efcore-patterns] では DbContext の構成とマイグレーション、[skill:dotnet-architecture-patterns] では垂直スライスとリクエストパイプラインの設計、[skill:dotnet-validation-patterns] では入力検証のパターン、[skill:dotnet-messaging-patterns] では統合イベントの基盤を説明しています。
集約ルートとエンティティ
集約とは、データ変更において単一の単位として扱われるドメインオブジェクトのまとまりです。集約ルートはその入り口であり、集約に対するすべての変更は集約ルートを経由します。
エンティティの基底クラス
エンティティは、状態が変わっても維持される識別子を持ちます。識別子と等価性を標準化するために、基底クラスを使用します。
public abstract class Entity<TId> : IEquatable<Entity<TId>>
where TId : notnull
{
// default! required for ORM hydration; Id is set immediately after construction
public TId Id { get; protected set; } = default!;
protected Entity() { } // Required for ORM hydration
protected Entity(TId id) => Id = id;
public override bool Equals(object? obj) =>
obj is Entity<TId> other && Equals(other);
public bool Equals(Entity<TId>? other) =>
other is not null
&& GetType() == other.GetType()
&& EqualityComparer<TId>.Default.Equals(Id, other.Id);
public override int GetHashCode() =>
EqualityComparer<TId>.Default.GetHashCode(Id);
public static bool operator ==(Entity<TId>? left, Entity<TId>? right) =>
Equals(left, right);
public static bool operator !=(Entity<TId>? left, Entity<TId>? right) =>
!Equals(left, right);
}
集約ルートの基底クラス
集約ルートは Entity を継承し、ドメインイベントを収集します。
public abstract class AggregateRoot<TId> : Entity<TId>
where TId : notnull
{
private readonly List<IDomainEvent> _domainEvents = [];
public IReadOnlyList<IDomainEvent> DomainEvents =>
_domainEvents.AsReadOnly();
protected AggregateRoot() { }
protected AggregateRoot(TId id) : base(id) { }
protected void RaiseDomainEvent(IDomainEvent domainEvent) =>
_domainEvents.Add(domainEvent);
public void ClearDomainEvents() => _domainEvents.Clear();
}
具体的な集約の例
public sealed class Order : AggregateRoot<Guid>
{
public CustomerId CustomerId { get; private set; } = default!;
public OrderStatus Status { get; private set; }
public Money Total { get; private set; } = Money.Zero("USD");
private readonly List<OrderLine> _lines = [];
public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();
private Order() { } // ORM constructor
public static Order Create(CustomerId customerId)
{
var order = new Order(Guid.NewGuid())
{
CustomerId = customerId,
Status = OrderStatus.Draft
};
order.RaiseDomainEvent(new OrderCreated(order.Id, customerId));
return order;
}
public void AddLine(ProductId productId, int quantity, Money unitPrice)
{
if (Status != OrderStatus.Draft)
throw new DomainException("Cannot modify a non-draft order.");
if (quantity <= 0)
throw new DomainException("Quantity must be positive.");
var line = new OrderLine(productId, quantity, unitPrice);
_lines.Add(line);
RecalculateTotal();
}
public void Submit()
{
if (Status != OrderStatus.Draft)
throw new DomainException("Only draft orders can be submitted.");
if (_lines.Count == 0)
throw new DomainException("Cannot submit an empty order.");
Status = OrderStatus.Submitted;
RaiseDomainEvent(new OrderSubmitted(Id, Total));
}
private void RecalculateTotal() =>
Total = _lines.Aggregate(
Money.Zero(Total.Currency),
(sum, line) => sum.Add(line.LineTotal));
}
集約の設計ルール
| ルール | 理由 |
|---|---|
| すべての変更は集約ルートを経由する | 不変条件を一箇所で守ります |
| 他の集約は ID のみで参照する | 集約間の結合を防ぎます。Customer ではなく CustomerId を使用します |
| 集約は小さく保つ | 大きな集約はロック競合を引き起こし、読み込みを遅くします |
| 1 トランザクションにつき 1 集約とする | 集約をまたぐ変更にはドメインイベントと結果整合性を使用します |
コレクションは IReadOnlyList<T> として公開する | 外部コードがルートのメソッドを迂回して子要素を変更するのを防ぎます |
これらのルールが EF Core の永続化に及ぼす影響(ナビゲーションプロパティ、所有型、カスケード動作)については、[skill:dotnet-efcore-architecture] を参照してください。
値オブジェクト
値オブジェクトは識別子を持たず、属性値によって定義されます。同じ属性を持つ値オブジェクトは等価です。C# では、record と record struct によって自然な値セマンティクスを実現できます。
Record ベースの値オブジェクト
// Simple value object -- wraps a primitive to enforce constraints
public sealed record CustomerId
{
public string Value { get; }
public CustomerId(string value)
{
if (string.IsNullOrWhiteSpace(value))
throw new DomainException("Customer ID cannot be empty.");
Value = value;
}
public override string ToString() => Value;
}
// Composite value object -- multiple properties with validation
public sealed record Address
{
public string Street { get; }
public string City { get; }
public string State { get; }
public string PostalCode { get; }
public string Country { get; }
public Address(string street, string city, string state,
string postalCode, string country)
{
if (string.IsNullOrWhiteSpace(street))
throw new DomainException("Street is required.");
if (string.IsNullOrWhiteSpace(city))
throw new DomainException("City is required.");
if (string.IsNullOrWhiteSpace(postalCode))
throw new DomainException("Postal code is required.");
Street = street;
City = city;
State = state;
PostalCode = postalCode;
Country = country;
}
}
Money 値オブジェクト
Money は、振る舞いを備えた複数フィールドの値オブジェクトの典型例です。
public sealed record Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
if (string.IsNullOrWhiteSpace(currency))
throw new DomainException("Currency is required.");
Amount = amount;
Currency = currency.ToUpperInvariant();
}
public static Money Zero(string currency) => new(0m, currency);
public Money Add(Money other)
{
EnsureSameCurrency(other);
return new Money(Amount + other.Amount, Currency);
}
public Money Subtract(Money other)
{
EnsureSameCurrency(other);
return new Money(Amount - other.Amount, Currency);
}
public Money Multiply(int quantity) =>
new(Amount * quantity, Currency);
public Money Multiply(decimal factor) =>
new(Amount * factor, Currency);
private void EnsureSameCurrency(Money other)
{
if (Currency != other.Currency)
throw new DomainException(
$"Cannot operate on {Currency} and {other.Currency}.");
}
public override string ToString() => $"{Amount:F2} {Currency}";
}
値オブジェクトの EF Core マッピング
値オブジェクトは所有型または値変換を使ってマッピングします(実装については [skill:dotnet-efcore-architecture] を参照してください)。
// Owned type -- maps to columns in the parent table
builder.OwnsOne(o => o.Total, money =>
{
money.Property(m => m.Amount).HasColumnName("TotalAmount");
money.Property(m => m.Currency).HasColumnName("TotalCurrency")
.HasMaxLength(3);
});
// Value conversion -- single-property value objects
builder.Property(o => o.CustomerId)
.HasConversion(
id => id.Value,
value => new CustomerId(value))
.HasMaxLength(50);
値オブジェクトを使用する場面
| 値オブジェクトを使用する | プリミティブを使用する |
|---|---|
| 制約のあるドメイン概念(メールアドレス、金額、数量) | ドメインルールのないインフラストラクチャ ID(相関 ID、トレース ID) |
| 単位を成す複数のプロパティ(住所、日付範囲) | 検証が不要な単一の値 |
| ドメインメソッドでプリミティブ執着を防ぐ必要がある | API 境界の単純な DTO フィールド |
ドメインイベント
ドメインイベントは、ドメインで発生した意味のある出来事を表します。集約間の疎結合を実現し、副作用(メールの送信、読み取りモデルの更新、統合イベントの発行)を引き起こします。
イベントコントラクト
// Marker interface for all domain events
public interface IDomainEvent
{
Guid EventId { get; }
DateTimeOffset OccurredAt { get; }
}
// Base record for convenience
public abstract record DomainEventBase : IDomainEvent
{
public Guid EventId { get; } = Guid.NewGuid();
public DateTimeOffset OccurredAt { get; } = DateTimeOffset.UtcNow;
}
// Concrete events
public sealed record OrderCreated(
Guid OrderId, CustomerId CustomerId) : DomainEventBase;
public sealed record OrderSubmitted(
Guid OrderId, Money Total) : DomainEventBase;
public sealed record OrderCancelled(
Guid OrderId, string Reason) : DomainEventBase;
ドメインイベントのディスパッチ
副作用が実行される前に集約の状態が永続化されるよう、SaveChangesAsync の成功後にイベントをディスパッチします。
public sealed class DomainEventDispatcher(
IServiceProvider serviceProvider)
{
public async Task DispatchAsync(
IEnumerable<IDomainEvent> events,
CancellationToken ct)
{
foreach (var domainEvent in events)
{
var handlerType = typeof(IDomainEventHandler<>)
.MakeGenericType(domainEvent.GetType());
var handlers = serviceProvider.GetServices(handlerType);
foreach (var handler in handlers)
{
await ((dynamic)handler).HandleAsync(
(dynamic)domainEvent, ct);
}
}
}
}
// Note: The (dynamic) dispatch pattern is simple but not AOT-compatible.
// For Native AOT scenarios, use a source-generated or dictionary-based
// dispatcher. See [skill:dotnet-native-aot] for AOT constraints.
// Handler interface
public interface IDomainEventHandler<in TEvent>
where TEvent : IDomainEvent
{
Task HandleAsync(TEvent domainEvent, CancellationToken ct);
}
イベントをディスパッチする保存処理
保存後にイベントをディスパッチするには、EF Core の SaveChangesInterceptor またはラッパーを使用します。
public sealed class EventDispatchingSaveChangesInterceptor(
DomainEventDispatcher dispatcher)
: SaveChangesInterceptor
{
public override async ValueTask<int> SavedChangesAsync(
SaveChangesCompletedEventData eventData,
int result,
CancellationToken ct)
{
if (eventData.Context is not null)
{
var aggregates = eventData.Context.ChangeTracker
.Entries<AggregateRoot<Guid>>()
.Where(e => e.Entity.DomainEvents.Count > 0)
.Select(e => e.Entity)
.ToList();
var events = aggregates
.SelectMany(a => a.DomainEvents)
.ToList();
foreach (var aggregate in aggregates)
{
aggregate.ClearDomainEvents();
}
await dispatcher.DispatchAsync(events, ct);
}
return result;
}
}
ドメインイベントと統合イベント
| 項目 | ドメインイベント | 統合イベント |
|---|---|---|
| 範囲 | 境界づけられたコンテキスト内 | 境界づけられたコンテキスト間/サービス間 |
| トランスポート | プロセス内(ディスパッチャー) | メッセージブローカー(Service Bus、RabbitMQ) |
| 結合度 | ドメイン型を参照 | プリミティブ型/DTO 型のみを使用 |
| 信頼性 | 同一のトランザクション範囲 | 冪等性のあるコンシューマーによる少なくとも 1 回の配信 |
| 例 | OrderSubmitted(メールハンドラーを起動) | OrderSubmittedIntegration(配送サービスに通知) |
ドメインイベントハンドラーは、統合イベントをメッセージブローカーに公開できます。統合イベントのインフラストラクチャについては、[skill:dotnet-messaging-patterns] を参照してください。
// Domain event handler that publishes an integration event
public sealed class OrderSubmittedHandler(
IPublishEndpoint publishEndpoint)
: IDomainEventHandler<OrderSubmitted>
{
public async Task HandleAsync(
OrderSubmitted domainEvent, CancellationToken ct)
{
// Map domain event to integration event (no domain types)
await publishEndpoint.Publish(
new OrderSubmittedIntegration(
domainEvent.OrderId,
domainEvent.Total.Amount,
domainEvent.Total.Currency),
ct);
}
}
リッチドメインモデルと貧血ドメインモデル
リッチドメインモデル
ビジネスロジックはドメインエンティティ内に置きます。メソッドで不変条件を適用し、意味のある結果を返します。
public sealed class ShoppingCart : AggregateRoot<Guid>
{
private readonly List<CartItem> _items = [];
public IReadOnlyList<CartItem> Items => _items.AsReadOnly();
public void AddItem(ProductId productId, int quantity, Money unitPrice)
{
var existing = _items.Find(i => i.ProductId == productId);
if (existing is not null)
{
existing.IncreaseQuantity(quantity);
}
else
{
_items.Add(new CartItem(productId, quantity, unitPrice));
}
}
public void RemoveItem(ProductId productId)
{
var item = _items.Find(i => i.ProductId == productId)
?? throw new DomainException(
$"Product {productId} not in cart.");
_items.Remove(item);
}
public Money GetTotal(string currency) =>
_items.Aggregate(
Money.Zero(currency),
(sum, item) => sum.Add(item.LineTotal));
}
貧血ドメインモデル(アンチパターン)
エンティティはパブリックセッターを持つデータの入れ物であり、ビジネスロジックは外部サービスに置かれます。
// ANTI-PATTERN: Entity is just a data container
public class ShoppingCart
{
public Guid Id { get; set; }
public List<CartItem> Items { get; set; } = [];
}
// All logic lives here -- the entity has no behavior
public class ShoppingCartService
{
public void AddItem(ShoppingCart cart, string productId,
int quantity, decimal unitPrice)
{
var existing = cart.Items.Find(i => i.ProductId == productId);
if (existing != null)
existing.Quantity += quantity;
else
cart.Items.Add(new CartItem { ... });
}
}
選択ガイド
| 要素 | リッチモデル | 貧血モデル |
|---|---|---|
| 複雑な不変条件 | エンティティ内で適用 | サービス間に分散 |
| テスト容易性 | エンティティの振る舞いを直接テスト | サービスとエンティティを一緒にテスト |
| 発見しやすさ | エンティティのメソッドで機能がわかる | 適切なサービスクラスを探す必要がある |
| 永続化との結合 | ORM に適したプライベートセッターが必要 | シンプルなプロパティマッピング |
| チームの慣れ | DDD の経験が必要 | 多くの開発者に馴染みがある |
推奨事項: 複雑なビジネスルールを持つ集約では、リッチモデルから始めてください。ドメインロジックが最小限の単純な CRUD エンティティ(参照データや構成レコードなど)では、貧血モデルも許容されます。
ドメインサービス
ドメインサービスは、単一のエンティティや値オブジェクトに自然に属さないビジネスロジックをカプセル化します。ドメイン型を操作し、集約をまたぐルールを適用します。
public sealed class PricingService
{
public Money CalculateDiscount(
Order order,
CustomerTier tier,
IReadOnlyList<PromotionRule> activePromotions)
{
var discount = Money.Zero(order.Total.Currency);
// Tier-based discount
discount = tier switch
{
CustomerTier.Gold => discount.Add(
order.Total.Multiply(0.10m)),
CustomerTier.Platinum => discount.Add(
order.Total.Multiply(0.15m)),
_ => discount
};
// Promotion-based discounts
foreach (var promo in activePromotions)
{
if (promo.AppliesTo(order))
{
discount = discount.Add(promo.Calculate(order));
}
}
return discount;
}
}
ドメインサービスを使用する場面
- 複数の集約からのデータが必要で、それらの集約同士を相互参照させるべきではないロジック
- どの単一のエンティティにも属さないビジネスルール(例:商品と顧客ランクをまたぐ価格設定)
- 外部ポリシーや構成がロジックを左右する場合(例:税額計算ルール)
ドメインサービスは純粋な状態を保つ必要があります。インフラストラクチャへの依存関係を持たせてはいけません。ロジックでデータベースや外部 API が必要な場合は、事前に読み込んだデータを使ってドメインサービスを呼び出すアプリケーションサービスに配置してください。
リポジトリのコントラクト
リポジトリのインターフェースはドメイン層に属し、集約の読み込みと保存のセマンティクスを表します。実装の詳細(EF Core、Dapper)はインフラストラクチャ層に配置します。
// Domain layer -- defines the contract
public interface IOrderRepository
{
Task<Order?> FindByIdAsync(Guid id, CancellationToken ct);
Task AddAsync(Order order, CancellationToken ct);
Task SaveChangesAsync(CancellationToken ct);
}
// Domain layer -- unit of work abstraction (optional)
public interface IUnitOfWork
{
Task<int> SaveChangesAsync(CancellationToken ct);
}
EF Core のリポジトリ実装については、[skill:dotnet-efcore-architecture] を参照してください。
リポジトリ設計のルール
| ルール | 理由 |
|---|---|
| 集約ルートごとにリポジトリを 1 つ設ける | 子エンティティにはルート経由でアクセスする |
IQueryable<T> を戻り値の型にしない | 永続化に関する懸念がドメインに漏れるのを防ぐ |
汎用的な IRepository<T> を使用しない | 集約固有の読み込みルールを表現できない |
| DTO ではなくドメイン型を返す | リポジトリはドメインのためのもの。読み取りモデルにはプロジェクションを使用する |
すべての非同期メソッドに CancellationToken を含める | 適切にキャンセルを伝播するために必要 |
ドメイン例外
不変条件違反を通知するには、ドメイン固有の例外を使用します。これにより、ドメインエラーとインフラストラクチャエラーを分離できます。
public class DomainException : Exception
{
public DomainException(string message) : base(message) { }
public DomainException(string message, Exception inner)
: base(message, inner) { }
}
// Specific domain exceptions for different invariant violations
public sealed class InsufficientStockException(
ProductId productId, int requested, int available)
: DomainException(
$"Insufficient stock for {productId}: " +
$"requested {requested}, available {available}")
{
public ProductId ProductId => productId;
public int Requested => requested;
public int Available => available;
}
API 境界でドメイン例外を HTTP レスポンスにマッピングします(例:DomainException を 422 Unprocessable Entity にマッピング)。HTTP ステータスコードなどのインフラストラクチャ上の懸念をドメイン層に持ち込まないでください。
エージェントでの注意点
- 集約のプロパティにパブリックセッターを公開しないでください -- すべての状態変更は、不変条件を適用する集約ルートのメソッドを通じて行う必要があります。プロパティには
private setまたはinitを使用してください。 - 集約ルート間にナビゲーションプロパティを作成しないでください -- エンティティのナビゲーションではなく、ID値オブジェクト(例:
CustomerId)で他の集約を参照してください。集約間のナビゲーションは、境界づけられたコンテキストの分離を損ないます。 - トランザクション内でドメインイベントをディスパッチしないでください --
SaveChangesAsyncが成功した後にディスパッチしてください。保存前にディスパッチすると、保存に失敗した場合でも副作用が発生します。 - 統合イベントでドメイン型を使用しないでください -- 統合イベントは境界づけられたコンテキストの境界を越えるため、プリミティブ型または DTO を使用する必要があります。ドメイン型の変更は、他のサービスに影響を与えます。
- 検証ロジックを API 層だけに実装しないでください -- ドメインの不変条件はドメインモデルに属します。API 検証([skill:dotnet-validation-patterns])は不正な形式の入力を検出し、ドメイン検証はビジネスルールを適用します。
- パブリックな
List<T>プロパティを持つ貧血エンティティを作成しないでください -- コレクションはIReadOnlyList<T>として公開し、ビジネスルールを適用する変更用メソッドを集約ルートに用意してください。 - インフラストラクチャサービスをドメインエンティティに注入しないでください -- エンティティは純粋な C# オブジェクトである必要があります。外部データを必要とするロジックにはドメインサービスを使用し、インフラストラクチャのオーケストレーションにはアプリケーションサービスを使用してください。