mcpField
D
ddd

ddd

github.com/codewithmukesh開発7913.0k 回インストール12 週間前に更新
リポジトリ

ddd について

複雑なビジネスルールを扱う C# 開発者向けに、ドメイン駆動設計の戦術パターンを解説します。集約、値オブジェクト、ドメインイベントに加え、採用判断の指針も示します。

主な機能

  1. 01集約ルートと整合性境界
  2. 02不変値オブジェクト
  3. 03型付き ID
  4. 04副作用を分離するドメインイベント
  5. 05集約をまたぐドメインサービス
  6. 06アンチパターンと採用指針

活用シーン

  1. 01複雑なドメインの集約と不変条件の定義
  2. 02値オブジェクトによる検証・等価性ルールの表現
  3. 03ドメインイベントによる副作用の分離

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/codewithmukesh/dotnet-claude-kit --skill ddd

インストール後、Claude Code で/dddを入力して読み込まれたか確認します。

詳細

ドメイン駆動設計(DDD)

コア原則

  1. 集約が整合性の境界を定義します — 集約とは、データ変更において単一の単位として扱われる、エンティティと値オブジェクトのまとまりです。集約内のすべての不変条件は、単一のトランザクション内で保証されます。集約をまたぐ整合性は結果整合性になります。
  2. プリミティブ型より値オブジェクトを優先します — プリミティブ型への過度な依存を値オブジェクトに置き換えます。Money、EmailAddress、OrderNumber は単なる文字列ではなく、検証、等価性、振る舞いを備えています。不変の値オブジェクトには C# のレコードを使用します。
  3. ドメインイベントで副作用を分離します — ドメインで意味のあること(OrderPlaced、PaymentReceived)が起きたときは、ドメインイベントを発行します。副作用(メールの送信、読み取りモデルの更新、別の集約への通知)は、これらのイベントを購読します。集約は自身のルールに専念できます。
  4. 集約ルートが唯一のエントリーポイントです — 外部コードは、ルートエンティティを通じてのみ集約にアクセスします。子エンティティを単独で読み込んだり変更したりすることはありません。ルートは集約全体のすべての不変条件を保証します。
  5. リポジトリはエンティティではなく集約を永続化します — 集約ルートごとにリポジトリを1つ設けます。リポジトリは集約全体を単位として読み込み、保存します。子エンティティ用のリポジトリは設けません。Infrastructure の実装では内部的に DbContext を使用します。これは集約の境界に対する DDD の戦術的パターンであり、汎用的な CRUD ラッパーではありません。

パターン

集約ルート

集約ルートは子要素へのアクセスをすべて管理し、不変条件を保証します。

// Domain/Orders/Order.cs
public sealed class Order : AggregateRoot
{
    private readonly List<OrderLine> _lines = [];

    private Order() { } // EF Core

    public OrderNumber Number { get; private set; } = null!;
    public CustomerId CustomerId { get; private set; }
    public Money Total { get; private set; } = Money.Zero("USD");
    public OrderStatus Status { get; private set; }
    public DateTimeOffset PlacedAt { get; private set; }
    public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();

    public static Order Place(CustomerId customerId, OrderNumber number, DateTimeOffset now)
    {
        var order = new Order
        {
            Id = Guid.CreateVersion7(),
            CustomerId = customerId,
            Number = number,
            Status = OrderStatus.Placed,
            PlacedAt = now
        };

        order.RaiseDomainEvent(new OrderPlaced(order.Id, customerId, now));
        return order;
    }

    public Result AddLine(ProductId productId, int quantity, Money unitPrice)
    {
        if (Status is not OrderStatus.Placed)
            return Result.Failure("Cannot modify a confirmed or cancelled order");

        if (quantity <= 0)
            return Result.Failure("Quantity must be positive");

        var existing = _lines.FirstOrDefault(l => l.ProductId == productId);
        if (existing is not null)
        {
            existing.IncreaseQuantity(quantity);
        }
        else
        {
            _lines.Add(new OrderLine(productId, quantity, unitPrice));
        }

        RecalculateTotal();
        return Result.Success();
    }

    public Result Confirm()
    {
        if (Status is not OrderStatus.Placed)
            return Result.Failure("Only placed orders can be confirmed");

        if (_lines.Count == 0)
            return Result.Failure("Cannot confirm an order with no lines");

        Status = OrderStatus.Confirmed;
        RaiseDomainEvent(new OrderConfirmed(Id));
        return Result.Success();
    }

    private void RecalculateTotal()
    {
        Total = _lines.Aggregate(Money.Zero(Total.Currency), (sum, line) => sum + line.Subtotal);
    }
}
レコードとしての値オブジェクト

構造的等価性を持つ不変の値オブジェクトには、C# のレコードを使用します。

// Domain/Common/Money.cs
public sealed record Money
{
    public decimal Amount { get; }
    public string Currency { get; }

    public Money(decimal amount, string currency)
    {
        ArgumentOutOfRangeException.ThrowIfNegative(amount);
        ArgumentException.ThrowIfNullOrWhiteSpace(currency);

        Amount = amount;
        Currency = currency.ToUpperInvariant();
    }

    public static Money Zero(string currency) => new(0, currency);

    public static Money operator +(Money left, Money right)
    {
        if (left.Currency != right.Currency)
            throw new InvalidOperationException($"Cannot add {left.Currency} and {right.Currency}");
        return new Money(left.Amount + right.Amount, left.Currency);
    }
}

// Other value objects (EmailAddress, OrderNumber, etc.) follow the same pattern:
// sealed record, constructor validation, no public setters
EF Core コンバーターを使用した強く型付けされた ID

異なるエンティティの GUID を取り違えないようにします。

// Domain/Common/StronglyTypedId.cs
public readonly record struct CustomerId(Guid Value)
{
    public static CustomerId New() => new(Guid.CreateVersion7());
    public override string ToString() => Value.ToString();
}

public readonly record struct ProductId(Guid Value)
{
    public static ProductId New() => new(Guid.CreateVersion7());
}

public readonly record struct OrderNumber(string Value)
{
    public override string ToString() => Value;
}

// Infrastructure/Persistence/Configurations/OrderConfiguration.cs
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        builder.HasKey(o => o.Id);

        builder.Property(o => o.CustomerId)
            .HasConversion(id => id.Value, value => new CustomerId(value));

        builder.Property(o => o.Number)
            .HasConversion(n => n.Value, value => new OrderNumber(value))
            .HasMaxLength(50);

        builder.ComplexProperty(o => o.Total, money =>
        {
            money.Property(m => m.Amount).HasColumnName("Total").HasPrecision(18, 2);
            money.Property(m => m.Currency).HasColumnName("Currency").HasMaxLength(3);
        });

        builder.HasMany(o => o.Lines).WithOne().HasForeignKey("OrderId");
        builder.Navigation(o => o.Lines).AutoInclude();
    }
}
ドメインイベントのディスパッチ

集約内でイベントを発行し、SaveChangesAsync でディスパッチします。

// Domain/Common/AggregateRoot.cs
public abstract class AggregateRoot : Entity
{
    private readonly List<IDomainEvent> _domainEvents = [];

    public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();

    protected void RaiseDomainEvent(IDomainEvent domainEvent) => _domainEvents.Add(domainEvent);

    public void ClearDomainEvents() => _domainEvents.Clear();
}

// INotification comes from the MIT-licensed Mediator package (not MediatR —
// see the packages rule). Use a plain marker interface if you don't use a mediator.
public interface IDomainEvent : INotification
{
    DateTimeOffset OccurredAt { get; }
}

// Domain/Orders/Events/OrderPlaced.cs
public sealed record OrderPlaced(Guid OrderId, CustomerId CustomerId, DateTimeOffset PlacedAt) : IDomainEvent
{
    public DateTimeOffset OccurredAt => PlacedAt;
}

// Infrastructure/Persistence/AppDbContext.cs — publisher injected via primary constructor
public class AppDbContext(DbContextOptions<AppDbContext> options, IPublisher publisher)
    : DbContext(options)
{
    public override async Task<int> SaveChangesAsync(CancellationToken ct = default)
    {
        var aggregates = ChangeTracker.Entries<AggregateRoot>()
            .Where(e => e.Entity.DomainEvents.Count > 0)
            .Select(e => e.Entity)
            .ToList();

        var events = aggregates.SelectMany(a => a.DomainEvents).ToList();

        var result = await base.SaveChangesAsync(ct);

        foreach (var @event in events)
            await publisher.Publish(@event, ct);

        foreach (var aggregate in aggregates)
            aggregate.ClearDomainEvents();

        return result;
    }
}
ドメインサービス

単一の集約に属さないロジックに使用します。

// Domain/Orders/Services/PricingService.cs
// Coordinates logic across aggregates — takes domain interfaces, returns value objects
public sealed class PricingService(IDiscountPolicy discountPolicy)
{
    public Money CalculatePrice(ProductId productId, int quantity, Money unitPrice, CustomerId customerId)
    {
        var subtotal = new Money(unitPrice.Amount * quantity, unitPrice.Currency);
        var discount = discountPolicy.GetDiscount(customerId, productId, quantity);
        return new Money(subtotal.Amount * (1 - discount), subtotal.Currency);
    }
}

アンチパターン

大きすぎる集約
// BAD — Customer aggregate owns everything the customer touches
public class Customer : AggregateRoot
{
    public List<Order> Orders { get; } = [];        // should be separate aggregate
    public List<Payment> Payments { get; } = [];     // should be separate aggregate
    public List<Address> Addresses { get; } = [];    // might be OK as child
    public ShoppingCart Cart { get; set; }            // should be separate aggregate
}

// GOOD — small, focused aggregates linked by ID
public class Customer : AggregateRoot
{
    public CustomerName Name { get; private set; }
    public EmailAddress Email { get; private set; }
    // Orders, Payments, Cart are separate aggregates referencing CustomerId
}
集約内ロジックに対するドメインイベント
// BAD — using events for logic within the same aggregate
order.RaiseDomainEvent(new OrderLineAdded(line));
// Then a handler recalculates the total... but you're in the same aggregate!

// GOOD — just call the method directly within the aggregate
_lines.Add(line);
RecalculateTotal();  // private method, no event needed
ID を持つ値オブジェクト
// BAD — value object with an Id (it's an entity then!)
public record Address
{
    public Guid Id { get; init; }  // value objects don't have identity
    public string Street { get; init; }
}

// GOOD — value objects are defined by their attributes, not an Id
public record Address(string Street, string City, string PostalCode, string Country);
貧血型の集約
// BAD — aggregate is just a data bag, service does all the work
public class Order : AggregateRoot
{
    public OrderStatus Status { get; set; }  // public setter!
    public List<OrderLine> Lines { get; set; } = [];
}

// Service directly manipulates order state
order.Status = OrderStatus.Confirmed;  // no invariant check!
order.Lines.Add(newLine);              // no validation!

// GOOD — aggregate encapsulates rules (see Aggregate Root pattern above)
order.Confirm();  // validates status, raises event
order.AddLine(productId, quantity, unitPrice);  // validates, recalculates

判断ガイド

シナリオ推奨事項
DDD を使用する場合CRUD の範囲を超えるビジネスルールを持つ複雑なドメイン
値オブジェクトを使用する場合ID ではなく属性に基づく等価性や検証ルールを持つあらゆる概念
集約のサイズ小さく保ちます。通常はルートエンティティ1つと子エンティティ0~3つです。毎回、集約全体を読み込みます
ドメインイベントと統合イベントの違いドメインイベント:境界づけられたコンテキスト内、同一トランザクション。統合イベント:メッセージバスを介したコンテキスト間の連携
強く型付けされた ID境界を越える集約ルートの ID には常に使用します。子エンティティの ID では任意です
DDD を使用しない場合シンプルな CRUD、設定、監査ログ、読み取りモデルには、通常のエンティティを使用します
リポジトリと DbContext の使い分け複雑な集約では集約ルートごとにリポジトリを設けます。より単純なクエリでは IAppDbContext を使用します
ドメインサービスロジックが複数の集約を必要とする場合、または集約が知るべきでない外部データを必要とする場合に限り使用します

コメント