mcpField
D
dotnet-domain-events-generator

dotnet-domain-events-generator

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

dotnet-domain-events-generator について

ドメインイベントを実装するための .NET ガイドです。イベント定義、エンティティからの発行、通知ハンドラー、Outbox による永続化を扱い、信頼性の高いイベント配信が必要なドメイン駆動設計のプロジェクト向けです。

主な機能

  1. 01ドメインイベントのインターフェースとレコードのテンプレート
  2. 02エンティティからのイベント発行
  3. 03通知ハンドラーの例
  4. 04Outbox の永続化と処理ジョブ
  5. 05イベント命名と冪等性のルール

活用シーン

  1. 01集約の状態変化をドメインイベントとして表現
  2. 02イベントに独立した副作用ハンドラーを追加
  3. 03Outbox パターンによる信頼性の高いイベント配信の設計

インストール

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

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

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

詳細

ドメインイベントジェネレーター

概要

ドメインイベントは、ドメイン内で発生した重要な出来事を記録します。

  • エンティティによって発行される - 状態が変更されたとき
  • 通知ハンドラーによって処理される - イベントに反応します
  • アウトボックスパターン - イベントを確実に配信します
  • 疎結合 - 発行側は購読者を認識しません

クイックリファレンス

コンポーネント目的場所
IDomainEventマーカーインターフェースDomain/Abstractions
{Entity}{Action}DomainEventイベントレコードDomain/{Aggregate}/Events
{Event}DomainEventHandlerイベントハンドラーApplication/{Feature}
OutboxMessage永続化されたイベントInfrastructure/Outbox

イベント構造

/Domain/
├── Abstractions/
│   └── IDomainEvent.cs
└── {Aggregate}/
    └── Events/
        ├── {Entity}CreatedDomainEvent.cs
        ├── {Entity}UpdatedDomainEvent.cs
        └── ...

/Application/
└── {Feature}/
    └── EventHandlers/
        ├── {Event}Handler.cs
        └── ...

/Infrastructure/
└── Outbox/
    ├── OutboxMessage.cs
    ├── OutboxMessageConfiguration.cs
    └── ProcessOutboxMessagesJob.cs

テンプレート:ドメインイベントインターフェース

// src/{name}.domain/Abstractions/IDomainEvent.cs
using MediatR;

namespace {name}.domain.abstractions;

/// <summary>
/// Marker interface for domain events.
/// Domain events represent something significant that happened in the domain.
/// </summary>
public interface IDomainEvent : INotification
{
    /// <summary>
    /// Unique identifier for this event instance
    /// </summary>
    Guid Id { get; }

    /// <summary>
    /// When the event occurred
    /// </summary>
    DateTime OccurredOnUtc { get; }
}

テンプレート:基本ドメインイベントレコード

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

/// <summary>
/// Base record for domain events with common properties
/// </summary>
public abstract record DomainEvent : IDomainEvent
{
    public Guid Id { get; init; } = Guid.NewGuid();
    public DateTime OccurredOnUtc { get; init; } = DateTime.UtcNow;
}

テンプレート:具体的なドメインイベント

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

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

/// <summary>
/// Raised when a new {Entity} is created
/// </summary>
public sealed record {Entity}CreatedDomainEvent(
    Guid {Entity}Id) : DomainEvent;

// src/{name}.domain/{Aggregate}/Events/{Entity}UpdatedDomainEvent.cs
/// <summary>
/// Raised when a {Entity} is updated
/// </summary>
public sealed record {Entity}UpdatedDomainEvent(
    Guid {Entity}Id,
    string PropertyName,
    string? OldValue,
    string? NewValue) : DomainEvent;

// src/{name}.domain/{Aggregate}/Events/{Entity}DeactivatedDomainEvent.cs
/// <summary>
/// Raised when a {Entity} is deactivated
/// </summary>
public sealed record {Entity}DeactivatedDomainEvent(
    Guid {Entity}Id,
    string Reason) : DomainEvent;

// src/{name}.domain/{Aggregate}/Events/{Entity}DeletedDomainEvent.cs
/// <summary>
/// Raised when a {Entity} is deleted
/// </summary>
public sealed record {Entity}DeletedDomainEvent(
    Guid {Entity}Id) : DomainEvent;

テンプレート:リッチドメインイベント

// src/{name}.domain/Users/Events/UserRegisteredDomainEvent.cs
using {name}.domain.abstractions;

namespace {name}.domain.users.events;

/// <summary>
/// Raised when a new user registers
/// </summary>
public sealed record UserRegisteredDomainEvent : DomainEvent
{
    public Guid UserId { get; init; }
    public string Email { get; init; } = string.Empty;
    public string Name { get; init; } = string.Empty;
    public Guid OrganizationId { get; init; }

    public UserRegisteredDomainEvent(
        Guid userId,
        string email,
        string name,
        Guid organizationId)
    {
        UserId = userId;
        Email = email;
        Name = name;
        OrganizationId = organizationId;
    }
}

// src/{name}.domain/Assessments/Events/AssessmentCompletedDomainEvent.cs
/// <summary>
/// Raised when a user completes an assessment
/// </summary>
public sealed record AssessmentCompletedDomainEvent : DomainEvent
{
    public Guid AssessmentId { get; init; }
    public Guid UserId { get; init; }
    public Guid OrganizationId { get; init; }
    public string AssessmentType { get; init; } = string.Empty;
    public decimal Score { get; init; }
    public DateTime CompletedAt { get; init; }

    public AssessmentCompletedDomainEvent(
        Guid assessmentId,
        Guid userId,
        Guid organizationId,
        string assessmentType,
        decimal score,
        DateTime completedAt)
    {
        AssessmentId = assessmentId;
        UserId = userId;
        OrganizationId = organizationId;
        AssessmentType = assessmentType;
        Score = score;
        CompletedAt = completedAt;
    }
}

テンプレート:エンティティでのイベント発行

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

namespace {name}.domain.{aggregate};

public sealed class {Entity} : Entity
{
    // ... properties

    private {Entity}(
        Guid id,
        string name,
        Guid organizationId,
        DateTime createdAt)
        : base(id)
    {
        Name = name;
        OrganizationId = organizationId;
        CreatedAt = createdAt;
    }

    /// <summary>
    /// Factory method - raises Created event
    /// </summary>
    public static Result<{Entity}> Create(
        string name,
        Guid organizationId,
        DateTime createdAt)
    {
        // Validation...

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

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

        return {entity};
    }

    /// <summary>
    /// Update method - raises Updated event
    /// </summary>
    public Result Update(string name, DateTime updatedAt)
    {
        if (string.IsNullOrWhiteSpace(name))
        {
            return Result.Failure({Entity}Errors.NameRequired);
        }

        var oldName = Name;
        Name = name;
        UpdatedAt = updatedAt;

        // Raise domain event with change details
        RaiseDomainEvent(new {Entity}UpdatedDomainEvent(
            Id,
            nameof(Name),
            oldName,
            name));

        return Result.Success();
    }

    /// <summary>
    /// Deactivate method - raises Deactivated event
    /// </summary>
    public Result Deactivate(string reason, DateTime deactivatedAt)
    {
        if (!IsActive)
        {
            return Result.Failure({Entity}Errors.AlreadyDeactivated);
        }

        IsActive = false;
        UpdatedAt = deactivatedAt;

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

        return Result.Success();
    }
}

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

// src/{name}.application/{Feature}/EventHandlers/{Entity}CreatedDomainEventHandler.cs
using MediatR;
using Microsoft.Extensions.Logging;
using {name}.domain.{aggregate}.events;

namespace {name}.application.{feature}.eventhandlers;

/// <summary>
/// Handles {Entity}CreatedDomainEvent
/// </summary>
internal sealed class {Entity}CreatedDomainEventHandler
    : INotificationHandler<{Entity}CreatedDomainEvent>
{
    private readonly ILogger<{Entity}CreatedDomainEventHandler> _logger;

    public {Entity}CreatedDomainEventHandler(
        ILogger<{Entity}CreatedDomainEventHandler> logger)
    {
        _logger = logger;
    }

    public Task Handle(
        {Entity}CreatedDomainEvent notification,
        CancellationToken cancellationToken)
    {
        _logger.LogInformation(
            "{Entity} created: {EntityId} at {OccurredOn}",
            notification.{Entity}Id,
            notification.OccurredOnUtc);

        // Add any side effects here:
        // - Send notifications
        // - Update read models
        // - Trigger workflows
        // - Publish to external systems

        return Task.CompletedTask;
    }
}

テンプレート:副作用を伴うイベントハンドラー

// src/{name}.application/Users/EventHandlers/UserRegisteredDomainEventHandler.cs
using MediatR;
using Microsoft.Extensions.Logging;
using {name}.application.abstractions.email;
using {name}.domain.users.events;

namespace {name}.application.users.eventhandlers;

/// <summary>
/// Sends welcome email when user registers
/// </summary>
internal sealed class UserRegisteredSendWelcomeEmailHandler
    : INotificationHandler<UserRegisteredDomainEvent>
{
    private readonly IEmailService _emailService;
    private readonly ILogger<UserRegisteredSendWelcomeEmailHandler> _logger;

    public UserRegisteredSendWelcomeEmailHandler(
        IEmailService emailService,
        ILogger<UserRegisteredSendWelcomeEmailHandler> logger)
    {
        _emailService = emailService;
        _logger = logger;
    }

    public async Task Handle(
        UserRegisteredDomainEvent notification,
        CancellationToken cancellationToken)
    {
        _logger.LogInformation(
            "Sending welcome email to user {UserId}",
            notification.UserId);

        await _emailService.SendWelcomeEmailAsync(
            notification.Email,
            notification.Name,
            cancellationToken);
    }
}

/// <summary>
/// Creates default settings when user registers
/// </summary>
internal sealed class UserRegisteredCreateDefaultSettingsHandler
    : INotificationHandler<UserRegisteredDomainEvent>
{
    private readonly IUserSettingsRepository _settingsRepository;
    private readonly IUnitOfWork _unitOfWork;

    public UserRegisteredCreateDefaultSettingsHandler(
        IUserSettingsRepository settingsRepository,
        IUnitOfWork unitOfWork)
    {
        _settingsRepository = settingsRepository;
        _unitOfWork = unitOfWork;
    }

    public async Task Handle(
        UserRegisteredDomainEvent notification,
        CancellationToken cancellationToken)
    {
        var settings = UserSettings.CreateDefault(notification.UserId);

        _settingsRepository.Add(settings);

        await _unitOfWork.SaveChangesAsync(cancellationToken);
    }
}

テンプレート:アウトボックスパターンの実装

アウトボックスメッセージエンティティ
// src/{name}.infrastructure/Outbox/OutboxMessage.cs
namespace {name}.infrastructure.outbox;

/// <summary>
/// Represents a domain event stored for reliable delivery
/// </summary>
public sealed class OutboxMessage
{
    public Guid Id { get; set; }
    public string Type { get; set; } = string.Empty;
    public string Content { get; set; } = string.Empty;
    public DateTime OccurredOnUtc { get; set; }
    public DateTime? ProcessedOnUtc { get; set; }
    public string? Error { get; set; }
}
アウトボックスの構成
// src/{name}.infrastructure/Configurations/OutboxMessageConfiguration.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using {name}.infrastructure.outbox;

namespace {name}.infrastructure.configurations;

internal sealed class OutboxMessageConfiguration
    : IEntityTypeConfiguration<OutboxMessage>
{
    public void Configure(EntityTypeBuilder<OutboxMessage> builder)
    {
        builder.ToTable("outbox_message");

        builder.HasKey(o => o.Id);

        builder.Property(o => o.Type)
            .HasMaxLength(500)
            .IsRequired();

        builder.Property(o => o.Content)
            .HasColumnType("jsonb")
            .IsRequired();

        builder.Property(o => o.OccurredOnUtc)
            .IsRequired();

        builder.Property(o => o.ProcessedOnUtc);

        builder.Property(o => o.Error)
            .HasColumnType("text");

        // Index for efficient querying of unprocessed messages
        builder.HasIndex(o => o.ProcessedOnUtc)
            .HasFilter("processed_on_utc IS NULL");
    }
}
DbContext でのアウトボックスへのイベント追加
// src/{name}.infrastructure/ApplicationDbContext.cs
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using {name}.domain.abstractions;
using {name}.infrastructure.outbox;

namespace {name}.infrastructure;

public sealed class ApplicationDbContext : DbContext, IUnitOfWork
{
    private static readonly JsonSerializerOptions JsonOptions = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase
    };

    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
        : base(options)
    {
    }

    public DbSet<OutboxMessage> OutboxMessages => Set<OutboxMessage>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly);
        base.OnModelCreating(modelBuilder);
    }

    public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
    {
        // Convert domain events to outbox messages before saving
        AddDomainEventsAsOutboxMessages();

        return await base.SaveChangesAsync(cancellationToken);
    }

    private void AddDomainEventsAsOutboxMessages()
    {
        var entities = ChangeTracker
            .Entries<Entity>()
            .Where(e => e.Entity.GetDomainEvents().Any())
            .Select(e => e.Entity)
            .ToList();

        var domainEvents = entities
            .SelectMany(e => e.GetDomainEvents())
            .ToList();

        foreach (var domainEvent in domainEvents)
        {
            var outboxMessage = new OutboxMessage
            {
                Id = Guid.NewGuid(),
                Type = domainEvent.GetType().AssemblyQualifiedName!,
                Content = JsonSerializer.Serialize(
                    domainEvent,
                    domainEvent.GetType(),
                    JsonOptions),
                OccurredOnUtc = DateTime.UtcNow
            };

            OutboxMessages.Add(outboxMessage);
        }

        foreach (var entity in entities)
        {
            entity.ClearDomainEvents();
        }
    }
}
アウトボックスプロセッサージョブ(Quartz)
// src/{name}.infrastructure/Outbox/ProcessOutboxMessagesJob.cs
using System.Text.Json;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Quartz;
using {name}.domain.abstractions;

namespace {name}.infrastructure.outbox;

[DisallowConcurrentExecution]
internal sealed class ProcessOutboxMessagesJob : IJob
{
    private static readonly JsonSerializerOptions JsonOptions = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase
    };

    private readonly ApplicationDbContext _dbContext;
    private readonly IPublisher _publisher;
    private readonly ILogger<ProcessOutboxMessagesJob> _logger;

    public ProcessOutboxMessagesJob(
        ApplicationDbContext dbContext,
        IPublisher publisher,
        ILogger<ProcessOutboxMessagesJob> logger)
    {
        _dbContext = dbContext;
        _publisher = publisher;
        _logger = logger;
    }

    public async Task Execute(IJobExecutionContext context)
    {
        _logger.LogInformation("Processing outbox messages...");

        var messages = await _dbContext
            .OutboxMessages
            .Where(m => m.ProcessedOnUtc == null)
            .OrderBy(m => m.OccurredOnUtc)
            .Take(20)
            .ToListAsync(context.CancellationToken);

        foreach (var message in messages)
        {
            try
            {
                var type = Type.GetType(message.Type);

                if (type is null)
                {
                    _logger.LogWarning(
                        "Could not resolve type {Type} for outbox message {MessageId}",
                        message.Type,
                        message.Id);

                    message.Error = $"Could not resolve type: {message.Type}";
                    message.ProcessedOnUtc = DateTime.UtcNow;
                    continue;
                }

                var domainEvent = JsonSerializer.Deserialize(
                    message.Content,
                    type,
                    JsonOptions) as IDomainEvent;

                if (domainEvent is null)
                {
                    _logger.LogWarning(
                        "Could not deserialize outbox message {MessageId}",
                        message.Id);

                    message.Error = "Could not deserialize message content";
                    message.ProcessedOnUtc = DateTime.UtcNow;
                    continue;
                }

                await _publisher.Publish(domainEvent, context.CancellationToken);

                message.ProcessedOnUtc = DateTime.UtcNow;

                _logger.LogInformation(
                    "Processed outbox message {MessageId} of type {Type}",
                    message.Id,
                    message.Type);
            }
            catch (Exception ex)
            {
                _logger.LogError(
                    ex,
                    "Error processing outbox message {MessageId}",
                    message.Id);

                message.Error = ex.ToString();
            }
        }

        await _dbContext.SaveChangesAsync(context.CancellationToken);
    }
}
Quartz ジョブの登録
// src/{name}.infrastructure/DependencyInjection.cs
private static void AddBackgroundJobs(IServiceCollection services)
{
    services.AddQuartz(configure =>
    {
        var jobKey = new JobKey(nameof(ProcessOutboxMessagesJob));

        configure
            .AddJob<ProcessOutboxMessagesJob>(jobKey)
            .AddTrigger(trigger =>
                trigger
                    .ForJob(jobKey)
                    .WithSimpleSchedule(schedule =>
                        schedule.WithIntervalInSeconds(10).RepeatForever()));
    });

    services.AddQuartzHostedService();
}

イベントの命名規則

イベントタイプ命名パターン例
作成{Entity}CreatedDomainEventUserCreatedDomainEvent
更新{Entity}UpdatedDomainEventUserUpdatedDomainEvent
削除{Entity}DeletedDomainEventUserDeletedDomainEvent
ステータス変更{Entity}{Status}DomainEventOrderShippedDomainEvent
アクション{Entity}{Action}DomainEventPaymentProcessedDomainEvent

重要なルール

  1. イベントは不変です - record 型を使用します
  2. イベントは過去形です - 発生した出来事を表します
  3. イベントはドメイン内で発行します - ハンドラー内では発行しません
  4. ハンドラーは独立しています - 個別に失敗する可能性があります
  5. 信頼性を高めるために Outbox を使用します - イベントはクラッシュ後も保持されます
  6. ハンドラーを await しないでください - fire-and-forget で実行します(MediatR 経由)
  7. ハンドラーは冪等にします - 同じイベントを複数回処理する可能性があります
  8. イベントにはコンテキストを含めます - クエリなしで処理できる十分なデータを含めます
  9. 1 つのイベントにつき集約は 1 つです - 所有関係を明確にします
  10. 戻り値はありません - イベントは通知です

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

// ❌ WRONG: Events with behavior
public record UserCreatedEvent
{
    public void SendEmail() { }  // Events should be data only!
}

// ✅ CORRECT: Pure data event
public record UserCreatedDomainEvent(Guid UserId, string Email) : DomainEvent;

// ❌ WRONG: Raising events in handler
internal sealed class CreateUserHandler : ICommandHandler<CreateUser, Guid>
{
    public async Task<Result<Guid>> Handle(...)
    {
        // Don't raise events here!
        await _publisher.Publish(new UserCreatedEvent(user.Id));
    }
}

// ✅ CORRECT: Raise events in entity
public static Result<User> Create(...)
{
    var user = new User(...);
    user.RaiseDomainEvent(new UserCreatedDomainEvent(user.Id, user.Email));
    return user;
}

// ❌ WRONG: Handler depends on other handler's result
internal sealed class Handler1 : INotificationHandler<Event>
{
    public async Task Handle(Event e, CancellationToken ct)
    {
        // Waiting for Handler2 to complete - bad!
        while (!await _service.IsHandler2Complete()) { }
    }
}

// ✅ CORRECT: Handlers are independent
internal sealed class Handler1 : INotificationHandler<Event>
{
    public Task Handle(Event e, CancellationToken ct)
    {
        // Does its own work, doesn't care about other handlers
        return DoWork(e, ct);
    }
}

関連スキル

  • dotnet-domain-entity-generator - イベントを発行するエンティティ
  • dotnet-pipeline-behaviors - イベント発行のビヘイビア
  • dotnet-clean-architecture - インフラストラクチャレイヤーのセットアップ
  • dotnet-cqrs-command-generator - イベントをトリガーするコマンド

コメント