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:
- •Command name (e.g.,
PlaceOrder,AddLineItem,CancelOrder) - •Command data — what parameters does the command carry? (e.g.,
orderId: string,quantity: number) - •Which aggregate does it target? (e.g.,
Order) - •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:
- •Command — an immutable data object representing the intent to do something (imperative: "PlaceOrder")
- •Command Handler — orchestrates the operation: validate → load → execute → persist
- •Repository injection — the handler depends on an interface, not a concrete implementation
- •Handler does not contain business logic — it delegates to the aggregate's domain methods
- •Result type — explicit success/failure instead of throwing exceptions
- •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
CommandBusif the student needs to dispatch commands indirectly