In CQRS everything that changes data goes through commands. This is the "write" half: create an order, confirm a payment, cancel a booking. Separate from reading, separate classes, different rules. This article explains what it looks like in NestJS with TypeScript.
What a Command is and why it deserves its own class
In a typical controller you used to be able to write it all inline: accept JSON, pull an object out of the database, change a field, save it. The problem — the logic is scattered across controllers, they can't be tested in isolation, and it's unclear where the business rules even live.
A Command in CQRS is a plain class holding data about an intent. "Confirm the order", "Charge the money", "Create the product". The controller turns an HTTP request into a command object and passes it to a handler. All the logic lives in the handler and the aggregate, not in the controller.
// core/order/command/confirm-order.command.ts
import { Command } from '@core/usecase';
export class ConfirmOrder implements Command<OrderId> {
constructor(
readonly orderId: OrderId,
readonly idempotencyKey: string,
) {}
}
A few things worth noting:
- All fields are
readonly. A command is data, not logic. No methods, no computations in the constructor. Command<R>is a marker interface fromcore/usecase.ts. TheRparameter is the type the handler returns. Here it'sOrderId.idempotencyKeyis a field for operations that must not run twice (payments, confirmations). The controller takes it from theIdempotency-Keyheader.- No
@IsNotEmptyor validation decorators. A command is an internal object; input validation happens earlier, at the request-DTO level.
The controller looks like this:
// adapters/in/http/order.controller.ts
@Post(':id/confirm')
async confirm(
@Param('id', ParseUUIDPipe) id: string,
@Headers('idempotency-key') key: string,
): Promise<{ id: string }> {
const result = await this.handler.execute(
new ConfirmOrder(OrderId.of(id), key),
);
return { id: result.value };
}
The controller only maps request parameters into a command and back. No business logic.
How a command-handler is built
The handler is the one who executes the command. Four steps in a strict order:
// core/order/command/confirm-order.handler.ts
@Injectable()
export class ConfirmOrderHandler implements Handler<ConfirmOrder, OrderId> {
constructor(
@Inject(ORDER_REPOSITORY) private readonly orders: OrderRepository,
@Inject(TX_RUNNER) private readonly tx: TransactionRunner,
@Inject(CLOCK) private readonly clock: Clock,
) {}
async execute(cmd: ConfirmOrder): Promise<OrderId> {
return this.tx.run(async () => {
// 1. Load the aggregate
const order = await this.orders.byId(cmd.orderId);
if (!order) throw new OrderNotFoundError(cmd.orderId);
// 2. Call the domain method — it checks the invariants
order.confirm(this.clock.now());
// 3. Save
await this.orders.save(order);
// 4. Return the result
return order.id;
});
}
}
Let's go step by step:
The transaction is opened in the handler, via TransactionRunner, not inside the repository. Everything inside tx.run(...) runs on a single database connection. If something goes wrong — an automatic rollback.
Loading the aggregate via byId. With an open transaction, the repository implementation uses SELECT ... FOR UPDATE, protecting against simultaneous modification by two requests.
The domain method checks the business rules. order.confirm() will itself throw OrderAlreadyConfirmedError if the order is already confirmed. The handler doesn't check the status itself — that's the aggregate's job.
Return the minimum — usually just the id. Not the whole object, not a read projection.
One aggregate per command
A common beginner mistake is trying to change two objects in a single transaction:
// Problem — two aggregates in one transaction
async execute(cmd: CreateOrder): Promise<OrderId> {
return this.tx.run(async () => {
const customer = await this.customers.byId(cmd.customerId);
customer.incrementOrderCount();
await this.customers.save(customer);
const order = Order.create(cmd.customerId, cmd.items);
await this.orders.save(order);
return order.id;
});
}
The problem: the transaction holds locks on two tables at once. Under concurrent requests this quickly leads to deadlocks. Customer and Order live at different rhythms and change for different reasons.
The rule is simple: one command — one aggregate. If you need to touch a second one, that's either a saga (a chain of separate commands with compensations) or the aggregate boundaries were drawn incorrectly.
The correct way: Order.create() internally registers a domain event OrderCreated. On save the repository writes it into the outbox table. A separate process publishes the event, and the Customer service updates the counter asynchronously.
// Correct — one aggregate, the event flies onward
async execute(cmd: CreateOrder): Promise<OrderId> {
return this.tx.run(async () => {
const order = Order.create(cmd.customerId, cmd.items);
await this.orders.save(order);
return order.id;
});
}
What a command-handler returns
A command-handler returns the minimum: the id of the created or changed entity, a status object, or void. Not the full object, not a read projection.
// Create an order — returns an id
export class CreateOrder implements Command<OrderId> {
constructor(readonly customerId: CustomerId, readonly items: OrderItem[]) {}
}
// Confirm — also an id
export class ConfirmOrder implements Command<OrderId> {
constructor(readonly orderId: OrderId, readonly idempotencyKey: string) {}
}
// Cancel — no response data needed
export class CancelOrder implements Command<void> {
constructor(readonly orderId: OrderId, readonly reason: string) {}
}
Why not return the full object? The write-handler starts assembling a read projection — joins, mappings, custom fields — which is the query-handler's job. If the client needs the full projection after creation, it makes a separate GET request. That's more honest and more reliable.
Validation: input data and business rules
Validation on the command side happens in two places, and they must not be confused.
On input — we check the format of the data. class-validator does this on the request-DTO before the command object is even created:
export class ConfirmOrderRequest {
@IsUUID()
orderId: string;
@IsString()
@MaxLength(64)
idempotencyKey: string;
}
If the data is invalid by format — a 400 response to the client, before any logic runs.
Inside the aggregate — we check the business invariants:
export class Order {
confirm(now: Date): void {
if (this.status !== OrderStatus.NEW) {
throw new OrderAlreadyConfirmedError(this.id, this.status);
}
if (this.items.length === 0) {
throw new EmptyOrderError(this.id);
}
this.status = OrderStatus.CONFIRMED;
this.confirmedAt = now;
this.registerEvent(new OrderConfirmed(this.id, now));
}
}
The aggregate throws a typed domain exception. The error-handler turns it into a 409 or 422. The handler doesn't check the status itself — that's the aggregate's responsibility.
Example: creating a product
The structure of the handler is the same for any domain:
// core/product/command/create-product.command.ts
export class CreateProduct implements Command<ProductId> {
constructor(
readonly name: string,
readonly sku: string,
readonly price: Money,
readonly categoryId: CategoryId,
) {}
}
@Injectable()
export class CreateProductHandler implements Handler<CreateProduct, ProductId> {
constructor(
@Inject(PRODUCT_REPOSITORY) private readonly products: ProductRepository,
@Inject(TX_RUNNER) private readonly tx: TransactionRunner,
) {}
async execute(cmd: CreateProduct): Promise<ProductId> {
return this.tx.run(async () => {
const existing = await this.products.bySku(cmd.sku);
if (existing) throw new DuplicateSkuError(cmd.sku);
const product = Product.create(cmd.name, cmd.sku, cmd.price, cmd.categoryId);
await this.products.save(product);
return product.id;
});
}
}
bySku — the uniqueness check runs inside the same transaction as the save. There's no "window" between the check and the insert where another request with the same SKU could slip in.
Example: an idempotent command
For operations that must not run twice — payments, charges — the handler checks the idempotency key inside the transaction:
@Injectable()
export class ChargeAccountHandler implements Handler<ChargeAccount, void> {
async execute(cmd: ChargeAccount): Promise<void> {
return this.tx.run(async () => {
const account = await this.accounts.byId(cmd.accountId);
if (!account) throw new AccountNotFoundError(cmd.accountId);
const alreadyProcessed = await this.accounts.hasProcessed(cmd.idempotencyKey);
if (alreadyProcessed) return;
account.charge(cmd.amount, cmd.idempotencyKey);
await this.accounts.save(account);
});
}
}
ChargeAccount returns void — the UI needs no response data, an HTTP 204 is enough. If the request was already processed — we quietly return without an error.
Common mistakes
A separate SELECT to "look and decide" inside the handler. Everything needed to make a decision should live in the aggregate. A separate read query in a command-handler is a race condition: between the check and the save the state could have changed.
// Mistake
async execute(cmd: ConfirmOrder): Promise<OrderId> {
return this.tx.run(async () => {
const hasPayment = await this.payments.existsByOrderId(cmd.orderId); // separate read
if (!hasPayment) throw new PaymentRequiredError(cmd.orderId);
// ...
});
}
If paymentStatus is needed to make the decision — it's a field of the Order aggregate, and order.confirm() will check it itself. Or these are different bounded contexts, and the check goes through an explicit query before the command is invoked.
Several aggregates in a single tx.run. Transferring money between two accounts is not one transaction with two aggregates, it's a saga: charge one account, publish an event, credit the other, and compensate on failure.
A command returns a full read projection. That's the query-handler's job. After a POST the client makes a GET if it needs the details.
In short
- Command — a class with
readonlyfields and theCommand<R>marker. Data only, no logic. - Handler does four steps: open a transaction → load the aggregate → call the domain method → save.
- The transaction is opened in the handler via
TransactionRunner, not inside the repository. - One command — one aggregate. Two aggregates mean a saga.
- Return the minimum: an
idorvoid. A read projection is a separate query. - Format validation — on the request-DTO. Business invariants — inside the aggregate's method.
- The aggregate's domain method checks the state itself and throws a typed exception.
What to read next
- Query side — read-handlers with
Query<R>andViewRepository. - Sync via events — how an event from a command-handler reaches the read-model.
- Read-model — the structure and recoverability of the read-model.
- When CQRS is justified — when to introduce CQRS and when not to.