Queries & Query Handlers
In CQRS, queries represent the read side of your application. Where commands mutate state and return little or nothing, a query asks a question and returns data — never causing side effects. By separating reads from writes, you can shape dedicated read models that are denormalized, cached, or backed by an entirely different data store, all optimized for fast lookups. NestJS gives you the QueryBus, query classes, and @QueryHandler to make this separation explicit and testable.
Defining a query
A query is a plain class that carries the parameters needed to answer a question. By convention it is immutable and implements the IQuery marker interface so the bus can route it. Keep queries free of behavior — they are simple data carriers.
// queries/impl/get-user-by-id.query.ts
import { IQuery } from '@nestjs/cqrs';
export class GetUserByIdQuery implements IQuery {
constructor(public readonly userId: string) {}
}
// queries/impl/search-users.query.ts
import { IQuery } from '@nestjs/cqrs';
export class SearchUsersQuery implements IQuery {
constructor(
public readonly term: string,
public readonly page = 1,
public readonly pageSize = 20,
) {}
}
Each query should express a single intent. Prefer many small, focused queries over one generic “search everything” query — they are easier to cache, authorize, and evolve.
Implementing a query handler
A handler is an @Injectable() class decorated with @QueryHandler(SomeQuery). It implements IQueryHandler<TQuery, TResult>, where TResult is the shape returned to the caller. The handler reads from whatever store is most efficient — a read replica, a materialized view, a cache, or a search index — and maps it into a read model (DTO).
// queries/handlers/get-user-by-id.handler.ts
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { GetUserByIdQuery } from '../impl/get-user-by-id.query';
import { UserReadEntity } from '../../read-model/user-read.entity';
import { UserView } from '../../read-model/user.view';
@QueryHandler(GetUserByIdQuery)
export class GetUserByIdHandler
implements IQueryHandler<GetUserByIdQuery, UserView>
{
constructor(
@InjectRepository(UserReadEntity)
private readonly users: Repository<UserReadEntity>,
) {}
async execute(query: GetUserByIdQuery): Promise<UserView> {
const user = await this.users.findOne({ where: { id: query.userId } });
if (!user) {
throw new NotFoundException(`User ${query.userId} not found`);
}
return {
id: user.id,
displayName: user.displayName,
email: user.email,
memberSince: user.createdAt.toISOString(),
};
}
}
The UserView is a read model — a flat, presentation-friendly DTO that has no relationship to your write-side domain entity:
// read-model/user.view.ts
export interface UserView {
id: string;
displayName: string;
email: string;
memberSince: string;
}
Dispatching through the QueryBus
Controllers and services depend only on the QueryBus. They construct a query and call execute, which returns a typed promise. The caller never knows which handler ran or where the data came from.
// users.controller.ts
import { Controller, Get, Param, Query } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import { GetUserByIdQuery } from './queries/impl/get-user-by-id.query';
import { SearchUsersQuery } from './queries/impl/search-users.query';
import { UserView } from './read-model/user.view';
@Controller('users')
export class UsersController {
constructor(private readonly queryBus: QueryBus) {}
@Get(':id')
getById(@Param('id') id: string): Promise<UserView> {
return this.queryBus.execute(new GetUserByIdQuery(id));
}
@Get()
search(
@Query('q') term = '',
@Query('page') page = 1,
): Promise<UserView[]> {
return this.queryBus.execute(new SearchUsersQuery(term, Number(page)));
}
}
Because execute is generic, the result type is inferred from the handler’s IQueryHandler<Query, Result> signature, giving you end-to-end type safety from the HTTP layer to the data layer.
Output:
$ curl http://localhost:3000/users/9f1c-...-a2
{
"id": "9f1c-...-a2",
"displayName": "Ada Lovelace",
"email": "[email protected]",
"memberSince": "2024-02-11T08:30:00.000Z"
}
Registering handlers in the module
List both query handlers and the CqrsModule import in your feature module. Handlers self-register with the bus on bootstrap via the @QueryHandler metadata.
// users.module.ts
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersController } from './users.controller';
import { UserReadEntity } from './read-model/user-read.entity';
import { GetUserByIdHandler } from './queries/handlers/get-user-by-id.handler';
import { SearchUsersHandler } from './queries/handlers/search-users.handler';
export const QueryHandlers = [GetUserByIdHandler, SearchUsersHandler];
@Module({
imports: [CqrsModule, TypeOrmModule.forFeature([UserReadEntity])],
controllers: [UsersController],
providers: [...QueryHandlers],
})
export class UsersModule {}
Optimizing read models independently
The biggest payoff of CQRS is that read models evolve on their own schedule. Because no query handler writes domain state, you are free to:
| Optimization | What it gives you |
|---|---|
| Read replica / separate DB | Offloads read traffic from the write database |
| Denormalized view tables | Single-row lookups, no joins at query time |
| Cache layer (Redis) | Sub-millisecond responses for hot queries |
| Search index (Elasticsearch) | Full-text and faceted search the write store can’t do |
| Materialized views | Pre-computed aggregates refreshed on a schedule |
A handler can transparently consult a cache before falling back to the database:
// queries/handlers/search-users.handler.ts
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { SearchUsersQuery } from '../impl/search-users.query';
import { UserView } from '../../read-model/user.view';
import { UserSearchService } from '../../read-model/user-search.service';
@QueryHandler(SearchUsersQuery)
export class SearchUsersHandler
implements IQueryHandler<SearchUsersQuery, UserView[]>
{
constructor(
@Inject(CACHE_MANAGER) private readonly cache: Cache,
private readonly search: UserSearchService,
) {}
async execute(query: SearchUsersQuery): Promise<UserView[]> {
const key = `users:search:${query.term}:${query.page}`;
const cached = await this.cache.get<UserView[]>(key);
if (cached) return cached;
const results = await this.search.query(
query.term,
query.page,
query.pageSize,
);
await this.cache.set(key, results, 30_000);
return results;
}
}
Queries must stay side-effect free. The cache write above is acceptable because it does not alter business state — but never publish events or mutate domain data inside a query handler. That belongs on the command side.
Best Practices
- Make query classes immutable and behavior-free; they only carry parameters.
- Return purpose-built read models (DTOs), never your write-side domain entities or ORM objects.
- Keep handlers strictly read-only — no domain mutations, no event publishing.
- Type handlers with
IQueryHandler<TQuery, TResult>so callers get inferred result types. - Back read models with the fastest store for the access pattern: replicas, caches, views, or search indexes.
- Group one query plus one handler per concern; avoid catch-all queries that are hard to cache and authorize.
- Validate and authorize at the controller or a query-level guard, before the bus dispatches.