AgentSkillsCN

Scaffold Command Handler

脚手架命令处理器

SKILL.md

Scaffold Command Handler

Generate a CQRS Command and Command Handler with validation, repository injection, and Result return type.

Triggers

  • "scaffold command handler"
  • "create command handler"
  • "new command handler"
  • "scaffold command"

Instructions

Step 1: Gather Information

Ask the student for:

  1. Command name (e.g., PlaceOrder, AddLineItem, CancelOrder)
  2. Command data — what parameters does the command carry? (e.g., orderId: string, quantity: number)
  3. Which aggregate does it target? (e.g., Order)
  4. What should happen — the business operation (e.g., "adds a line item to an existing order")

If the student provides a name only, infer the aggregate and operation from the command name.

Step 2: Determine Output Location

Check the current working directory:

  • If in a before/ directory, create files there
  • If in a lesson directory, create in before/
  • Otherwise, create in the current directory

Step 3: Generate Command File

Create {command-name}.command.ts (kebab-case filename):

typescript
import { z } from 'zod';

// === Command Validation Schema ===

const {CommandName}Schema = z.object({
  // Command properties with validation
});

export type {CommandName}Data = z.infer<typeof {CommandName}Schema>;

// === Command ===

export class {CommandName}Command {
  readonly type = '{CommandName}' as const;

  private constructor(public readonly data: Readonly<{CommandName}Data>) {}

  static create(data: {CommandName}Data): {CommandName}Command {
    const validated = {CommandName}Schema.parse(data); // Throws on invalid
    return new {CommandName}Command(Object.freeze(validated));
  }
}

Step 4: Generate Repository Interface

Create {aggregate-name}.repository.ts (if it doesn't already exist):

typescript
import type { {Aggregate} } from './{aggregate-name}';
import type { {Aggregate}Id } from './{aggregate-name}';

// === Repository Interface (Domain Layer) ===
// Defined in the domain — implemented in infrastructure

export interface {Aggregate}Repository {
  findById(id: {Aggregate}Id): Promise<{Aggregate} | null>;
  save(aggregate: {Aggregate}): Promise<void>;
}

Step 5: Generate Command Handler

Create {command-name}.handler.ts:

typescript
import type { {Aggregate}Repository } from './{aggregate-name}.repository';
import { {CommandName}Command } from './{command-name}.command';
import { create{Aggregate}Id } from './{aggregate-name}';

// === Result Type ===

type Result<T, E = Error> =
  | { success: true; value: T }
  | { success: false; error: E };

// === Command Handler ===
// Pattern: Validate → Load → Execute → Persist

export class {CommandName}Handler {
  constructor(private readonly repository: {Aggregate}Repository) {}

  async execute(command: {CommandName}Command): Promise<Result<void>> {
    // 1. Validate (already done by Command.create, add business validation here)

    // 2. Load the aggregate
    const aggregateId = create{Aggregate}Id(command.data.{idField});
    const aggregate = await this.repository.findById(aggregateId);

    if (!aggregate) {
      return {
        success: false,
        error: new Error(`{Aggregate} not found: ${command.data.{idField}}`),
      };
    }

    // 3. Execute the domain operation
    const result = aggregate.{operation}(/* params from command.data */);

    if (!result.success) {
      return result;
    }

    // 4. Persist
    await this.repository.save(aggregate);

    return { success: true, value: undefined };
  }
}

Step 6: Generate Test File

Create {command-name}.handler.test.ts:

typescript
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { {CommandName}Handler } from './{command-name}.handler';
import { {CommandName}Command } from './{command-name}.command';
import type { {Aggregate}Repository } from './{aggregate-name}.repository';
import { {Aggregate}, create{Aggregate}Id } from './{aggregate-name}';

describe('{CommandName}Handler', () => {
  let handler: {CommandName}Handler;
  let mockRepository: {Aggregate}Repository;

  beforeEach(() => {
    mockRepository = {
      findById: vi.fn(),
      save: vi.fn(),
    };
    handler = new {CommandName}Handler(mockRepository);
  });

  describe('execute', () => {
    it('should succeed with valid command', async () => {
      // Arrange
      const aggregate = createTestAggregate();
      vi.mocked(mockRepository.findById).mockResolvedValue(aggregate);
      vi.mocked(mockRepository.save).mockResolvedValue(undefined);

      const command = {CommandName}Command.create({
        // valid command data
      });

      // Act
      const result = await handler.execute(command);

      // Assert
      expect(result.success).toBe(true);
      expect(mockRepository.save).toHaveBeenCalledWith(aggregate);
    });

    it('should fail when aggregate not found', async () => {
      // Arrange
      vi.mocked(mockRepository.findById).mockResolvedValue(null);

      const command = {CommandName}Command.create({
        // command data with non-existent aggregate
      });

      // Act
      const result = await handler.execute(command);

      // Assert
      expect(result.success).toBe(false);
      expect(mockRepository.save).not.toHaveBeenCalled();
    });

    it('should fail when domain operation violates invariant', async () => {
      // Arrange
      const aggregate = createTestAggregateInInvalidState();
      vi.mocked(mockRepository.findById).mockResolvedValue(aggregate);

      const command = {CommandName}Command.create({
        // command that should fail
      });

      // Act
      const result = await handler.execute(command);

      // Assert
      expect(result.success).toBe(false);
      expect(mockRepository.save).not.toHaveBeenCalled();
    });
  });

  describe('command validation', () => {
    it('should reject invalid command data', () => {
      expect(() =>
        {CommandName}Command.create({
          // invalid data
        })
      ).toThrow();
    });
  });
});

// === Test Helpers ===

function createTestAggregate(): {Aggregate} {
  const id = create{Aggregate}Id('test-id');
  const result = {Aggregate}.create(id, { /* valid props */ });
  if (!result.success) throw new Error('Test setup failed');
  result.value.clearDomainEvents(); // Clear creation events
  return result.value;
}

function createTestAggregateInInvalidState(): {Aggregate} {
  // Create an aggregate in a state that should reject the command
  return createTestAggregate();
}

Step 7: Explain the Pattern

After generating, briefly explain:

  1. Command — an immutable data object representing the intent to do something (imperative: "PlaceOrder")
  2. Command Handler — orchestrates the operation: validate → load → execute → persist
  3. Repository injection — the handler depends on an interface, not a concrete implementation
  4. Handler does not contain business logic — it delegates to the aggregate's domain methods
  5. Result type — explicit success/failure instead of throwing exceptions
  6. Test strategy — mock the repository, test the handler's orchestration logic

Customization Notes

  • For commands that create a new aggregate, skip the "Load" step and use the aggregate's factory method
  • The repository interface belongs in the domain layer; the implementation belongs in infrastructure
  • One handler per command — keep handlers focused and simple
  • Consider adding a CommandBus if the student needs to dispatch commands indirectly