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:
- •Query name (e.g.,
GetOrderDetails,ListCustomerOrders,SearchProducts) - •Query parameters — what filters/criteria does it take? (e.g.,
orderId: string,customerId: string,page: number) - •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:
- •Query — a request for data; it never changes state (read-only)
- •Query Handler — orchestrates the read operation; thin layer over the read store
- •DTO / Read Model — a flat data structure optimized for the consumer; not a domain object
- •Read Store — an interface for querying data; can be a separate database, view, or projection
- •No domain objects in responses — queries return DTOs to prevent leaking domain internals
- •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