← Back to the section

CQRS is not an on/off switch. It's a scale: you take exactly as much as you need right now and add the next tier as you grow. Starting straight away with an event-driven architecture on a young service is expensive and pointless.

Let's break down the four steps: from an ordinary service to a full-fledged read-model with Kafka.

Tier 1: an ordinary service without CQRS

Most services start here. One @Injectable() class, methods for reading and writing in one place, TypeORM working directly.

@Injectable()
export class OrderService {
  constructor(private readonly dataSource: DataSource) {}

  async createOrder(dto: CreateOrderDto): Promise<string> {
    const order = Order.create(dto.customerId, dto.items);
    await this.dataSource.getRepository(OrderEntity).save(order.toEntity());
    return order.id;
  }

  async getOrder(id: string): Promise<OrderDto> {
    const row = await this.dataSource.getRepository(OrderEntity).findOneBy({ id });
    if (!row) throw new OrderNotFoundError(id);
    return toOrderDto(row);
  }
}

When this is fine: simple CRUD services, internal utilities, proxies. There's no point complicating things while there's no real domain.

Tier 2: the Command and Query markers

When real business logic appears, the service starts to sprawl. Creating an order requires validation, discount calculation, sending events. Reading wants to assemble data from several tables. All of this in a single class turns into a mess.

The solution is to separate commands (writes) and queries (reads) explicitly, via the Command<R> and Query<R> markers.

// core/order/port/in/create-order.command.ts
export class CreateOrderCommand implements Command<OrderId> {
  constructor(
    readonly customerId: CustomerId,
    readonly items: ReadonlyArray<OrderItemInput>,
  ) {}
}

// core/order/port/in/get-order.query.ts
export class GetOrderQuery implements Query<OrderSummary> {
  constructor(readonly orderId: OrderId) {}
}

Each use-case gets its own handler:

// application/order/create-order.handler.ts
@Injectable()
export class CreateOrderHandler implements Handler<CreateOrderCommand, OrderId> {
  constructor(
    @Inject(ORDER_REPOSITORY) private readonly orders: OrderRepository,
    @Inject(TX_RUNNER) private readonly tx: TransactionRunner,
  ) {}

  async execute(cmd: CreateOrderCommand): Promise<OrderId> {
    return this.tx.run(async () => {
      const order = Order.create(cmd.customerId, cmd.items);
      await this.orders.save(order);
      return order.id;
    });
  }
}

// application/order/get-order.handler.ts
@Injectable()
export class GetOrderHandler implements Handler<GetOrderQuery, OrderSummary> {
  constructor(
    @Inject(ORDER_REPOSITORY) private readonly orders: OrderRepository,
  ) {}

  async execute(query: GetOrderQuery): Promise<OrderSummary> {
    const order = await this.orders.byId(query.orderId);
    if (!order) throw new OrderNotFoundError(query.orderId);
    return toOrderSummary(order);
  }
}

Two important points at this tier:

  • The query-handler works without a transaction. Reading doesn't need tx.run() — this is deliberate. A transaction here would only add overhead without benefit.
  • A command returns the minimum. CreateOrderHandler returns OrderId, not the full object. If the UI needs data after creation — the controller calls GetOrderHandler separately.

Read and write still use a single OrderRepository. That's fine at this tier.

Tier 3 split: a separate repository for reading

Over time, read queries start to differ from the write model. The client portal wants to see customer_name right in the order row, without joins. Pagination with filters is needed. The TypeORM mapping of the aggregate is poorly suited to such queries.

The solution is to set up a separate OrderViewRepository interface specifically for reading.

// core/order/port/out/order.repository.ts — for writing
export interface OrderRepository {
  byId(id: OrderId): Promise<Order | null>;
  save(order: Order): Promise<void>;
}

// core/order/port/out/order-view.repository.ts — for reading
export interface OrderViewRepository {
  summary(orderId: OrderId): Promise<OrderSummary | null>;
  search(customerId: CustomerId, status: OrderStatus, page: PageRequest): Promise<Page<OrderSummary>>;
}

The OrderViewRepository implementation uses plain SQL instead of TypeORM methods:

@Injectable()
export class TypeOrmOrderViewRepository implements OrderViewRepository {
  constructor(private readonly dataSource: DataSource) {}

  async summary(orderId: OrderId): Promise<OrderSummary | null> {
    const rows = await this.dataSource.query(
      `SELECT o.id, o.status, o.customer_name, o.total_amount, o.created_at
         FROM orders o
        WHERE o.id = $1`,
      [orderId],
    );
    return rows[0] ? toOrderSummary(rows[0]) : null;
  }

  async search(customerId: CustomerId, status: OrderStatus, page: PageRequest): Promise<Page<OrderSummary>> {
    const rows = await this.dataSource.query(
      `SELECT o.id, o.status, o.customer_name, o.total_amount, o.created_at
         FROM orders 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, page.size, page.offset()],
    );
    return toPage(rows, page);
  }
}

The query-handler now works only with OrderViewRepository — it doesn't see the OrderRepository for writing:

@Injectable()
export class GetOrderHandler implements Handler<GetOrderQuery, OrderSummary> {
  constructor(
    @Inject(ORDER_VIEW_REPOSITORY) private readonly view: OrderViewRepository,
  ) {}

  async execute(query: GetOrderQuery): Promise<OrderSummary> {
    const summary = await this.view.summary(query.orderId);
    if (!summary) throw new OrderNotFoundError(query.orderId);
    return summary;
  }
}

Physically everything is still in a single PostgreSQL — just different queries. The separation is at the code level so far, not the infrastructure.

Tier 3 event-driven: separate storage for the read-model

When read load starts to interfere with writes, or the read projection is fundamentally different (full-text search, analytical summaries), the read-model moves to separate storage.

Data gets there through events:

Write:                             Read:
  PostgreSQL                         order_summary (PG table / Redis / ES)
  ├── orders                         ├── customer_name (denormalized)
  └── outbox_events                  └── indexes for the needed queries
        ↓
  outbox-relay (SKIP LOCKED)
        ↓
  Kafka (order.events)
        ↓
  consumer
        ↓
  UPSERT order_summary

What appears additionally:

  • The outbox_events table — the event is written in the same transaction as the aggregate. This guarantees the event won't be lost on failure.
  • The outbox-relay — a NestJS task that reads unprocessed events via SELECT ... FOR UPDATE SKIP LOCKED and publishes them to Kafka.
  • The consumer — subscribes to the topic and updates order_summary via UPSERT.
  • Protection against reprocessing — a processed_event table or a version check in the UPSERT.
@Injectable()
export class OrderEventConsumer {
  constructor(private readonly dataSource: DataSource) {}

  @EventPattern('order.events')
  async handle(payload: OrderEventPayload): Promise<void> {
    await this.dataSource.transaction(async (em) => {
      const already = await em.query(
        `SELECT 1 FROM processed_event WHERE event_id = $1`, [payload.eventId],
      );
      if (already.length) return;

      await em.query(
        `INSERT INTO order_summary (id, status, customer_name, total_amount, updated_at)
         VALUES ($1, $2, $3, $4, NOW())
         ON CONFLICT (id) DO UPDATE
           SET status = EXCLUDED.status,
               total_amount = EXCLUDED.total_amount,
               updated_at = EXCLUDED.updated_at`,
        [payload.orderId, payload.status, payload.customerName, payload.totalAmount],
      );
      await em.query(
        `INSERT INTO processed_event (event_id, processed_at) VALUES ($1, NOW())`,
        [payload.eventId],
      );
    });
  }
}

You pay for this with read lag: data appears in the read-model after 100ms–1s under normal conditions, longer during failures. This is acceptable for most UIs, but it has to be accounted for.

Below this threshold a read replica plus a cache handles the job more cheaply.

How a service grows through the tiers

A typical path:

  1. Started as a small utility service. A flat ProductService, TypeORM Entity exposed outward. Tier 1.
  2. A real domain appeared: introduced Command<R> / Query<R>, Handler, TransactionRunner. Tier 2.
  3. The read side got more complex: the client portal wants projections with denormalized fields, without joins. Set up ProductViewRepository with direct SQL. Tier 3 split.
  4. Read load started hitting the write side during peaks — moved to a Redis projection with outbox and Kafka. Tier 3 event-driven.

Every transition is justified by real pain, not by the desire to use a "modern architecture".

Common mistakes

Markers without meaning. Adding Command<R> / Query<R> at Tier 1 without introducing a TransactionRunner or a separation of read and write paths is a box-ticking gesture. Either move fully to Tier 2 or remove the markers.

A single repository under event-driven infrastructure. If the read-model is already in separate storage but the query-handler still injects ORDER_REPOSITORY — the separation loses its meaning. You need a separate ORDER_VIEW_REPOSITORY.

Jumping over tiers. Starting with event-driven, skipping Tier 2 and Tier 3-split — excess complexity without practice. Each tier builds on the previous one.

A query-handler with a transaction. Wrapping a read in tx.run() at Tier 2 is unnecessary overhead. The Query<R> marker means exactly "without a transaction".

In short

  • CQRS is a maturity scale: you take exactly as much as you need right now.
  • Tier 1 — an ordinary @Injectable() service; markers aren't needed.
  • Tier 2Command<R> / Query<R> are mandatory; the query-handler has no transaction; a single OrderRepository.
  • Tier 3 split — a separate OrderViewRepository with direct SQL; physically one DB.
  • Tier 3 event-driven — the read-model in separate storage, synchronization via outbox + Kafka; you accept data lag.
  • Transitions strictly go bottom-up, each justified by real load or pain.