Skip to content

How-to: CQRS Pattern ​

FT reference: FT233 (NENE2-FT/cqrslog) — CQRS Pattern API

Demonstrates Command Query Responsibility Segregation (CQRS): the write side accepts Commands and mutates the write model; the read side accepts Queries and reads from a denormalised read model (SQL VIEW). The two sides share the same SQLite database but have separate handler classes, separate model objects, and no shared state.


Core CQRS concepts ​

ConceptDescription
CommandAn intent to change state — PlaceOrderCommand, UpdateOrderStatusCommand
QueryA request for data — GetOrderSummaryQuery, ListOrderSummariesQuery
CommandHandlerExecutes a command against the write model (normalised tables)
QueryHandlerExecutes a query against the read model (denormalised view)
Write modelNormalised tables optimised for transactional writes
Read modelDenormalised view optimised for query output shape

Routes ​

MethodPathSideDescription
POST/ordersWritePlace a new order (command)
PATCH/orders/{id}/statusWriteUpdate order status (command)
GET/ordersReadList order summaries (query)
GET/orders/{id}ReadGet one order summary (query)

Command objects (write side) ​

Commands are immutable value objects that carry validated data into the handler:

php
final readonly class PlaceOrderCommand
{
    /**
     * @param list<array{product: string, quantity: int, unit_price: int}> $items
     */
    public function __construct(
        public string $customer,
        public array  $items,
    ) {
    }
}

final readonly class UpdateOrderStatusCommand
{
    public function __construct(
        public int    $orderId,
        public string $newStatus,
    ) {
    }
}

Commands hold no business logic — they are typed containers for the controller's validated input. Using readonly prevents mutation after construction.


Command handler (write model) ​

OrderCommandHandler owns all mutations. It writes to normalised tables:

php
final readonly class OrderCommandHandler
{
    public function __construct(private DatabaseQueryExecutorInterface $executor) {}

    public function place(PlaceOrderCommand $command, string $now): int
    {
        $orderId = $this->executor->insert(
            'INSERT INTO orders (customer, status, created_at, updated_at) VALUES (?, ?, ?, ?)',
            [$command->customer, 'pending', $now, $now],
        );

        foreach ($command->items as $item) {
            $this->executor->insert(
                'INSERT INTO order_items (order_id, product, quantity, unit_price) VALUES (?, ?, ?, ?)',
                [$orderId, $item['product'], $item['quantity'], $item['unit_price']],
            );
        }

        return $orderId;
    }

    public function updateStatus(UpdateOrderStatusCommand $command, string $now): bool
    {
        $affected = $this->executor->execute(
            'UPDATE orders SET status = ?, updated_at = ? WHERE id = ?',
            [$command->newStatus, $now, $command->orderId],
        );

        return $affected > 0;
    }
}

The handler returns primitive values (int orderId, bool success) — not read model objects. After a command succeeds, the controller re-queries the read side to get the response shape.


Query objects (read side) ​

Queries are typed wrappers for query parameters:

php
final readonly class GetOrderSummaryQuery
{
    public function __construct(public int $orderId) {}
}

final readonly class ListOrderSummariesQuery
{
    public function __construct(public ?string $status) {}
}

Wrapping query parameters in objects makes the query handler's contract explicit and avoids primitive-obsession across handler signatures.


Query handler (read model) ​

OrderQueryHandler reads from order_summary, a SQL VIEW that denormalises the join at the DB layer:

php
final readonly class OrderQueryHandler
{
    public function __construct(private DatabaseQueryExecutorInterface $executor) {}

    public function get(GetOrderSummaryQuery $query): ?OrderSummary
    {
        $rows = $this->executor->fetchAll(
            'SELECT * FROM order_summary WHERE id = ?',
            [$query->orderId],
        );

        return $rows !== [] ? $this->hydrate($rows[0]) : null;
    }

    /** @return list<OrderSummary> */
    public function list(ListOrderSummariesQuery $query): array
    {
        if ($query->status !== null) {
            $rows = $this->executor->fetchAll(
                'SELECT * FROM order_summary WHERE status = ? ORDER BY created_at DESC',
                [$query->status],
            );
        } else {
            $rows = $this->executor->fetchAll(
                'SELECT * FROM order_summary ORDER BY created_at DESC',
                [],
            );
        }

        return array_map($this->hydrate(...), $rows);
    }

    /** @param array<string, mixed> $row */
    private function hydrate(array $row): OrderSummary
    {
        return new OrderSummary(
            id:         (int) $row['id'],
            customer:   (string) $row['customer'],
            status:     (string) $row['status'],
            createdAt:  (string) $row['created_at'],
            itemCount:  (int) $row['item_count'],
            totalCents: (int) ($row['total_cents'] ?? 0),
        );
    }
}

OrderSummary is a read model DTO — it is never written to; it only represents a query result. Keeping it separate from any write-side Order entity prevents read-side concerns leaking into the write model.


Read model: SQL VIEW as denormalised projection ​

The read model is a SQLite VIEW that pre-computes the join and aggregation:

sql
CREATE VIEW IF NOT EXISTS order_summary AS
SELECT
    o.id,
    o.customer,
    o.status,
    o.created_at,
    COUNT(oi.id)                     AS item_count,
    SUM(oi.quantity * oi.unit_price) AS total_cents
FROM orders o
LEFT JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id;

The view provides a stable query surface — the query handler doesn't need to know about the normalised orders/order_items join. If the write model changes its table structure, only the view definition needs updating, not the query handler.

total_cents stores monetary amounts as integer cents (no floating-point rounding errors). ?? 0 guards against NULL when no items exist.


Write model schema ​

sql
CREATE TABLE IF NOT EXISTS orders (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    customer   TEXT    NOT NULL,
    status     TEXT    NOT NULL DEFAULT 'pending',
    created_at TEXT    NOT NULL,
    updated_at TEXT    NOT NULL
);

CREATE TABLE IF NOT EXISTS order_items (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    order_id   INTEGER NOT NULL REFERENCES orders(id),
    product    TEXT    NOT NULL,
    quantity   INTEGER NOT NULL,
    unit_price INTEGER NOT NULL
);

Write model is normalised: orders + order_items in a 1:N relationship. No computed columns — the read projection is in the view.


Controller: wiring commands and queries ​

After a write command succeeds, the controller uses the read side to build the response:

php
private function placeOrder(ServerRequestInterface $request): ResponseInterface
{
    // ... validate input ...
    $orderId = $this->commands->place(new PlaceOrderCommand($customer, $items), $now);

    // Re-query via read model to get the response shape
    $summary = $this->queries->get(new GetOrderSummaryQuery($orderId));

    return $this->json->create($summary->toArray(), 201);
}

This "command then query" pattern keeps the write side ignorant of response shape and ensures the response always reflects the view's projection (including computed fields like total_cents).

Item validation before the command:

php
foreach ($rawItems as $item) {
    if (!is_array($item)) continue;

    $product   = isset($item['product']) && is_string($item['product']) ? trim($item['product']) : '';
    $quantity  = isset($item['quantity']) && is_int($item['quantity']) && $item['quantity'] > 0 ? $item['quantity'] : 0;
    $unitPrice = isset($item['unit_price']) && is_int($item['unit_price']) && $item['unit_price'] >= 0 ? $item['unit_price'] : -1;

    if ($product === '' || $quantity === 0 || $unitPrice < 0) {
        return $this->json->create(['error' => 'each item needs product, quantity>0, unit_price>=0'], 422);
    }
    $items[] = ['product' => $product, 'quantity' => $quantity, 'unit_price' => $unitPrice];
}

is_int() strict check on quantity and unit_price rejects floats and strings from JSON. unit_price >= 0 allows zero (free items); quantity > 0 requires at least one.


When to use CQRS ​

CQRS adds structural overhead. Use it when:

  • The read and write data shapes diverge significantly (e.g. list needs aggregates the write model doesn't store)
  • Read load far exceeds write load and you want to scale them independently
  • The domain has complex write invariants (transactions, validation, domain events) that should be isolated from read optimisations
  • You are building toward event sourcing (CQRS pairs naturally with event-sourced write models)

Avoid CQRS when:

  • The read and write shapes are identical (a simple CRUD endpoint)
  • The codebase is small and the indirection outweighs the clarity benefit
  • The team is unfamiliar with the pattern (introduces cognitive overhead)

Released under the MIT License.