mcpField
D
dotnet-cqrs-command-generator

dotnet-cqrs-command-generator

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

dotnet-cqrs-command-generator について

CQRS コマンドの構成を支援する .NET 開発スキルです。レコード、バリデーター、ハンドラー、任意のリクエスト DTO のテンプレートを示し、Result によるエラー処理と責務分離を重視します。

主な機能

  1. 01コマンドレコードとハンドラーのテンプレート
  2. 02FluentValidation による入力ルール
  3. 03戻り値あり・なしのコマンド
  4. 04複雑なリクエストと一括処理のパターン
  5. 05トランザクションとエラー処理の指針

活用シーン

  1. 01作成・更新・削除操作のコマンド設計
  2. 02コマンドへの検証とハンドラーの追加
  3. 03一括またはトランザクション書き込みの構成

インストール

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

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

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

詳細

CQRS コマンドジェネレーター

概要

このスキルは、CQRS(コマンドクエリ責務分離)パターンに従ったコマンドを生成します。コマンドは、システムの状態を変更する意図を表します。各コマンドは次の要素を備えています。

  • コマンドレコード - リクエストパラメーターを含む不変のデータ構造
  • バリデーター - 入力検証のための FluentValidation ルール
  • ハンドラー - Result を返すビジネスロジックの実装
  • リクエスト DTO(任意) - API レイヤーのリクエストモデル

クイックリファレンス

コマンドの種類戻り値ユースケース
ICommandResult戻り値のない操作(更新、削除)
ICommand<T>Result<T>データを返す操作(作成時に ID を返す)

コマンドの構造

/Application/{Feature}/
├── Create{Entity}/
│   ├── Create{Entity}Command.cs        # Record + Validator + Handler
│   └── Create{Entity}Request.cs        # Optional API DTO
├── Update{Entity}/
│   ├── Update{Entity}Command.cs
│   └── Update{Entity}Request.cs
└── Delete{Entity}/
    └── Delete{Entity}Command.cs

テンプレート:戻り値のあるコマンド(作成)

データを返す操作(通常は作成後のエンティティ ID)に使用します。

// src/{name}.application/{Feature}/Create{Entity}/Create{Entity}Command.cs
using FluentValidation;
using {name}.application.abstractions.clock;
using {name}.application.abstractions.messaging;
using {name}.domain.abstractions;
using {name}.domain.{entities};

namespace {name}.application.{feature}.Create{Entity};

// ═══════════════════════════════════════════════════════════════
// COMMAND RECORD
// ═══════════════════════════════════════════════════════════════
public sealed record Create{Entity}Command(
    string Name,
    string? Description,
    Guid? ParentId) : ICommand<Guid>;

// ═══════════════════════════════════════════════════════════════
// VALIDATOR
// ═══════════════════════════════════════════════════════════════
internal sealed class Create{Entity}CommandValidator : AbstractValidator<Create{Entity}Command>
{
    public Create{Entity}CommandValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty()
            .WithMessage("{Entity} name is required")
            .MaximumLength(100)
            .WithMessage("{Entity} name must not exceed 100 characters");

        RuleFor(x => x.Description)
            .MaximumLength(500)
            .When(x => x.Description is not null);
    }
}

// ═══════════════════════════════════════════════════════════════
// HANDLER
// ═══════════════════════════════════════════════════════════════
internal sealed class Create{Entity}CommandHandler 
    : ICommandHandler<Create{Entity}Command, Guid>
{
    private readonly I{Entity}Repository _{entity}Repository;
    private readonly IDateTimeProvider _dateTimeProvider;
    private readonly IUnitOfWork _unitOfWork;

    public Create{Entity}CommandHandler(
        I{Entity}Repository {entity}Repository,
        IDateTimeProvider dateTimeProvider,
        IUnitOfWork unitOfWork)
    {
        _{entity}Repository = {entity}Repository;
        _dateTimeProvider = dateTimeProvider;
        _unitOfWork = unitOfWork;
    }

    public async Task<Result<Guid>> Handle(
        Create{Entity}Command request,
        CancellationToken cancellationToken)
    {
        // 1. Validate business rules
        var existingEntity = await _{entity}Repository
            .GetByNameAsync(request.Name, cancellationToken);

        if (existingEntity is not null)
        {
            return Result.Failure<Guid>({Entity}Errors.AlreadyExists);
        }

        // 2. Create domain entity using factory method
        var {entity}Result = {Entity}.Create(
            request.Name,
            request.Description,
            _dateTimeProvider.UtcNow);

        if ({entity}Result.IsFailure)
        {
            return Result.Failure<Guid>({entity}Result.Error);
        }

        // 3. Persist to repository
        _{entity}Repository.Add({entity}Result.Value);

        // 4. Save changes (via Unit of Work)
        await _unitOfWork.SaveChangesAsync(cancellationToken);

        // 5. Return created entity ID
        return {entity}Result.Value.Id;
    }
}

テンプレート:戻り値のないコマンド(更新)

データを返さない操作に使用します。

// src/{name}.application/{Feature}/Update{Entity}/Update{Entity}Command.cs
using FluentValidation;
using {name}.application.abstractions.clock;
using {name}.application.abstractions.messaging;
using {name}.domain.abstractions;
using {name}.domain.{entities};

namespace {name}.application.{feature}.Update{Entity};

// ═══════════════════════════════════════════════════════════════
// COMMAND RECORD
// ═══════════════════════════════════════════════════════════════
public sealed record Update{Entity}Command(
    Guid Id,
    string Name,
    string? Description) : ICommand;

// ═══════════════════════════════════════════════════════════════
// VALIDATOR
// ═══════════════════════════════════════════════════════════════
internal sealed class Update{Entity}CommandValidator : AbstractValidator<Update{Entity}Command>
{
    public Update{Entity}CommandValidator()
    {
        RuleFor(x => x.Id)
            .NotEmpty()
            .WithMessage("{Entity} ID is required");

        RuleFor(x => x.Name)
            .NotEmpty()
            .MaximumLength(100);
    }
}

// ═══════════════════════════════════════════════════════════════
// HANDLER
// ═══════════════════════════════════════════════════════════════
internal sealed class Update{Entity}CommandHandler 
    : ICommandHandler<Update{Entity}Command>
{
    private readonly I{Entity}Repository _{entity}Repository;
    private readonly IDateTimeProvider _dateTimeProvider;
    private readonly IUnitOfWork _unitOfWork;

    public Update{Entity}CommandHandler(
        I{Entity}Repository {entity}Repository,
        IDateTimeProvider dateTimeProvider,
        IUnitOfWork unitOfWork)
    {
        _{entity}Repository = {entity}Repository;
        _dateTimeProvider = dateTimeProvider;
        _unitOfWork = unitOfWork;
    }

    public async Task<r> Handle(
        Update{Entity}Command request,
        CancellationToken cancellationToken)
    {
        // 1. Retrieve existing entity
        var {entity} = await _{entity}Repository
            .GetByIdAsync(request.Id, cancellationToken);

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

        // 2. Call domain method to update
        var updateResult = {entity}.Update(
            request.Name,
            request.Description,
            _dateTimeProvider.UtcNow);

        if (updateResult.IsFailure)
        {
            return Result.Failure(updateResult.Error);
        }

        // 3. Save changes
        await _unitOfWork.SaveChangesAsync(cancellationToken);

        return Result.Success();
    }
}

テンプレート:削除コマンド

// src/{name}.application/{Feature}/Delete{Entity}/Delete{Entity}Command.cs
using FluentValidation;
using {name}.application.abstractions.messaging;
using {name}.domain.abstractions;
using {name}.domain.{entities};

namespace {name}.application.{feature}.Delete{Entity};

public sealed record Delete{Entity}Command(Guid Id) : ICommand;

internal sealed class Delete{Entity}CommandValidator : AbstractValidator<Delete{Entity}Command>
{
    public Delete{Entity}CommandValidator()
    {
        RuleFor(x => x.Id).NotEmpty();
    }
}

internal sealed class Delete{Entity}CommandHandler 
    : ICommandHandler<Delete{Entity}Command>
{
    private readonly I{Entity}Repository _{entity}Repository;
    private readonly IUnitOfWork _unitOfWork;

    public Delete{Entity}CommandHandler(
        I{Entity}Repository {entity}Repository,
        IUnitOfWork unitOfWork)
    {
        _{entity}Repository = {entity}Repository;
        _unitOfWork = unitOfWork;
    }

    public async Task<r> Handle(
        Delete{Entity}Command request,
        CancellationToken cancellationToken)
    {
        var {entity} = await _{entity}Repository
            .GetByIdAsync(request.Id, cancellationToken);

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

        // Check business rules before deletion
        if ({entity}.HasActiveRelationships())
        {
            return Result.Failure({Entity}Errors.CannotDeleteWithActiveRelationships);
        }

        _{entity}Repository.Remove({entity});

        await _unitOfWork.SaveChangesAsync(cancellationToken);

        return Result.Success();
    }
}

テンプレート:複雑なリクエストオブジェクトを持つコマンド

パラメーターが多いコマンドには、ネストされたリクエストオブジェクトを使用します。

// src/{name}.application/{Feature}/Create{Entity}/Create{Entity}Command.cs
using FluentValidation;
using {name}.application.abstractions.messaging;
using {name}.domain.abstractions;

namespace {name}.application.{feature}.Create{Entity};

// Request object for complex data
public sealed class Create{Entity}Request
{
    public required string Name { get; init; }
    public string? Description { get; init; }
    public required Guid OrganizationId { get; init; }
    public List<Create{Child}Request> Children { get; init; } = new();
}

public sealed class Create{Child}Request
{
    public required string Name { get; init; }
    public int SortOrder { get; init; }
}

// Command wraps the request
public sealed record Create{Entity}Command(
    Create{Entity}Request Request) : ICommand<Guid>;

// Validator for nested structures
internal sealed class Create{Entity}CommandValidator 
    : AbstractValidator<Create{Entity}Command>
{
    public Create{Entity}CommandValidator()
    {
        RuleFor(x => x.Request.Name)
            .NotEmpty()
            .MaximumLength(100);

        RuleFor(x => x.Request.OrganizationId)
            .NotEmpty();

        RuleForEach(x => x.Request.Children)
            .ChildRules(child =>
            {
                child.RuleFor(c => c.Name).NotEmpty();
                child.RuleFor(c => c.SortOrder).GreaterThanOrEqualTo(0);
            });
    }
}

// Handler processes the complex request
internal sealed class Create{Entity}CommandHandler 
    : ICommandHandler<Create{Entity}Command, Guid>
{
    // ... dependencies

    public async Task<Result<Guid>> Handle(
        Create{Entity}Command command,
        CancellationToken cancellationToken)
    {
        var request = command.Request;
        
        // Process parent entity
        var {entity} = {Entity}.Create(
            request.Name,
            request.Description,
            request.OrganizationId);

        // Process children
        foreach (var childRequest in request.Children)
        {
            var child = {Child}.Create(
                childRequest.Name,
                childRequest.SortOrder);
            
            {entity}.AddChild(child);
        }

        _{entity}Repository.Add({entity});
        await _unitOfWork.SaveChangesAsync(cancellationToken);

        return {entity}.Id;
    }
}

検証ルールのリファレンス

一般的なバリデーター
// String validations
RuleFor(x => x.Name)
    .NotEmpty().WithMessage("Name is required")
    .NotNull().WithMessage("Name cannot be null")
    .MaximumLength(100).WithMessage("Name too long")
    .MinimumLength(3).WithMessage("Name too short")
    .Matches("^[a-zA-Z]+$").WithMessage("Only letters allowed");

// Numeric validations
RuleFor(x => x.Amount)
    .GreaterThan(0).WithMessage("Must be positive")
    .LessThanOrEqualTo(1000).WithMessage("Max 1000")
    .InclusiveBetween(1, 100).WithMessage("Must be 1-100");

// GUID validations
RuleFor(x => x.Id)
    .NotEmpty().WithMessage("ID is required")
    .NotEqual(Guid.Empty).WithMessage("Invalid ID");

// Email validation
RuleFor(x => x.Email)
    .NotEmpty()
    .EmailAddress().WithMessage("Invalid email format");

// Conditional validation
RuleFor(x => x.ParentId)
    .NotEmpty()
    .When(x => x.RequiresParent);

// Collection validation
RuleFor(x => x.Items)
    .NotEmpty().WithMessage("At least one item required")
    .Must(items => items.Count <= 10).WithMessage("Max 10 items");

// Custom validation
RuleFor(x => x.DateRange)
    .Must(BeValidDateRange).WithMessage("End date must be after start date");

private bool BeValidDateRange(DateRange range) => range.End > range.Start;
非同期検証(必要な場合に限り使用)
internal sealed class Create{Entity}CommandValidator 
    : AbstractValidator<Create{Entity}Command>
{
    private readonly I{Entity}Repository _{entity}Repository;

    public Create{Entity}CommandValidator(I{Entity}Repository {entity}Repository)
    {
        _{entity}Repository = {entity}Repository;

        RuleFor(x => x.Name)
            .MustAsync(BeUniqueName)
            .WithMessage("Name already exists");
    }

    private async Task<bool> BeUniqueName(string name, CancellationToken ct)
    {
        var existing = await _{entity}Repository.GetByNameAsync(name, ct);
        return existing is null;
    }
}

注:関心事の分離をより適切に保つため、存在確認はバリデーターではなくハンドラーで行うことを推奨します。


ハンドラーのパターン

パターン 1:単一エンティティの操作
public async Task<Result<Guid>> Handle(CreateCommand request, CancellationToken ct)
{
    // Create
    var entity = Entity.Create(request.Data);
    _repository.Add(entity);
    await _unitOfWork.SaveChangesAsync(ct);
    return entity.Id;
}
パターン 2:関連エンティティを伴う操作
public async Task<Result<Guid>> Handle(CreateCommand request, CancellationToken ct)
{
    // Load related entity
    var parent = await _parentRepository.GetByIdAsync(request.ParentId, ct);
    if (parent is null)
        return Result.Failure<Guid>(ParentErrors.NotFound);

    // Create with relationship
    var entity = Entity.Create(request.Data, parent);
    _repository.Add(entity);
    await _unitOfWork.SaveChangesAsync(ct);
    return entity.Id;
}
パターン 3:一括操作
public async Task<r> Handle(CreateBatchCommand request, CancellationToken ct)
{
    var entities = new List<Entity>();

    foreach (var item in request.Items)
    {
        var entityResult = Entity.Create(item);
        if (entityResult.IsFailure)
            return Result.Failure(entityResult.Error);
        
        entities.Add(entityResult.Value);
    }

    _repository.AddRange(entities);
    await _unitOfWork.SaveChangesAsync(ct);
    return Result.Success();
}
パターン 4:トランザクションを伴う操作
public async Task<r> Handle(ComplexCommand request, CancellationToken ct)
{
    using var transaction = await _unitOfWork.BeginTransactionAsync(ct);
    
    try
    {
        // Multiple operations
        var entity1 = await CreateEntity1(request);
        var entity2 = await CreateEntity2(request, entity1);
        
        await _unitOfWork.SaveChangesAsync(ct);
        await transaction.CommitAsync(ct);
        
        return Result.Success();
    }
    catch
    {
        await transaction.RollbackAsync(ct);
        throw;
    }
}

重要なルール

  1. コマンドはレコードです - 不変であり、値による等価性を持ちます
  2. コマンドごとにハンドラーを 1 つ用意します - ハンドラーを共有しません
  3. バリデーターは内部向けです - Application レイヤーの外部には公開しません
  4. Result パターンを使用します - ビジネスエラーに対して例外をスローしません
  5. IUnitOfWork を注入します - リポジトリ内で SaveChanges を呼び出しません
  6. 必ず CancellationToken を使用します - すべての非同期呼び出しに渡します
  7. ドメインロジックは Domain に配置します - ハンドラーは処理を調整しますが、ビジネスルールは含めません
  8. 作成時には ID を返します - 作成には ICommand<Guid> を使用します
  9. 順番に検証します - 作成前に存在を確認し、その後にビジネスルールを検証します
  10. ハンドラーの役割を絞ります - 各ハンドラーの責務は 1 つにします

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

// ❌ WRONG: Throwing exceptions for business errors
if (entity is null)
    throw new NotFoundException("Entity not found");

// ✅ CORRECT: Return Result
if (entity is null)
    return Result.Failure<Guid>(EntityErrors.NotFound);

// ❌ WRONG: Saving in repository
public void Add(Entity entity)
{
    _dbContext.Add(entity);
    _dbContext.SaveChanges(); // Don't do this!
}

// ✅ CORRECT: Handler calls SaveChanges via UnitOfWork
_repository.Add(entity);
await _unitOfWork.SaveChangesAsync(ct);

// ❌ WRONG: Business logic in handler
if (request.Amount > 1000 && user.Level < 5)
    return Result.Failure(Error.InsufficientLevel);

// ✅ CORRECT: Business logic in domain
var result = entity.ProcessOrder(request.Amount, user);
if (result.IsFailure)
    return Result.Failure(result.Error);

関連スキル

  • dotnet-cqrs-query-generator - 読み取り側のクエリを生成します
  • dotnet-domain-entity-generator - ファクトリーメソッドを持つドメインエンティティを生成します
  • dotnet-result-pattern - Result パターンを完全に実装します
  • dotnet-pipeline-behaviors - 検証およびログ記録のビヘイビアー

コメント