W dzisiejszym wpisie, opiszę, po co stosować, jak, kiedy oraz czym jest CQRS (Command Query Responsibility Segregation).
PS – wpis powstał w 2018 roku, na przestrzeni tych lat ja poznałem dużo lepiej tajniki programowania, projektowania aplikacji, powstał symfony/messenger i wiele innych paczek przydatnych do CQRS itd. W PHPie zresztą też się wiele zmieniło, dlatego traktuj ten wpis jako idee jak działa CQRS a nie wyznacznik technologiczny. Kiedyś postaram się zaktualizować ten wpis do najnowszych technologii i obecnej mojej wiedzy.
Wprowadzenie do CQRS
Zacznijmy od najważniejszej rzeczy, CQRS nie jest architekturą aplikacji, jest to wzorzec projektowy. Możecie usłyszeć wiele opinii w necie, lecz sam Greg Young, twórca CQRS potwierdził, że jest to zwykły wzorzec projektowy.
Ale po co go się stosuje? Im bardziej nasza aplikacja staje się złożona, tym coraz więcej mamy encji, repozytoriów itd. Jeżeli będziemy próbowali coś zmienić, stanie się to coraz trudniejsze, a co za tym idzie każda modyfikacja będzie coraz bardziej kosztowne i tutaj przychodzi nam z pomocą CQRS.
Głównym zadaniem CQRS jest ujednolicenie sposobu zapisu oraz odczytu. Poza tym dzięki takiemu podejściu spełniamy SOLID. Zyskujemy również większą skalowalność, bezpieczeństwo (pod kątem zmian) itd.
Jeżeli nadal Cię to nie przekonuje, wyobraź sobie projekt, którego cała logika biznesowa znajduję się w kontrolerach, a ty masz coś modyfikować, ciężko prawda? Może szybko zostanie stworzony, ale na dłuższy czas nie zda to egzaminu. Dodatkowo może się pojawić copy paste który spowoduje że jedna logika zostanie powielona w kilku miejsach.
PS drobna ciekawostka, przed powstaniem CQRS, jego poprzednikiem był CQS (Command Query Separation).
Przedstawię Ci teraz prosty diagram UML, który przedstawi przepływ operacji z pomocą wzorca projektowego CQRS.

Przy odczycie można zauważyć dwie gałęzie opcjonalne, pierwsza z nich jest to QueryBus (opisany niżej), drugi zaś Repository. Jeżeli Query dziedziczy z Repository, powinniśmy zwrócić uwagę na jedną rzecz. Dajemy możliwość deweloperowi pobrania encji, zapisu danych itd. Nikt z nas nie chciałby się raczej obudzić z flush’em w twigu… W takiej sytuacji możemy wyjść dając repozytorium w konstruktorze i wywoływać tylko te metody, które są nam potrzebne. Automatycznie blokujemy dostęp do metod takich jak “save”, “remove”, pobrania encji, etc. które możemy wykorzystać w commandach.
Co to jest Command, CommandBus, Handler?
Zaczniemy od omówienia czym jest Command. W telegraficznym skrócie przechowuje on dane (muszą one być zwalidowane) które są potrzebne do wykonania logiki biznesowej lub flow (Handlera). . Podsumowując możemy skrócić to do po prostu danych pochodzących z requesta (żądania HTTP np. POST, GET, PUT itd).
<?php
declare(strict_types=1);
namespace App\Command;
use App\Domain\Entity\Post;
class AddPostCommand implements CommandInterface
{
public function __construct(private readonly Post $post)
{
}
public function getPost(): Post
{
return $this->post;
}
}
Wspomniałem wcześniej o Command, więc gdzie on powinien trafić? Command zawsze musi trafić do CommandBus’a (istnieją języki w których pojęcie CommandBusa nie istnieje), którego zadaniem jest przydzielenie odpowiedniego Handler’a do Command’a. Jeżeli nasuwa Ci się pytanie czy CommandBus może zwracać wartość, od razu Ci odpowiem że nie! Tak samo jak Handler nie może on zwracać żadnej wartości. Możemy za jego pomocą zbierać różne dane i zapisywać je do logów. Ale jakie dane i po co? M.in wyrzucone wyjątki, czas wykonania logiki biznesowej etc. Zbierając te dane możemy zidentyfikować dany Handler któremu schodzi bardzo długo z wykonaniem logiki biznesowej i go zoptymalizować.
<?php
declare(strict_types=1);
namespace App\Service;
use App\Command\CommandInterface;
use App\Exception\HandlerNotFoundException;
use App\Handler\HandlerInterface;
use Psr\Container\ContainerInterface;
class CommandBus
{
public function __construct(private readonly ContainerInterface $container)
{
$this->container = $container;
}
public function handle(CommandInterface $command): void
{
$handler = $this->commandToHandler(\get_class($command));
$handler->handle($command);
}
private function commandToHandler(string $command): HandlerInterface
{
$commandHandler = str_replace('Command', 'Handler', $command);
$handler = $this->container->get($commandHandler);
if (empty($handler) || !$handler instanceof HandlerInterface) {
throw new HandlerNotFoundException('Handler not found from: ' . $command);
}
return $handler;
}
}
Teraz przejdźmy do Handler’a. Możemy go nazwać sercem tego wzorca projektowego, to tutaj znajduję się cała logika biznesowa lub flow aplikacji. Musisz pamiętać o najważniejszym, Handler nigdy nie może zwracać wartości!
Przyklad:
<?php
declare(strict_types=1);
namespace App\Handler;
use App\Command\CommandInterface;
use App\Exception\ValidateEntityException;
use App\Query\PostQuery;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class AddPostHandler implements HandlerInterface
{
public function __construct(
private readonly PostQuery $postQuery,
private readonly ValidatorInterface $validator
) {
}
public function handle(CommandInterface $command): void
{
$post = $command->getPost();
$post->setCreatedAt(new \DateTime('now', new \DateTimeZone('Europe/Warsaw')));
$post->setContent(htmlspecialchars($post->getContent()));
if (\count($this->validator->validate($post)) > 0) {
throw new ValidateEntityException('Validate failed in: ' . \get_class($post));
}
$this->postQuery->save($post);
}
}
Warstwa bazy danych czyli Query, ViewObject
Rozpoczniemy od ViewObject. Jest on zbiorem podobnie jak Command Setterów i Getterów zawierającym dane pozyskane z magazynu danych np. MySQL’a. Dzięki niemu, po wykonaniu zapytania otrzymujemy gotowy obiekt z potrzebnymi danymi który możemy bezpieczenie wyprowadzić do UI (User Interface) bez obawy o zapis w twigu. 😉 Nie musi on w 100% odzwierciedlać encji wręcz nie powinien bo w większości przypadków nie potrzebujemy wszystkich danych z encji.
<?php
declare(strict_types=1);
namespace App\Query\ViewObject;
use DateTime;
class PostViewObject
{
public function __construct(
private readonly DateTime $createdAt,
private readonly string $content,
private readonly string $title,
) {
}
public function getCreatedAt(): DateTime
{
return $this->createdAt;
}
public function getContent(): string
{
return $this->content;
}
public function getTitle(): string
{
return $this->title;
}
}
Okej, mamy ViewObject więc przyszedł czas na Query. Jest on jednym z głównych elementów komunikacji z bazą danych. To w tym miejscu są wykonywane zapytanie do bazy danych (przykładem może być wyszukanie użytkownika za pomocą id) oraz jest tworzony i zwracany ViewObject (tutaj może Ci pomóc operator new w doctrine ;)). Query zawsze będzie zwracać ViewObject. Musisz pamiętać również że Query i Repository pattern to nie to samo, w Query nie może się znajdować m.in. zapisywanie danych, pobieranie encji etc.
Przejdźmy więc do implementacji w której mamy dwie opcje. W Query mogą znajdować się wszystkie zapytanie (oczywiście odpowiednie dla niego czyli wyszukiwanie itd.) lub druga, może być warstwą abstrakcji komunikacji z Repository pattern. Wybór należy do Ciebie obie są poprawne. Przy implementacji Repository musisz pamiętać o hermetyzacji metod nieodpowiednich dla Query.
Przykład:
<?php
declare(strict_types=1);
namespace App\Query;
use App\Domain\Entity\Post;
interface PostQuery
{
public function getAll(): array;
}
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Domain\Entity\Post;
use App\Query\PostQuery;
use App\Query\ViewObject\PostViewObject;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Common\Persistence\ManagerRegistry;
class PostRepository extends ServiceEntityRepository implements PostQuery
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Post::class);
}
public function getAll(): array
{
//Możesz tu zastosować operator new od Doctrine
$posts = $this->createQueryBuilder('p')
->orderBy('p.createdAt', 'DESC')
->getQuery()
->execute()
;
if (empty($posts)) {
return [];
}
$postsView = [];
foreach ($posts as $post) {
$postsView[] = new PostViewObject(
$post->getCreatedAt(),
$post->getContent(),
$post->getTitle()
);
}
return $postsView;
}
public function remove(Post $post): void
{
$em = $this->getEntityManager();
$em->remove($post);
$em->flush();
}
public function save(Post $post): void
{
$em = $this->getEntityManager();
$em->persist($post);
$em->flush();
}
}
Ostatnim elementem omówionym w tym wpisie jest QueryBus który nie jest obowiązkowy w implementacji. Ma takie samo zadanie jak CommandBus, jedyną różnicą jest dobierania zamiast Handler’a Query.
Ale kiedy używać CQRS?
Jeżeli twoja aplikacja ma bardzo dużo logiki biznesowej, jest ciągle rozbudowywana itd. warto rozważyć wdrożenie CQRS, ale tak naprawdę to wszystko zależy, jak to bywa w IT. Nie da się określić kiedy go wdrażać a kiedy nie. Na pewno nie warto, moim zdaniem wdrażać go gdy mamy tylko akcje CRUD’owe. Jest to po prostu nieopłacalne, warto wtedy zainteresować się Symfony + APIPlatform, które jest genialne dla prostych CRUD’ów. Musimy pamiętać że CQRS to wzorzec projektowy więc możemy go wdrożyć tylko dla części naszej aplikacji, a nie dla całości.