PHP / Magento Dev Blog

  • Publikacje
  • O autorze
  • Kontakt

CQRS – Command Bus, Query Bus, read models, integracja z Magento 2

by Henryk Tews / wtorek, 07 maja 2024 / Opublikowano w Wzorce projektowe

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.

About Henryk Tews

Co możesz przeczytać następne

Wzorzec Factory Method – Simple Factory, GoF, auto-generowane fabryki Magento 2
Wzorce GoF w Magento 2 – gdzie je znaleźć i jak działają
Chain of Responsibility – łańcuch walidatorów, konfiguracja przez di.xml z sortOrder
  • Publikacje
  • O autorze
  • Kontakt

© 2026 Created by

GÓRA
Zarządzaj zgodą
Aby zapewnić jak najlepsze wrażenia, korzystamy z technologii, takich jak pliki cookie, do przechowywania i/lub uzyskiwania dostępu do informacji o urządzeniu. Zgoda na te technologie pozwoli nam przetwarzać dane, takie jak zachowanie podczas przeglądania lub unikalne identyfikatory na tej stronie. Brak wyrażenia zgody lub wycofanie zgody może niekorzystnie wpłynąć na niektóre cechy i funkcje.
Funkcjonalne Zawsze aktywne
Przechowywanie lub dostęp do danych technicznych jest ściśle konieczny do uzasadnionego celu umożliwienia korzystania z konkretnej usługi wyraźnie żądanej przez subskrybenta lub użytkownika, lub wyłącznie w celu przeprowadzenia transmisji komunikatu przez sieć łączności elektronicznej.
Preferencje
Przechowywanie lub dostęp techniczny jest niezbędny do uzasadnionego celu przechowywania preferencji, o które nie prosi subskrybent lub użytkownik.
Statystyka
Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do celów statystycznych. Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do anonimowych celów statystycznych. Bez wezwania do sądu, dobrowolnego podporządkowania się dostawcy usług internetowych lub dodatkowych zapisów od strony trzeciej, informacje przechowywane lub pobierane wyłącznie w tym celu zwykle nie mogą być wykorzystywane do identyfikacji użytkownika.
Marketing
Przechowywanie lub dostęp techniczny jest wymagany do tworzenia profili użytkowników w celu wysyłania reklam lub śledzenia użytkownika na stronie internetowej lub na kilku stronach internetowych w podobnych celach marketingowych.
  • Zarządzaj opcjami
  • Zarządzaj serwisami
  • Zarządzaj {vendor_count} dostawcami
  • Przeczytaj więcej o tych celach
Zobacz preferencje
  • {title}
  • {title}
  • {title}