CQRS (Command Query Responsibility Segregation) to wzorzec architektoniczny który rozdziela operacje odczytu od operacji zapisu. Brzmi jak akademicki abstrakt, ale rozwiązuje realny problem: model danych zoptymalizowany pod zapis (relacyjny, znormalizowany, z walidacją) jest zwykle beznadziejny do odczytu (wiele joinów, skomplikowane mapowanie). CQRS pozwala mieć dwa osobne modele – jeden do zapisu, drugi do odczytu.
Problem który CQRS rozwiązuje
<?php
// Klasyczne Repository - jeden model do wszystkiego
class OrderRepository
{
// Odczyt dla wyświetlenia listy zamówień na dashboardzie klienta
// Potrzebujemy: id, data, status, total, lista SKU, imię klienta
public function getCustomerOrders(int $customerId): array
{
// 4 JOIN-y żeby zebrać dane z kilku tabel
// Model zwraca ciężkie obiekty Order z ładowanymi relacjami
// 80% danych z każdego obiektu nigdy nie trafia do widoku
}
// Zapis nowego zamówienia - potrzebuje pełnej walidacji i logiki domenowej
public function save(Order $order): void
{
// Walidacja business rules, sprawdzanie stanów magazynowych,
// aktualizacja wielu tabel, emisja eventów
}
}
// Problem: optymalizacja odczytu i zapisu stoją ze sobą w sprzeczności
// Ciężki model domenowy świetny do walidacji, słaby do szybkiego odczytu
Podstawy CQRS – Commands i Queries
<?php
declare(strict_types=1);
// Command - operacja zmieniająca stan systemu
// Zwraca void lub ID nowo stworzonej encji - nigdy danych
interface CommandInterface {}
// Query - operacja odczytu
// Nigdy nie zmienia stanu systemu
interface QueryInterface {}
// Command Handler - wykonuje operację zapisu
interface CommandHandlerInterface
{
public function handle(CommandInterface $command): mixed;
}
// Query Handler - wykonuje operację odczytu
interface QueryHandlerInterface
{
public function handle(QueryInterface $query): mixed;
}
// Command Bus - dispatchuje komendy do odpowiednich handlerów
class CommandBus
{
/** @var array<class-string, CommandHandlerInterface> */
private array $handlers = [];
public function register(string $commandClass, CommandHandlerInterface $handler): void
{
$this->handlers[$commandClass] = $handler;
}
public function dispatch(CommandInterface $command): mixed
{
$class = get_class($command);
if (!isset($this->handlers[$class])) {
throw new \RuntimeException("No handler for command: {$class}");
}
return $this->handlers[$class]->handle($command);
}
}
// Query Bus - dispatchuje zapytania do odpowiednich handlerów
class QueryBus
{
/** @var array<class-string, QueryHandlerInterface> */
private array $handlers = [];
public function register(string $queryClass, QueryHandlerInterface $handler): void
{
$this->handlers[$queryClass] = $handler;
}
public function ask(QueryInterface $query): mixed
{
$class = get_class($query);
if (!isset($this->handlers[$class])) {
throw new \RuntimeException("No handler for query: {$class}");
}
return $this->handlers[$class]->handle($query);
}
}
Strona Commands – pełny model domenowy
<?php
declare(strict_types=1);
// Konkretne komendy - immutable DTO z danymi wejściowymi
readonly class PlaceOrderCommand implements CommandInterface
{
public function __construct(
public int $customerId,
public array $items, // [['sku' => 'X', 'qty' => 2, 'price' => 29.99], ...]
public string $shippingMethod,
public string $paymentMethod,
public ?string $couponCode = null
) {}
}
readonly class CancelOrderCommand implements CommandInterface
{
public function __construct(
public int $orderId,
public int $requestedByUserId,
public string $reason
) {}
}
// Handler dla PlaceOrderCommand - pełna logika domenowa
class PlaceOrderHandler implements CommandHandlerInterface
{
public function __construct(
private OrderRepository $orderRepository,
private InventoryService $inventoryService,
private PricingService $pricingService,
private EventDispatcher $eventDispatcher
) {}
public function handle(CommandInterface $command): int
{
assert($command instanceof PlaceOrderCommand);
// Walidacja dostępności produktów
foreach ($command->items as $item) {
if (!$this->inventoryService->isAvailable($item['sku'], $item['qty'])) {
throw new \RuntimeException("Product {$item['sku']} out of stock");
}
}
// Oblicz ceny z rabatem
$total = $this->pricingService->calculateTotal(
$command->items,
$command->couponCode
);
// Stwórz obiekt domenowy
$order = Order::create(
customerId: $command->customerId,
items: $command->items,
total: $total,
shippingMethod: $command->shippingMethod,
paymentMethod: $command->paymentMethod
);
// Zapis
$this->orderRepository->save($order);
// Zarezerwuj stany magazynowe
$this->inventoryService->reserve($order);
// Emituj event - Observer może wysłać email, zaktualizować ERP itd.
$this->eventDispatcher->dispatch(new OrderPlacedEvent($order->getId()));
return $order->getId();
}
}
Strona Queries – zoptymalizowane projekcje danych
<?php
declare(strict_types=1);
// Query - opisuje czego szukamy i jak posortowane/paginowane
readonly class GetCustomerOrdersQuery implements QueryInterface
{
public function __construct(
public int $customerId,
public int $page = 1,
public int $perPage = 20,
public string $status = 'all'
) {}
}
// Read Model - lekkie DTO zoptymalizowane pod konkretny widok
readonly class OrderSummaryDto
{
public function __construct(
public int $id,
public string $incrementId,
public string $status,
public float $grandTotal,
public string $createdAt,
public array $skus, // tylko SKU bez pełnych danych produktów
public int $itemCount
) {}
}
// Query Handler - bezpośrednie zapytanie SQL zoptymalizowane pod odczyt
// Nie używa Repository ani modeli domenowych - prosto do bazy
class GetCustomerOrdersHandler implements QueryHandlerInterface
{
public function __construct(
private \PDO $pdo
) {}
public function handle(QueryInterface $query): array
{
assert($query instanceof GetCustomerOrdersQuery);
$offset = ($query->page - 1) * $query->perPage;
$sql = '
SELECT
o.entity_id,
o.increment_id,
o.status,
o.grand_total,
o.created_at,
COUNT(oi.item_id) AS item_count,
GROUP_CONCAT(oi.sku ORDER BY oi.item_id SEPARATOR ",") AS skus
FROM sales_order o
JOIN sales_order_item oi ON o.entity_id = oi.order_id
AND oi.parent_item_id IS NULL
WHERE o.customer_id = :customer_id
';
if ($query->status !== 'all') {
$sql .= ' AND o.status = :status';
}
$sql .= '
GROUP BY o.entity_id
ORDER BY o.created_at DESC
LIMIT :limit OFFSET :offset
';
$stmt = $this->pdo->prepare($sql);
$stmt->bindValue(':customer_id', $query->customerId, \PDO::PARAM_INT);
$stmt->bindValue(':limit', $query->perPage, \PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, \PDO::PARAM_INT);
if ($query->status !== 'all') {
$stmt->bindValue(':status', $query->status);
}
$stmt->execute();
return array_map(
fn(array $row) => new OrderSummaryDto(
id: (int) $row['entity_id'],
incrementId: $row['increment_id'],
status: $row['status'],
grandTotal: (float) $row['grand_total'],
createdAt: $row['created_at'],
skus: explode(',', $row['skus']),
itemCount: (int) $row['item_count']
),
$stmt->fetchAll(\PDO::FETCH_ASSOC)
);
}
}
Integracja w kontrolerze / API
<?php
declare(strict_types=1);
class OrderController
{
public function __construct(
private CommandBus $commandBus,
private QueryBus $queryBus
) {}
// GET /api/orders?page=1 - Query
public function index(Request $request): JsonResponse
{
$orders = $this->queryBus->ask(new GetCustomerOrdersQuery(
customerId: $request->getCustomerId(),
page: (int) $request->query->get('page', 1),
status: $request->query->get('status', 'all')
));
return new JsonResponse(array_map(
fn(OrderSummaryDto $dto) => [
'id' => $dto->id,
'increment_id' => $dto->incrementId,
'status' => $dto->status,
'total' => $dto->grandTotal,
'items' => $dto->itemCount,
'skus' => $dto->skus,
],
$orders
));
}
// POST /api/orders - Command
public function store(Request $request): JsonResponse
{
$orderId = $this->commandBus->dispatch(new PlaceOrderCommand(
customerId: $request->getCustomerId(),
items: $request->json('items'),
shippingMethod: $request->json('shipping_method'),
paymentMethod: $request->json('payment_method'),
couponCode: $request->json('coupon_code')
));
return new JsonResponse(['order_id' => $orderId], 201);
}
}
CQRS w Magento 2 – czy to się opłaca?
Magento 2 ma już częściowy CQRS przez Service Contracts: Repository do zapisu przez save() i zapytania przez getList() z SearchCriteria. Pełne CQRS z osobnymi handlerami warto rozważyć gdy:
- Masz złożone raporty które przez Repository z EAV są wolne
- Piszesz własny moduł który będzie intensywnie odpytywany
- Chcesz osobne źródło odczytu (np. Elasticsearch jako read store)
- Testowalność kodu jest priorytetem – handlery są trywialne do testowania
Podsumowanie
CQRS to nie silver bullet – dla prostych CRUD-ów to over-engineering. Wartość pojawia się gdy modele do odczytu i zapisu mają różne wymagania: zapis wymaga walidacji i logiki domenowej, odczyt wymaga wydajnych, denormalizowanych projekcji. Separacja przez Command Bus i Query Bus sprawia że każdy handler robi jedną rzecz i jest trywialny do testowania.
