AgentSkillsCN

Scaffold Query Handler

脚手架查询处理器

SKILL.md

Scaffold Query Handler

Generate a CQRS Query and Query Handler with DTO/read model, read-optimized store, and tests.

Triggers

  • "scaffold query handler"
  • "create query handler"
  • "new query handler"
  • "scaffold query"

Instructions

Step 1: Gather Information

Ask the student for:

  1. Query name (e.g., GetOrderDetails, ListCustomerOrders, SearchProducts)
  2. Query parameters — what filters/criteria does it take? (e.g., orderId: string, customerId: string, page: number)
  3. What data should be returned — the shape of the read model/DTO (e.g., order summary with line items, product list with prices)

If the student provides a name only, infer reasonable parameters and DTO shape from the query 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 DTO / Read Model

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

typescript
// === Read Model / DTO ===
// DTOs are plain data structures optimized for the read side.
// They are NOT domain objects — they represent a view of the data.

export interface {QueryName}Result {
  // Flattened, denormalized data optimized for the consumer
  // No domain logic, no methods — just data
}

// For list queries, include pagination metadata
export interface {QueryName}PageResult {
  items: {QueryName}Result[];
  total: number;
  page: number;
  pageSize: number;
  totalPages: number;
}

Step 4: Generate Query File

Create {query-name}.query.ts:

typescript
import { z } from 'zod';

// === Query Validation Schema ===

const {QueryName}Schema = z.object({
  // Query parameters with validation
});

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

// === Query ===

export class {QueryName}Query {
  readonly type = '{QueryName}' as const;

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

  static create(data: {QueryName}Data): {QueryName}Query {
    const validated = {QueryName}Schema.parse(data);
    return new {QueryName}Query(Object.freeze(validated));
  }
}

Step 5: Generate Read Store Interface

Create {query-name}.read-store.ts:

typescript
import type { {QueryName}Result } from './{query-name}.dto';
import type { {QueryName}Data } from './{query-name}.query';

// === Read Store Interface ===
// Defined in the application layer — implemented in infrastructure.
// Optimized for reads: may use views, materialized queries, or a separate read DB.

export interface {QueryName}ReadStore {
  execute(params: {QueryName}Data): Promise<{QueryName}Result | null>;
  // For list queries:
  // execute(params: {QueryName}Data): Promise<{QueryName}PageResult>;
}

Step 6: Generate Query Handler

Create {query-name}.handler.ts:

typescript
import type { {QueryName}ReadStore } from './{query-name}.read-store';
import type { {QueryName}Result } from './{query-name}.dto';
import { {QueryName}Query } from './{query-name}.query';

// === Result Type ===

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

// === Query Handler ===
// Query handlers are read-only. They NEVER modify state.
// They return DTOs, NEVER domain objects.

export class {QueryName}Handler {
  constructor(private readonly readStore: {QueryName}ReadStore) {}

  async execute(query: {QueryName}Query): Promise<Result<{QueryName}Result>> {
    const result = await this.readStore.execute(query.data);

    if (!result) {
      return {
        success: false,
        error: new Error('Not found'),
      };
    }

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

Step 7: Generate Test File

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

typescript
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { {QueryName}Handler } from './{query-name}.handler';
import { {QueryName}Query } from './{query-name}.query';
import type { {QueryName}ReadStore } from './{query-name}.read-store';
import type { {QueryName}Result } from './{query-name}.dto';

describe('{QueryName}Handler', () => {
  let handler: {QueryName}Handler;
  let mockReadStore: {QueryName}ReadStore;

  beforeEach(() => {
    mockReadStore = {
      execute: vi.fn(),
    };
    handler = new {QueryName}Handler(mockReadStore);
  });

  describe('execute', () => {
    it('should return data when found', async () => {
      // Arrange
      const expectedResult: {QueryName}Result = {
        // expected DTO data
      };
      vi.mocked(mockReadStore.execute).mockResolvedValue(expectedResult);

      const query = {QueryName}Query.create({
        // valid query params
      });

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

      // Assert
      expect(result.success).toBe(true);
      if (result.success) {
        expect(result.value).toEqual(expectedResult);
      }
    });

    it('should return error when not found', async () => {
      // Arrange
      vi.mocked(mockReadStore.execute).mockResolvedValue(null);

      const query = {QueryName}Query.create({
        // query params for non-existent data
      });

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

      // Assert
      expect(result.success).toBe(false);
    });

    it('should pass query parameters to read store', async () => {
      // Arrange
      vi.mocked(mockReadStore.execute).mockResolvedValue(null);

      const queryData = {
        // specific query params
      };
      const query = {QueryName}Query.create(queryData);

      // Act
      await handler.execute(query);

      // Assert
      expect(mockReadStore.execute).toHaveBeenCalledWith(queryData);
    });
  });

  describe('query validation', () => {
    it('should reject invalid query parameters', () => {
      expect(() =>
        {QueryName}Query.create({
          // invalid params
        })
      ).toThrow();
    });
  });
});

Step 8: Explain the Pattern

After generating, briefly explain:

  1. Query — a request for data; it never changes state (read-only)
  2. Query Handler — orchestrates the read operation; thin layer over the read store
  3. DTO / Read Model — a flat data structure optimized for the consumer; not a domain object
  4. Read Store — an interface for querying data; can be a separate database, view, or projection
  5. No domain objects in responses — queries return DTOs to prevent leaking domain internals
  6. Separation from commands — commands and queries use different models; the read side is optimized for querying, the write side for consistency

Customization Notes

  • For list queries, add pagination parameters (page, pageSize) and return a page result
  • Read stores can be backed by SQL views, materialized views, or dedicated read databases
  • Query handlers should be simple — if there's complex logic, it probably belongs on the write side
  • Consider adding caching at the read store level for frequently accessed data
  • DTOs should be flat and denormalized — avoid nested domain structures