AgentSkillsCN

Scaffold Aggregate

脚手架聚合

SKILL.md

Scaffold Aggregate

Generate a DDD Aggregate Root with domain events, invariant enforcement, and consistency boundaries.

Triggers

  • "scaffold aggregate"
  • "create aggregate"
  • "new aggregate"
  • "scaffold aggregate root"

Instructions

Step 1: Gather Information

Ask the student for:

  1. Aggregate name (e.g., Order, ShoppingCart, Inventory)
  2. Properties / child entities (e.g., lineItems: LineItem[], status: OrderStatus)
  3. Commands it should handle (e.g., addItem, removeItem, submit)
  4. Invariants — rules that must always hold (e.g., "order must have at least one item", "cannot add items to a submitted order")
  5. Domain events it should emit (e.g., OrderPlaced, ItemAdded)

If the student provides a name only, suggest a typical aggregate structure based on the domain.

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 Domain Event Types

Create {aggregate-name}-events.ts:

typescript
// === Domain Events ===
// Events are named in past tense — they describe what happened

interface DomainEvent {
  readonly eventType: string;
  readonly occurredOn: Date;
  readonly aggregateId: string;
}

export interface {Aggregate}Created extends DomainEvent {
  readonly eventType: '{Aggregate}Created';
  // event-specific data
}

export interface {Command}Executed extends DomainEvent {
  readonly eventType: '{Command}Executed';
  // event-specific data
}

// Discriminated union of all events for this aggregate
export type {Aggregate}Event =
  | {Aggregate}Created
  | {Command}Executed;

Step 4: Generate Aggregate File

Create {aggregate-name}.ts:

typescript
import { z } from 'zod';
import type { {Aggregate}Event } from './{aggregate-name}-events';

// === Branded ID Type ===

declare const __brand: unique symbol;
type Brand<T, B extends string> = T & { [__brand]: B };

export type {Aggregate}Id = Brand<string, '{Aggregate}Id'>;

export function create{Aggregate}Id(value: string): {Aggregate}Id {
  return value as {Aggregate}Id;
}

// === Result Type ===

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

// === Aggregate Root ===

export class {Aggregate} {
  private _domainEvents: {Aggregate}Event[] = [];

  private constructor(
    private readonly _id: {Aggregate}Id,
    // private properties
  ) {}

  // === Factory Method ===

  static create(
    id: {Aggregate}Id,
    props: { /* creation props */ }
  ): Result<{Aggregate}> {
    // Validate creation invariants

    const aggregate = new {Aggregate}(id);

    // Record creation event
    aggregate.addDomainEvent({
      eventType: '{Aggregate}Created',
      occurredOn: new Date(),
      aggregateId: id,
    });

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

  // === Identity ===

  get id(): {Aggregate}Id {
    return this._id;
  }

  // === Command Methods ===
  // Each method enforces invariants and emits events

  /*
  doSomething(params): Result<void> {
    // 1. Check invariants
    if (!this.canDoSomething()) {
      return { success: false, error: new Error('Invariant violated: ...') };
    }

    // 2. Apply state change
    this._state = newState;

    // 3. Record domain event
    this.addDomainEvent({
      eventType: 'SomethingDone',
      occurredOn: new Date(),
      aggregateId: this._id,
    });

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

  // === Domain Events ===

  get domainEvents(): readonly {Aggregate}Event[] {
    return [...this._domainEvents];
  }

  private addDomainEvent(event: {Aggregate}Event): void {
    this._domainEvents.push(event);
  }

  clearDomainEvents(): void {
    this._domainEvents = [];
  }

  // === Equality ===

  equals(other: {Aggregate}): boolean {
    return this._id === other._id;
  }
}

Step 5: Generate Test File

Create {aggregate-name}.test.ts:

typescript
import { describe, it, expect } from 'vitest';
import { {Aggregate}, create{Aggregate}Id } from './{aggregate-name}';

describe('{Aggregate}', () => {
  const validId = create{Aggregate}Id('test-id-1');

  describe('create', () => {
    it('should create with valid properties', () => {
      const result = {Aggregate}.create(validId, { /* valid props */ });

      expect(result.success).toBe(true);
      if (result.success) {
        expect(result.value.id).toBe(validId);
      }
    });

    it('should emit {Aggregate}Created event on creation', () => {
      const result = {Aggregate}.create(validId, { /* valid props */ });

      if (result.success) {
        const events = result.value.domainEvents;
        expect(events).toHaveLength(1);
        expect(events[0].eventType).toBe('{Aggregate}Created');
      }
    });
  });

  describe('commands', () => {
    // Test each command method

    it('should enforce invariants', () => {
      // Create aggregate in a state where command should fail
      // Attempt command
      // Verify failure with appropriate error
    });

    it('should emit event on successful command', () => {
      // Create aggregate
      // Execute command
      // Verify event emitted
    });
  });

  describe('domain events', () => {
    it('should clear events after clearDomainEvents()', () => {
      const result = {Aggregate}.create(validId, { /* props */ });

      if (result.success) {
        expect(result.value.domainEvents).toHaveLength(1);
        result.value.clearDomainEvents();
        expect(result.value.domainEvents).toHaveLength(0);
      }
    });

    it('should return a copy of events (not the internal array)', () => {
      const result = {Aggregate}.create(validId, { /* props */ });

      if (result.success) {
        const events1 = result.value.domainEvents;
        const events2 = result.value.domainEvents;
        expect(events1).not.toBe(events2); // Different array references
        expect(events1).toEqual(events2);   // Same content
      }
    });
  });
});

Step 6: Explain the Pattern

After generating, briefly explain:

  1. Aggregate Root — the entry point for the consistency boundary; all changes go through it
  2. Domain Events — record what happened; emitted during command execution, dispatched after persistence
  3. Invariant enforcement — every command method checks business rules before applying changes
  4. Command → Validate → Apply → Event — the standard pattern for aggregate methods
  5. clearDomainEvents() — called by the repository after events have been dispatched
  6. Consistency boundary — everything inside the aggregate is kept consistent; cross-aggregate consistency is eventual

Customization Notes

  • Replace placeholder commands with the student's actual business operations
  • Add child entities / value objects as needed
  • Keep aggregates small — if it's growing, consider splitting into smaller aggregates
  • Each command method should validate invariants, mutate state, and emit exactly one event
  • Domain events should be immutable and contain only the data needed by subscribers