In CQRS the application is split into two halves: commands change state, queries read it. The query side is the reading half. It works by different rules than the write side: no transactions, no aggregates, no writing to the database. Reading only — fast and straightforward.
Why reading and writing are different things
Imagine you want to show the user a list of their orders. For that you need one line: order number, status, total, date. Nothing complicated.
But if you use the same tools you used to create the order — an ORM, an aggregate with business logic, a transaction — you'll load the whole order structure from several tables, build an object with its own rules, and then turn it back into plain JSON. A lot of work for a single row in a table.
The query side solves this differently: it goes straight to the database with the SQL query it needs, gets exactly the fields required for display, and returns them. No aggregate. No ORM overhead. No transaction.
Query — a class with query parameters
A query is expressed as a separate class with readonly fields. It implements the marker interface Query<R>, where R is the result type. This helps the routing system tell a query from a command and pick the right pipeline.
// core/order/port/in/get-order-summary.query.ts
import { Query } from '@core/usecase';
import { OrderSummary } from 'core/order/port/view/order-summary';
export class GetOrderSummaryQuery implements Query<OrderSummary> {
constructor(readonly orderId: string) {}
}
For a paginated list:
// core/order/port/in/search-orders.query.ts
import { Query } from '@core/usecase';
import { OrderListItem } from 'core/order/port/view/order-list-item';
import { Page } from 'core/pagination';
export class SearchOrdersQuery implements Query<Page<OrderListItem>> {
constructor(
readonly customerId: string,
readonly status: string | null,
readonly page: number,
readonly size: number,
) {}
}
A few naming rules: classes are named Get…Query, Search…Query, List…Query. Everything is readonly — parameters don't change after creation. The result type is a read-DTO or Page<ReadDto>, never an aggregate.
Read-DTO — data shaped to fit the screen
A read-DTO is not an aggregate. Its structure is dictated by what needs to be shown on the screen, not by how the domain model is built.
Denormalization is a good example. Instead of storing only customerId and making a second query for the customer's name, the read-DTO already contains customerName. Instead of an array of order line items — a single itemCount field. One query — everything you need.
// core/order/port/view/order-summary.ts
export interface OrderSummary {
readonly orderId: string;
readonly status: 'PENDING' | 'CONFIRMED' | 'SHIPPED' | 'CANCELLED';
readonly customerName: string; // customer name denormalized — no second query
readonly totalAmount: number;
readonly currency: string;
readonly itemCount: number; // the number of line items, not an array
readonly createdAt: string; // ISO-8601
readonly lastUpdatedAt: string;
}
A list needs fewer fields:
// core/order/port/view/order-list-item.ts
export interface OrderListItem {
readonly orderId: string;
readonly status: string;
readonly customerName: string;
readonly totalAmount: number;
readonly currency: string;
readonly createdAt: string;
}
All fields are readonly, there are no methods — it's just data, safely serializable to JSON.
ViewRepository — a separate repository for reading
Reading uses a separate repository — <X>ViewRepository. It has nothing to do with the repository through which data is written. A separate DI token, a separate interface.
// core/order/port/out/order-view.repository.ts
export const ORDER_VIEW_REPOSITORY = Symbol('OrderViewRepository');
export interface OrderViewRepository {
summary(orderId: string): Promise<OrderSummary | null>;
search(customerId: string, status: string | null, page: number, size: number): Promise<Page<OrderListItem>>;
}
The implementation goes through DataSource.query() — raw SQL without ORM entities, without a transaction, without locks:
// adapters/out/persistence/typeorm-order-view.repository.ts
@Injectable()
export class TypeOrmOrderViewRepository implements OrderViewRepository {
constructor(private readonly dataSource: DataSource) {}
async summary(orderId: string): Promise<OrderSummary | null> {
const rows = await this.dataSource.query<OrderSummaryRow[]>(
`SELECT o.id AS "orderId",
o.status,
o.customer_name AS "customerName",
o.total_amount AS "totalAmount",
o.currency,
o.item_count AS "itemCount",
o.created_at AS "createdAt",
o.updated_at AS "lastUpdatedAt"
FROM order_summary o
WHERE o.id = $1`,
[orderId],
);
return rows[0] ? toOrderSummary(rows[0]) : null;
}
async search(
customerId: string,
status: string | null,
page: number,
size: number,
): Promise<Page<OrderListItem>> {
const offset = page * size;
const rows = await this.dataSource.query<OrderListItemRow[]>(
`SELECT o.id AS "orderId",
o.status,
o.customer_name AS "customerName",
o.total_amount AS "totalAmount",
o.currency,
o.created_at AS "createdAt"
FROM order_summary o
WHERE o.customer_id = $1
AND ($2::text IS NULL OR o.status = $2)
ORDER BY o.created_at DESC
LIMIT $3 OFFSET $4`,
[customerId, status ?? null, size, offset],
);
const [{ total }] = await this.dataSource.query<[{ total: string }]>(
`SELECT COUNT(*) AS total FROM order_summary WHERE customer_id = $1`,
[customerId],
);
return { items: rows.map(toOrderListItem), total: Number(total), page, size };
}
}
If there's no separate read table order_summary — you can read from the write tables via a JOIN. The repository interface stays the same:
async summary(orderId: string): Promise<OrderSummary | null> {
const rows = await this.dataSource.query<OrderSummaryRow[]>(
`SELECT o.id AS "orderId",
o.status,
c.name AS "customerName",
o.total_amount AS "totalAmount",
o.currency,
(SELECT COUNT(*) FROM order_item WHERE order_id = o.id) AS "itemCount",
o.created_at AS "createdAt",
o.updated_at AS "lastUpdatedAt"
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.id = $1`,
[orderId],
);
return rows[0] ? toOrderSummary(rows[0]) : null;
}
Query handler — reads only
The handler for a query is simple: it receives the query object, goes to the ViewRepository, returns the read-DTO. No logic, no writing.
// core/order/usecase/get-order-summary.handler.ts
@Injectable()
export class GetOrderSummaryHandler implements Handler<GetOrderSummaryQuery, OrderSummary> {
constructor(
@Inject(ORDER_VIEW_REPOSITORY)
private readonly orderView: OrderViewRepository,
) {}
async execute(query: GetOrderSummaryQuery): Promise<OrderSummary> {
const summary = await this.orderView.summary(query.orderId);
if (!summary) throw new OrderNotFoundError(query.orderId);
return summary;
}
}
For a paginated search:
@Injectable()
export class SearchOrdersHandler implements Handler<SearchOrdersQuery, Page<OrderListItem>> {
constructor(
@Inject(ORDER_VIEW_REPOSITORY)
private readonly orderView: OrderViewRepository,
) {}
async execute(query: SearchOrdersQuery): Promise<Page<OrderListItem>> {
return this.orderView.search(query.customerId, query.status, query.page, query.size);
}
}
The handler has no TransactionRunner — and that's not by accident. NestJS doesn't support declarative read-only transactions out of the box, and DataSource.query() in the ViewRepository works without an explicit transaction. That's the contract: the query side is not transactional.
A common mistake: business logic inside reads
Sometimes there's a temptation to add something to a query handler — for example, to archive a stale order while it's being read:
// Don't do this
async execute(query: GetOrderSummaryQuery): Promise<OrderSummary> {
const order = await this.orders.byId(query.orderId); // write repository
if (order.shouldBeArchived()) {
order.archive(); // mutation in a read-handler
await this.orders.save(order); // writing inside a query
}
return toSummary(order);
}
There are several problems here at once: the handler changes data, loads an aggregate instead of a read-DTO, and mixes reading with writing. Such logic belongs to a separate scheduled command handler — not the query side.
Another common mistake is returning an aggregate or an ORM Entity instead of a read-DTO. An aggregate carries business rules and internal relationships that aren't needed in an API response, and serializing an Entity may include unwanted data or trigger lazy-loading.
File structure
core/
└── order/
├── domain/
│ └── order.ts # aggregate (write side)
├── port/
│ ├── in/
│ │ ├── get-order-summary.query.ts
│ │ └── search-orders.query.ts
│ ├── out/
│ │ ├── order.repository.ts # write-side repository
│ │ └── order-view.repository.ts # read-side repository
│ └── view/
│ ├── order-summary.ts # read-DTO for details
│ └── order-list-item.ts # read-DTO for the list
└── usecase/
├── get-order-summary.handler.ts
└── search-orders.handler.ts
In short
- The query side is the reading half of CQRS. Reading only, no writing, no aggregate business logic.
- Query — a class with
readonlyfields and theQuery<R>marker, whereRis the read-DTO type. - ViewRepository — a separate repository with direct SQL via
DataSource.query(), without ORM entities and without transactions. - Read-DTO — a flat
readonlyinterface shaped to the screen's needs: denormalized fields (customerName), aggregated values (itemCount). - The handler receives a query, calls the ViewRepository, returns the read-DTO. No domain-method calls, no writing.
- If there's no separate read table — read from the write tables via a JOIN, but the ViewRepository interface stays the same.
What to read next
- Command side — the write half: a handler through an aggregate and
TransactionRunner. - Read-model — where and in what form to store data for reading.
- Synchronization via events — how
order_summaryis populated from write-side events. - When CQRS is justified — when to apply CQRS and when a single repository is enough.