← Back to the section

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 readonly fields and the Query<R> marker, where R is the read-DTO type.
  • ViewRepository — a separate repository with direct SQL via DataSource.query(), without ORM entities and without transactions.
  • Read-DTO — a flat readonly interface 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.
  • 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_summary is populated from write-side events.
  • When CQRS is justified — when to apply CQRS and when a single repository is enough.