mcpField
D
dotnet-domain-modeling

dotnet-domain-modeling

github.com/wshaddix開発86536 回インストール35 週間前に更新
リポジトリ

dotnet-domain-modeling について

永続化技術に依存しない純粋なドメインモデルを対象とする、C# の戦術的なドメイン駆動設計ガイドです。集約、値オブジェクト、ドメインイベント、リポジトリ契約を扱い、リッチモデルと貧血モデルの違いも説明します。

主な機能

  1. 01集約ルートとエンティティの設計
  2. 02値オブジェクトによる概念の表現
  3. 03ドメインイベントの定義とディスパッチ
  4. 04ドメインイベントと統合イベントの区別
  5. 05ドメインサービスとリポジトリ契約の設計
  6. 06リッチモデルと貧血モデルの比較

活用シーン

  1. 01複雑なルールに合わせた集約境界の設定
  2. 02モデルで制約と値セマンティクスを表現
  3. 03ドメインイベントと副作用処理の計画
  4. 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 ステータスコードなどのインフラストラクチャ上の懸念をドメイン層に持ち込まないでください。


エージェントでの注意点

  1. 集約のプロパティにパブリックセッターを公開しないでください -- すべての状態変更は、不変条件を適用する集約ルートのメソッドを通じて行う必要があります。プロパティには private set または init を使用してください。
  2. 集約ルート間にナビゲーションプロパティを作成しないでください -- エンティティのナビゲーションではなく、ID値オブジェクト(例:CustomerId)で他の集約を参照してください。集約間のナビゲーションは、境界づけられたコンテキストの分離を損ないます。
  3. トランザクション内でドメインイベントをディスパッチしないでください -- SaveChangesAsync が成功した後にディスパッチしてください。保存前にディスパッチすると、保存に失敗した場合でも副作用が発生します。
  4. 統合イベントでドメイン型を使用しないでください -- 統合イベントは境界づけられたコンテキストの境界を越えるため、プリミティブ型または DTO を使用する必要があります。ドメイン型の変更は、他のサービスに影響を与えます。
  5. 検証ロジックを API 層だけに実装しないでください -- ドメインの不変条件はドメインモデルに属します。API 検証([skill:dotnet-validation-patterns])は不正な形式の入力を検出し、ドメイン検証はビジネスルールを適用します。
  6. パブリックな List<T> プロパティを持つ貧血エンティティを作成しないでください -- コレクションは IReadOnlyList<T> として公開し、ビジネスルールを適用する変更用メソッドを集約ルートに用意してください。
  7. インフラストラクチャサービスをドメインエンティティに注入しないでください -- エンティティは純粋な C# オブジェクトである必要があります。外部データを必要とするロジックにはドメインサービスを使用し、インフラストラクチャのオーケストレーションにはアプリケーションサービスを使用してください。

参考資料

コメント