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.
