mcpField
D
dotnet-domain-entity-generator

dotnet-domain-entity-generator

github.com/ronnythedev開発7762 回インストール15 週間前に更新
リポジトリ

dotnet-domain-entity-generator について

.NETで集約とドメイン動作をモデル化する開発者向けの、DDDパターンに基づくドメインエンティティ生成スキルです。カプセル化、検証付きファクトリ、ドメインイベント、値オブジェクト、型付きエラーを扱います。

主な機能

  1. 01集約ルートと子エンティティのテンプレート
  2. 02検証付きファクトリメソッド
  3. 03状態変更を示すドメインイベント
  4. 04不変な値オブジェクトのパターン
  5. 05型付きエラーとResult処理

活用シーン

  1. 01.NETでドメイン集約をモデル化
  2. 02検証済みの概念を値オブジェクトで表現
  3. 03ドメインイベントでエンティティ変更を通知

インストール

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

$npx skills add https://github.com/ronnythedev/dotnet-clean-architecture-skills --skill dotnet-domain-entity-generator

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

詳細

ドメインエンティティジェネレーター

概要

このスキルは、ドメイン駆動設計(DDD)の原則に従ってドメインエンティティを生成します。

  • カプセル化 - プライベートセッターによる変更の制御
  • ファクトリーメソッド - 検証を行う静的な Create() メソッド
  • ドメインイベント - 状態が変化したときにイベントを発生させます
  • リッチドメインモデル - 振る舞いはサービスではなくエンティティに持たせます
  • 不変条件の保護 - エンティティは常に有効な状態を維持します

クイックリファレンス

概念目的例
集約ルート集約のエントリーポイントOrganization、User
子エンティティ集約の一部であり、単独では独自の ID を持たないOrderItem、AssessmentDetail
値オブジェクト不変で、ID を持たないEmail、Money、Address
ドメインイベント状態の変化を通知しますUserCreatedDomainEvent

エンティティの構造

/Domain/{Aggregate}/
├── {Entity}.cs                    # Main entity
├── {Entity}Errors.cs              # Typed errors
├── I{Entity}Repository.cs         # Repository interface
├── ValueObjects/
│   ├── {ValueObject}.cs
│   └── ...
└── Events/
    ├── {Entity}CreatedDomainEvent.cs
    ├── {Entity}UpdatedDomainEvent.cs
    └── ...

テンプレート:集約ルートエンティティ

// src/{name}.domain/{Aggregate}/{Entity}.cs
using {name}.domain.abstractions;
using {name}.domain.{aggregate}.events;

namespace {name}.domain.{aggregate};

public sealed class {Entity} : Entity
{
    // ═══════════════════════════════════════════════════════════════
    // PRIVATE COLLECTIONS (encapsulated)
    // ═══════════════════════════════════════════════════════════════
    private readonly List<{ChildEntity}> _{childEntities} = new();

    // ═══════════════════════════════════════════════════════════════
    // PROPERTIES (private setters)
    // ═══════════════════════════════════════════════════════════════
    public string Name { get; private set; }
    public string? Description { get; private set; }
    public bool IsActive { get; private set; }
    public DateTime CreatedAt { get; private set; }
    public DateTime UpdatedAt { get; private set; }
    
    // Navigation property (read-only collection)
    public IReadOnlyCollection<{ChildEntity}> {ChildEntities} => _{childEntities}.AsReadOnly();

    // ═══════════════════════════════════════════════════════════════
    // CONSTRUCTORS
    // ═══════════════════════════════════════════════════════════════
    
    // Private constructor for EF Core
    private {Entity}() { }

    // Private constructor for factory method
    private {Entity}(
        Guid id,
        string name,
        string? description,
        DateTime createdAt)
        : base(id)
    {
        Name = name;
        Description = description;
        IsActive = true;
        CreatedAt = createdAt;
        UpdatedAt = createdAt;
    }

    // ═══════════════════════════════════════════════════════════════
    // FACTORY METHODS
    // ═══════════════════════════════════════════════════════════════
    
    /// <summary>
    /// Creates a new {Entity} with validation
    /// </summary>
    public static Result<{Entity}> Create(
        string name,
        string? description,
        DateTime createdAt)
    {
        // Validate invariants
        if (string.IsNullOrWhiteSpace(name))
        {
            return Result.Failure<{Entity}>({Entity}Errors.NameIsRequired);
        }

        if (name.Length > 100)
        {
            return Result.Failure<{Entity}>({Entity}Errors.NameTooLong);
        }

        var {entity} = new {Entity}(
            Guid.NewGuid(),
            name,
            description,
            createdAt);

        // Raise domain event
        {entity}.RaiseDomainEvent(new {Entity}CreatedDomainEvent({entity}.Id));

        return {entity};
    }

    // ═══════════════════════════════════════════════════════════════
    // DOMAIN METHODS
    // ═══════════════════════════════════════════════════════════════
    
    /// <summary>
    /// Updates the {Entity} properties
    /// </summary>
    public Result Update(
        string name,
        string? description,
        DateTime updatedAt)
    {
        if (string.IsNullOrWhiteSpace(name))
        {
            return Result.Failure({Entity}Errors.NameIsRequired);
        }

        if (name.Length > 100)
        {
            return Result.Failure({Entity}Errors.NameTooLong);
        }

        Name = name;
        Description = description;
        UpdatedAt = updatedAt;

        RaiseDomainEvent(new {Entity}UpdatedDomainEvent(Id));

        return Result.Success();
    }

    /// <summary>
    /// Deactivates the {Entity}
    /// </summary>
    public Result Deactivate(DateTime updatedAt)
    {
        if (!IsActive)
        {
            return Result.Failure({Entity}Errors.AlreadyDeactivated);
        }

        IsActive = false;
        UpdatedAt = updatedAt;

        RaiseDomainEvent(new {Entity}DeactivatedDomainEvent(Id));

        return Result.Success();
    }

    /// <summary>
    /// Reactivates the {Entity}
    /// </summary>
    public Result Activate(DateTime updatedAt)
    {
        if (IsActive)
        {
            return Result.Failure({Entity}Errors.AlreadyActive);
        }

        IsActive = true;
        UpdatedAt = updatedAt;

        return Result.Success();
    }

    // ═══════════════════════════════════════════════════════════════
    // CHILD ENTITY MANAGEMENT
    // ═══════════════════════════════════════════════════════════════
    
    /// <summary>
    /// Adds a child entity to this aggregate
    /// </summary>
    public Result Add{ChildEntity}({ChildEntity} {childEntity})
    {
        if ({childEntity} is null)
        {
            return Result.Failure({Entity}Errors.Child{ChildEntity}Required);
        }

        if (_{childEntities}.Any(c => c.Name == {childEntity}.Name))
        {
            return Result.Failure({Entity}Errors.Duplicate{ChildEntity}Name);
        }

        _{childEntities}.Add({childEntity});

        RaiseDomainEvent(new {ChildEntity}AddedDomainEvent(Id, {childEntity}.Id));

        return Result.Success();
    }

    /// <summary>
    /// Removes a child entity from this aggregate
    /// </summary>
    public Result Remove{ChildEntity}(Guid {childEntity}Id)
    {
        var {childEntity} = _{childEntities}.FirstOrDefault(c => c.Id == {childEntity}Id);

        if ({childEntity} is null)
        {
            return Result.Failure({Entity}Errors.{ChildEntity}NotFound);
        }

        _{childEntities}.Remove({childEntity});

        return Result.Success();
    }

    // ═══════════════════════════════════════════════════════════════
    // QUERY METHODS
    // ═══════════════════════════════════════════════════════════════
    
    public bool HasActiveChildren() => _{childEntities}.Any(c => c.IsActive);

    public {ChildEntity}? GetChildById(Guid childId) => 
        _{childEntities}.FirstOrDefault(c => c.Id == childId);
}

テンプレート:子エンティティ(集約の一部)

// src/{name}.domain/{Aggregate}/{ChildEntity}.cs
using {name}.domain.abstractions;

namespace {name}.domain.{aggregate};

public sealed class {ChildEntity} : Entity
{
    // ═══════════════════════════════════════════════════════════════
    // PROPERTIES
    // ═══════════════════════════════════════════════════════════════
    public Guid {Parent}Id { get; private set; }
    public string Name { get; private set; }
    public string? Description { get; private set; }
    public int SortOrder { get; private set; }
    public bool IsActive { get; private set; }
    public DateTime CreatedAt { get; private set; }
    public DateTime UpdatedAt { get; private set; }

    // Navigation property
    public {Parent} {Parent} { get; private set; } = null!;

    // ═══════════════════════════════════════════════════════════════
    // CONSTRUCTORS
    // ═══════════════════════════════════════════════════════════════
    
    private {ChildEntity}() { } // EF Core

    private {ChildEntity}(
        Guid id,
        Guid {parent}Id,
        string name,
        string? description,
        int sortOrder,
        DateTime createdAt)
        : base(id)
    {
        {Parent}Id = {parent}Id;
        Name = name;
        Description = description;
        SortOrder = sortOrder;
        IsActive = true;
        CreatedAt = createdAt;
        UpdatedAt = createdAt;
    }

    // ═══════════════════════════════════════════════════════════════
    // FACTORY METHOD
    // ═══════════════════════════════════════════════════════════════
    
    public static {ChildEntity} Create(
        Guid {parent}Id,
        string name,
        string? description,
        int sortOrder,
        DateTime createdAt)
    {
        return new {ChildEntity}(
            Guid.NewGuid(),
            {parent}Id,
            name,
            description,
            sortOrder,
            createdAt);
    }

    // ═══════════════════════════════════════════════════════════════
    // DOMAIN METHODS
    // ═══════════════════════════════════════════════════════════════
    
    public void Update(
        string name,
        string? description,
        int sortOrder,
        DateTime updatedAt)
    {
        Name = name;
        Description = description;
        SortOrder = sortOrder;
        UpdatedAt = updatedAt;
    }

    public void Deactivate(DateTime updatedAt)
    {
        IsActive = false;
        UpdatedAt = updatedAt;
    }
}

テンプレート:値オブジェクト

// src/{name}.domain/{Aggregate}/ValueObjects/Email.cs
namespace {name}.domain.{aggregate}.valueobjects;

public sealed record Email
{
    public string Value { get; }

    private Email(string value)
    {
        Value = value;
    }

    public static Result<Email> Create(string email)
    {
        if (string.IsNullOrWhiteSpace(email))
        {
            return Result.Failure<Email>(EmailErrors.Empty);
        }

        email = email.Trim().ToLowerInvariant();

        if (email.Length > 255)
        {
            return Result.Failure<Email>(EmailErrors.TooLong);
        }

        if (!IsValidFormat(email))
        {
            return Result.Failure<Email>(EmailErrors.InvalidFormat);
        }

        return new Email(email);
    }

    private static bool IsValidFormat(string email)
    {
        // Simple email validation
        var atIndex = email.IndexOf('@');
        var dotIndex = email.LastIndexOf('.');
        
        return atIndex > 0 
            && dotIndex > atIndex + 1 
            && dotIndex < email.Length - 1;
    }

    public override string ToString() => Value;

    // Implicit conversion for convenience
    public static implicit operator string(Email email) => email.Value;
}

public static class EmailErrors
{
    public static readonly Error Empty = new("Email.Empty", "Email cannot be empty");
    public static readonly Error TooLong = new("Email.TooLong", "Email cannot exceed 255 characters");
    public static readonly Error InvalidFormat = new("Email.InvalidFormat", "Email format is invalid");
}
値オブジェクトのその他の例
// Money Value Object
public sealed record Money
{
    public decimal Amount { get; }
    public string Currency { get; }

    private Money(decimal amount, string currency)
    {
        Amount = amount;
        Currency = currency;
    }

    public static Result<Money> Create(decimal amount, string currency = "USD")
    {
        if (amount < 0)
            return Result.Failure<Money>(MoneyErrors.NegativeAmount);

        if (string.IsNullOrWhiteSpace(currency) || currency.Length != 3)
            return Result.Failure<Money>(MoneyErrors.InvalidCurrency);

        return new Money(Math.Round(amount, 2), currency.ToUpperInvariant());
    }

    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw new InvalidOperationException("Cannot add different currencies");

        return new Money(Amount + other.Amount, Currency);
    }

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

// DateRange Value Object
public sealed record DateRange
{
    public DateTime Start { get; }
    public DateTime End { get; }

    private DateRange(DateTime start, DateTime end)
    {
        Start = start;
        End = end;
    }

    public static Result<DateRange> Create(DateTime start, DateTime end)
    {
        if (end <= start)
            return Result.Failure<DateRange>(DateRangeErrors.EndMustBeAfterStart);

        return new DateRange(start, end);
    }

    public bool Contains(DateTime date) => date >= Start && date <= End;
    
    public bool Overlaps(DateRange other) => 
        Start < other.End && End > other.Start;

    public int DurationInDays => (End - Start).Days;
}

テンプレート:ドメインエラー

// src/{name}.domain/{Aggregate}/{Entity}Errors.cs
using {name}.domain.abstractions;

namespace {name}.domain.{aggregate};

public static class {Entity}Errors
{
    // Not found errors
    public static readonly Error NotFound = new(
        "{Entity}.NotFound",
        "The {entity} with the specified ID was not found");

    // Validation errors
    public static readonly Error NameIsRequired = new(
        "{Entity}.NameRequired",
        "{Entity} name is required");

    public static readonly Error NameTooLong = new(
        "{Entity}.NameTooLong",
        "{Entity} name cannot exceed 100 characters");

    // Business rule errors
    public static readonly Error AlreadyExists = new(
        "{Entity}.AlreadyExists",
        "A {entity} with this name already exists");

    public static readonly Error AlreadyDeactivated = new(
        "{Entity}.AlreadyDeactivated",
        "The {entity} is already deactivated");

    public static readonly Error AlreadyActive = new(
        "{Entity}.AlreadyActive",
        "The {entity} is already active");

    public static readonly Error CannotDeleteWithActiveRelationships = new(
        "{Entity}.CannotDeleteWithActiveRelationships",
        "Cannot delete {entity} with active relationships");

    // Child entity errors
    public static readonly Error {ChildEntity}NotFound = new(
        "{Entity}.{ChildEntity}NotFound",
        "The {childEntity} was not found in this {entity}");

    public static readonly Error Duplicate{ChildEntity}Name = new(
        "{Entity}.Duplicate{ChildEntity}Name",
        "A {childEntity} with this name already exists");

    public static readonly Error Child{ChildEntity}Required = new(
        "{Entity}.Child{ChildEntity}Required",
        "{ChildEntity} cannot be null");
}

テンプレート:ドメインイベント

// src/{name}.domain/{Aggregate}/Events/{Entity}CreatedDomainEvent.cs
using {name}.domain.abstractions;

namespace {name}.domain.{aggregate}.events;

public sealed record {Entity}CreatedDomainEvent(Guid {Entity}Id) : IDomainEvent;

// src/{name}.domain/{Aggregate}/Events/{Entity}UpdatedDomainEvent.cs
public sealed record {Entity}UpdatedDomainEvent(Guid {Entity}Id) : IDomainEvent;

// src/{name}.domain/{Aggregate}/Events/{Entity}DeactivatedDomainEvent.cs
public sealed record {Entity}DeactivatedDomainEvent(Guid {Entity}Id) : IDomainEvent;

// src/{name}.domain/{Aggregate}/Events/{ChildEntity}AddedDomainEvent.cs
public sealed record {ChildEntity}AddedDomainEvent(
    Guid {Entity}Id,
    Guid {ChildEntity}Id) : IDomainEvent;

テンプレート:リポジトリインターフェイス

// src/{name}.domain/{Aggregate}/I{Entity}Repository.cs
namespace {name}.domain.{aggregate};

public interface I{Entity}Repository
{
    // ═══════════════════════════════════════════════════════════════
    // READ OPERATIONS
    // ═══════════════════════════════════════════════════════════════
    
    Task<{Entity}?> GetByIdAsync(
        Guid id,
        CancellationToken cancellationToken = default);

    Task<{Entity}?> GetByNameAsync(
        string name,
        CancellationToken cancellationToken = default);

    Task<IReadOnlyList<{Entity}>> GetByOrganizationIdAsync(
        Guid organizationId,
        CancellationToken cancellationToken = default);

    Task<bool> ExistsAsync(
        Guid id,
        CancellationToken cancellationToken = default);

    // ═══════════════════════════════════════════════════════════════
    // WRITE OPERATIONS
    // ═══════════════════════════════════════════════════════════════
    
    void Add({Entity} {entity});

    void AddRange(IEnumerable<{Entity}> {entities});

    void Update({Entity} {entity});

    void Remove({Entity} {entity});
}

エンティティの基底クラス

// src/{name}.domain/Abstractions/Entity.cs
namespace {name}.domain.abstractions;

public abstract class Entity
{
    private readonly List<IDomainEvent> _domainEvents = new();

    protected Entity(Guid id)
    {
        Id = id;
    }

    protected Entity() { } // EF Core

    public Guid Id { get; init; }

    public IReadOnlyList<IDomainEvent> GetDomainEvents() => _domainEvents.ToList();

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

    protected void RaiseDomainEvent(IDomainEvent domainEvent)
    {
        _domainEvents.Add(domainEvent);
    }

    public override bool Equals(object? obj)
    {
        if (obj is not Entity other)
            return false;

        if (ReferenceEquals(this, other))
            return true;

        if (GetType() != other.GetType())
            return false;

        if (Id == Guid.Empty || other.Id == Guid.Empty)
            return false;

        return Id == other.Id;
    }

    public static bool operator ==(Entity? left, Entity? right)
    {
        if (left is null && right is null)
            return true;

        if (left is null || right is null)
            return false;

        return left.Equals(right);
    }

    public static bool operator !=(Entity? left, Entity? right) => !(left == right);

    public override int GetHashCode() => Id.GetHashCode() * 41;
}

DDD の重要なルール

  1. セッターは常にプライベートにする - 外部からプロパティを直接変更できないようにします
  2. 作成にはファクトリーメソッドを使う - 検証を行う静的な Create() メソッドを使います
  3. 状態の変化にはドメインイベントを使う - 重要な変化を通知します
  4. エンティティは常に有効な状態にする - コンストラクターとメソッドで不変条件を保護します
  5. 集約ルートが子を管理する - 子エンティティはルートを通じて管理します
  6. 値オブジェクトは不変にする - record 型を使います
  7. リポジトリは集約ルートごとに用意する - エンティティごとには用意しません
  8. セッターにロジックを持たせない - 名前付きメソッドを使います
  9. Result パターンを使う - 例外をスローせず、エラーを返します
  10. エンティティを永続化技術から独立させる - ドメインに EF Core の属性を付けません

避けるべきアンチパターン

// ❌ WRONG: Public setters
public string Name { get; set; }

// ✅ CORRECT: Private setters
public string Name { get; private set; }

// ❌ WRONG: Constructor with all parameters
public User(Guid id, string name, string email, DateTime createdAt, ...)

// ✅ CORRECT: Factory method
public static Result<User> Create(string name, string email, DateTime createdAt)

// ❌ WRONG: Throwing exceptions
if (name == null) throw new ArgumentNullException(nameof(name));

// ✅ CORRECT: Return Result
if (string.IsNullOrWhiteSpace(name))
    return Result.Failure<Entity>(EntityErrors.NameRequired);

// ❌ WRONG: Anemic domain model
public class User
{
    public string Name { get; set; }
    public void SetName(string name) => Name = name; // Just a setter!
}

// ✅ CORRECT: Rich domain model with behavior
public class User
{
    public string Name { get; private set; }
    
    public Result ChangeName(string newName, DateTime updatedAt)
    {
        if (string.IsNullOrWhiteSpace(newName))
            return Result.Failure(UserErrors.NameRequired);
        
        Name = newName;
        UpdatedAt = updatedAt;
        RaiseDomainEvent(new UserNameChangedDomainEvent(Id, newName));
        return Result.Success();
    }
}

// ❌ WRONG: Exposing internal collections
public List<OrderItem> Items { get; set; } = new();

// ✅ CORRECT: Encapsulated collections
private readonly List<OrderItem> _items = new();
public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();

関連スキル

  • dotnet-repository-pattern - リポジトリを実装します
  • dotnet-ef-core-configuration - エンティティをデータベースにマッピングします
  • dotnet-domain-events-generator - ドメインイベントを処理します
  • dotnet-result-pattern - エラーを処理します

コメント