PHP / Magento Dev Blog

  • Publikacje
  • O autorze
  • Kontakt

CQRS – Command Bus, Query Bus, read models, Magento 2 integration

by Henryk Tews / Tuesday, 07 May 2024 / Published in Wzorce projektowe

CQRS (Command Query Responsibility Segregation) is an architectural pattern that separates write operations (Commands) from read operations (Queries). This separation enables independent optimisation of reads and writes, event sourcing, and eventually consistent read models. I show an implementation with CommandBus and QueryBus, apply it to a Magento 2 module, and explain when the added complexity is justified.

Core concept

<?php

// Without CQRS: one service does everything
class ProductService
{
    public function getProduct(int $id): array { /* reads */ }
    public function updatePrice(int $id, float $price): void { /* writes */ }
    public function getProductsByCategory(int $catId): array { /* reads */ }
    public function deleteProduct(int $id): void { /* writes */ }
    // Grows without limit, hard to optimise reads vs writes separately
}

// With CQRS: commands for writes, queries for reads
// Commands change state, return nothing (or just an ID)
// Queries return data, change nothing

Command side – writes

<?php

declare(strict_types=1);

// Commands are plain DTOs - intent + data, no logic
final readonly class UpdateProductPriceCommand
{
    public function __construct(
        public int $productId,
        public float $newPrice,
        public int $adminUserId,
        public string $reason,
    ) {}
}

final readonly class CreateProductCommand
{
    public function __construct(
        public string $sku,
        public string $name,
        public float $price,
        public int $categoryId,
    ) {}
}

// Command handlers contain the write logic
class UpdateProductPriceHandler
{
    public function __construct(
        private \Magento\Catalog\Api\ProductRepositoryInterface $productRepository,
        private PriceChangeAuditRepository $auditRepository,
        private \Psr\Log\LoggerInterface $logger
    ) {}

    public function handle(UpdateProductPriceCommand $command): void
    {
        $product = $this->productRepository->getById($command->productId);
        $oldPrice = (float) $product->getPrice();

        $product->setPrice($command->newPrice);
        $this->productRepository->save($product);

        // Write side can do multiple things atomically
        $this->auditRepository->save(new PriceChangeAudit(
            productId: $command->productId,
            oldPrice:  $oldPrice,
            newPrice:  $command->newPrice,
            adminId:   $command->adminUserId,
            reason:    $command->reason,
            changedAt: new \DateTimeImmutable(),
        ));

        $this->logger->info('Price updated', [
            'product_id' => $command->productId,
            'old_price'  => $oldPrice,
            'new_price'  => $command->newPrice,
        ]);
    }
}

// Command Bus - routes commands to handlers
class CommandBus
{
    private array $handlers = [];

    public function register(string $commandClass, object $handler): void
    {
        $this->handlers[$commandClass] = $handler;
    }

    public function dispatch(object $command): void
    {
        $class = get_class($command);
        $handler = $this->handlers[$commandClass ?? $class]
            ?? throw new \RuntimeException("No handler for: {$class}");
        $handler->handle($command);
    }
}

Query side – reads

<?php

declare(strict_types=1);

// Queries are also DTOs - describe what we want to read
final readonly class GetProductsByPriceRangeQuery
{
    public function __construct(
        public float $minPrice,
        public float $maxPrice,
        public int $storeId = 1,
        public int $page = 1,
        public int $pageSize = 20,
    ) {}
}

// Query result - a read model, optimised for display
final readonly class ProductListItem
{
    public function __construct(
        public int $id,
        public string $sku,
        public string $name,
        public float $price,
        public string $imageUrl,
        public bool $isInStock,
    ) {}
}

// Query handlers read from a potentially denormalised read model
class GetProductsByPriceRangeHandler
{
    public function __construct(
        private \Magento\Framework\App\ResourceConnection $resourceConnection
    ) {}

    /** @return ProductListItem[] */
    public function handle(GetProductsByPriceRangeQuery $query): array
    {
        // Read model query - can use a denormalised view or flattened table
        // optimised purely for this read use case
        $connection = $this->resourceConnection->getConnection();

        $select = $connection->select()
            ->from(
                ['p' => 'catalog_product_flat_' . $query->storeId],
                ['entity_id', 'sku', 'name', 'price', 'image']
            )
            ->join(
                ['stock' => 'cataloginventory_stock_status'],
                'stock.product_id = p.entity_id AND stock.stock_id = 1',
                ['stock_status']
            )
            ->where('p.price >= ?', $query->minPrice)
            ->where('p.price <= ?', $query->maxPrice)
            ->where('p.status = ?', 1)
            ->limit($query->pageSize, ($query->page - 1) * $query->pageSize)
            ->order('p.price ASC');

        return array_map(
            fn($row) => new ProductListItem(
                id:       (int) $row['entity_id'],
                sku:      $row['sku'],
                name:     $row['name'],
                price:    (float) $row['price'],
                imageUrl: $row['image'] ?? '',
                isInStock: (int) $row['stock_status'] === 1,
            ),
            $connection->fetchAll($select)
        );
    }
}

// Query Bus
class QueryBus
{
    private array $handlers = [];

    public function register(string $queryClass, object $handler): void
    {
        $this->handlers[$queryClass] = $handler;
    }

    public function ask(object $query): mixed
    {
        $class = get_class($query);
        return ($this->handlers[$class] ?? throw new \RuntimeException("No handler for: {$class}"))
            ->handle($query);
    }
}

Integration in Magento 2 di.xml

<config>
    <type name="Vendor\Module\Bus\CommandBus">
        <arguments>
            <argument name="handlers" xsi:type="array">
                <item name="Vendor\Module\Command\UpdateProductPriceCommand" xsi:type="array">
                    <item name="handler" xsi:type="object">Vendor\Module\Handler\UpdateProductPriceHandler</item>
                </item>
                <item name="Vendor\Module\Command\CreateProductCommand" xsi:type="array">
                    <item name="handler" xsi:type="object">Vendor\Module\Handler\CreateProductHandler</item>
                </item>
            </argument>
        </arguments>
    </type>
</config>

When CQRS is worth the complexity

Criterion CQRS fits CQRS is overkill
Read/write ratio Very asymmetric (reads >> writes or vice versa) Roughly equal
Domain complexity Complex business rules on writes Simple CRUD
Performance Reads and writes need independent optimisation Standard DB queries are fine
Audit trail Every change must be tracked Not required
Team size Multiple developers, clear boundaries needed Solo or small team

Summary

CQRS is not a silver bullet – it adds real complexity through bus infrastructure, separate handlers, and read model maintenance. The payoff is clear separation of concerns, independent scalability of reads and writes, and a natural foundation for audit logging and event sourcing. In Magento 2 context, it makes most sense in modules with complex business logic (pricing, B2B workflows, inventory management) where the traditional one-service-does-everything approach becomes unmanageable.

About Henryk Tews

What you can read next

Memento pattern – undo/redo, price history, DB persistence as audit log
Observer and Strategy in PHP – behavioural patterns
Flyweight pattern – object sharing, instance cache, Magento 2

© 2026 Created by

TOP
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 Always active
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.
  • Manage options
  • Manage services
  • Manage {vendor_count} vendors
  • Read more about these purposes
Zobacz preferencje
  • {title}
  • {title}
  • {title}