dotnet-cqrs-command-generator について
CQRS コマンドの構成を支援する .NET 開発スキルです。レコード、バリデーター、ハンドラー、任意のリクエスト DTO のテンプレートを示し、Result によるエラー処理と責務分離を重視します。
主な機能
- 01コマンドレコードとハンドラーのテンプレート
- 02FluentValidation による入力ルール
- 03戻り値あり・なしのコマンド
- 04複雑なリクエストと一括処理のパターン
- 05トランザクションとエラー処理の指針
活用シーン
- 01作成・更新・削除操作のコマンド設計
- 02コマンドへの検証とハンドラーの追加
- 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 レイヤーのリクエストモデル
クイックリファレンス
| コマンドの種類 | 戻り値 | ユースケース |
|---|---|---|
ICommand | Result | 戻り値のない操作(更新、削除) |
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 つ用意します - ハンドラーを共有しません
- バリデーターは内部向けです - Application レイヤーの外部には公開しません
- Result パターンを使用します - ビジネスエラーに対して例外をスローしません
- IUnitOfWork を注入します - リポジトリ内で SaveChanges を呼び出しません
- 必ず CancellationToken を使用します - すべての非同期呼び出しに渡します
- ドメインロジックは Domain に配置します - ハンドラーは処理を調整しますが、ビジネスルールは含めません
- 作成時には ID を返します - 作成には
ICommand<Guid>を使用します - 順番に検証します - 作成前に存在を確認し、その後にビジネスルールを検証します
- ハンドラーの役割を絞ります - 各ハンドラーの責務は 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- 検証およびログ記録のビヘイビアー
コメント
Markdown 対応