DDD and CQRS in a Monolith

How to apply CQRS inside a DDD monolith without dragging in message brokers, distributed transactions, or eventual-consistency pain you don't need yet.

It was a Wednesday afternoon at the London product agency I led engineering at, mid-way through the portfolio DDD migration. A teammate pinged me. The /billing/invoices page on one of the bigger client products was timing out under load. Seventeen joins, three subqueries, a COUNT(*) that loved to scan. The write path was tidy. Invoices, line items, payments, all properly modeled as aggregates with invariants. The read path was the same model bent until it screamed. Yeah, that’s the day I stopped arguing with myself and split the two.

So this post is about that split. CQRS inside a DDD monolith. No Kafka, no Debezium, no five-service topology, no eventual-consistency war room. Two models in the same process, sharing the same database, doing two different jobs. Most teams should pick this version, not the distributed one.

Why split reads from writes at all

The honest answer is your aggregates and your screens want different things. An aggregate exists to enforce invariants on a write. “An order with status confirmed cannot have its line items mutated.” Small clean rule, works because the aggregate is small and loaded fully on every write.

Your screens want a different shape. A customer detail page wants the latest invoice, total spend, last login, and an unread notification count, in one sorted query. Forcing that through the aggregate means either lazy-loading half the world or shoving denormalized fields into the write model where they do not belong.

CQRS just gives you permission to stop pretending one model can do both jobs. Write model stays a real domain model. Read model is a flat projection optimized for the screen. Same process, same database, two shapes. The fancy stuff is optional.

Two models, same database, one process

Here is what it looks like in practice. The write side is the DDD model you already have.

// domain/orders/order.ts
import { Money } from "../shared/money";
import { OrderLine } from "./order-line";
import { OrderConfirmed } from "./events";

export class Order {
  private events: DomainEvent[] = [];

  private constructor(
    readonly id: OrderId,
    readonly customerId: CustomerId,
    private status: "draft" | "confirmed" | "cancelled",
    private lines: OrderLine[],
  ) {}

  confirm(now: Date): void {
    if (this.status !== "draft") {
      throw new Error("only draft orders can be confirmed");
    }
    if (this.lines.length === 0) {
      throw new Error("cannot confirm empty order");
    }
    this.status = "confirmed";
    this.events.push(new OrderConfirmed(this.id, this.customerId, this.total(), now));
  }

  pullEvents(): DomainEvent[] {
    const out = this.events;
    this.events = [];
    return out;
  }

  total(): Money {
    return this.lines.reduce((acc, l) => acc.add(l.subtotal()), Money.zero("USD"));
  }
}

Nothing exotic. Aggregates, invariants, domain events queued on the instance. The repository persists the aggregate and hands the queued events to a dispatcher right after the transaction commits. That dispatcher is where the read side wakes up.

The read side is not an aggregate. It’s a table designed for a screen, plus a thin query object that hits it.

// read/orders/order-summary.query.ts
import { Pool } from "pg";

export interface OrderSummaryRow {
  orderId: string;
  customerId: string;
  customerName: string;
  totalMinor: number;
  currency: "USD" | "EUR" | "GBP" | "TRY";
  status: "draft" | "confirmed" | "cancelled";
  confirmedAt: string | null;
}

export class OrderSummaryQuery {
  constructor(private readonly db: Pool) {}

  async listForCustomer(customerId: string, limit = 50): Promise<OrderSummaryRow[]> {
    const { rows } = await this.db.query<OrderSummaryRow>(
      `select order_id        as "orderId",
              customer_id     as "customerId",
              customer_name   as "customerName",
              total_minor     as "totalMinor",
              currency,
              status,
              confirmed_at    as "confirmedAt"
         from order_summary
        where customer_id = $1
        order by confirmed_at desc nulls last
        limit $2`,
      [customerId, limit],
    );
    return rows;
  }
}

order_summary is a denormalized table. It has the customer name on it, even though the customer is a different aggregate. That’s intentional. Reads do not honor aggregate boundaries. Writes do.

Projecting from domain events

The piece that ties the two sides together is a projection. A domain event fires when the aggregate changes. A projection handler updates the read table. Same process, same transaction, no broker.

// read/orders/order-summary.projector.ts
import { PoolClient } from "pg";
import { OrderConfirmed, OrderCancelled } from "../../domain/orders/events";

export class OrderSummaryProjector {
  async on(event: DomainEvent, tx: PoolClient): Promise<void> {
    if (event instanceof OrderConfirmed) {
      await tx.query(
        `insert into order_summary
           (order_id, customer_id, customer_name, total_minor, currency, status, confirmed_at)
         select $1, c.id, c.full_name, $2, $3, 'confirmed', $4
           from customers c where c.id = $5
         on conflict (order_id) do update
           set total_minor = excluded.total_minor,
               status      = 'confirmed',
               confirmed_at = excluded.confirmed_at`,
        [event.orderId, event.total.amountMinor, event.total.currency, event.at, event.customerId],
      );
      return;
    }
    if (event instanceof OrderCancelled) {
      await tx.query(
        `update order_summary set status = 'cancelled' where order_id = $1`,
        [event.orderId],
      );
    }
  }
}

The dispatcher invokes projectors inside the same transaction that committed the aggregate write. If the projection fails, the write rolls back too. No eventual consistency. The read model is always exactly as fresh as the write model, because they are committed together.

That is the part most CQRS posts skip and most teams get burned by. You do not need Kafka to do CQRS. You need a dispatcher and a transaction.

When the in-process projection is wrong

Two cases where I broke it on purpose. The first was a regulated CPG manufacturer’s internal IT org, years ago. A reporting screen aggregated quality data across half a dozen plants. Synchronous projection would have meant every plant write touched a read table that was already three minutes behind on VACUUM. Split it off. Read model became a materialized view refreshed on a schedule. Screen got a “data as of 14:02” footer and the conversation ended.

The second was the trading platform I architected. Tick fan-out at around 10M concurrent connections is its own animal. The “current price by symbol” read model lived in Redis, not Postgres. Writes still went through the domain layer, projector pushed to Redis on commit. Postgres remained the system of record.

Default is a same-transaction projection in Postgres. Leave it there until you have a measured reason to escalate. Then move that one read model, not all of them.

Kafka topology, consumer group semantics, rebalance dynamics — that’s all distance between your write and your read. Distance is failure surface. Default to in-process projections for a DDD monolith. Buy the distributed topology only when you have a measured reason to.

Gradual migration per bounded context

CQRS does not require a big-bang. One bounded context at a time. Start with the context whose screens hurt the most. Build a read model alongside the existing aggregates. Wire a projector to the events you already publish. Keep the old query path live behind a flag for a week. Cut over. Move on. If a context has zero query pressure, skip it. CQRS without a queryable pain point is overhead.

Takeaways

  • Split reads from writes when your aggregates and your screens want different shapes. That is the whole reason.
  • Do it inside one process and one database first. Projections in the same transaction as the write give you fresh reads with no broker.
  • Domain events are the contract between the two sides. Keep them small, named after the business action, not the table.
  • Escalate one read model at a time. Materialized view, Redis, replica, separate service. Measured reason only.
  • Do CQRS per bounded context, not per project. Some contexts never need it.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.