This project showcases a list-making Web API using Hexagonal Architecture, CQRS, Event Sourcing and DDD.
Featuring
Aggregates, Value Objects, Domain Events and other DDD building blocks
Eventual Consistency
All sorts of tests: unit, integration, functional and acceptance with BDD
PHP and Symfony Framework
A frontend app is now available here!
Install Docker and Docker Compose.
- Clone the repository:
$ git clone git@github.com:renan-taranto/cqrs-event-sourcing-example.git
- Move to the project directory:
$ cd cqrs-event-sourcing-example
- Build the containers and install dependencies with:
$ make install
Run:
$ make start
It will run on port 80, check the Documentation section to know how to use it.
Swagger UI is available at /doc. It describes how all endpoints works.
- Create a board by sending a
POST
request to /boards
# POST http://127.0.0.1/boards
{
"id": "c439b9ba-b8f2-47c0-b369-872214668d7d",
"title": "Kanban"
}
IDs are Version 4 UUIDs. In order to test the API manually, you can generate them at https://www.uuidgenerator.net/version4.
- Add a list to the board with a
POST
request to /lists
# POST http://127.0.0.1/lists
{
"id": "71340b94-c02f-4bef-9989-9486d68cf28e",
"title": "To Do",
"boardId": "c439b9ba-b8f2-47c0-b369-872214668d7d"
}
- Add an item to the list with a
POST
request to /items
# POST http://127.0.0.1/items
{
"id": "e1620255-88bf-4f0b-9793-4cc35e2513d7",
"title": "[Feature] Item Labels",
"listId": "71340b94-c02f-4bef-9989-9486d68cf28e"
}
- See the result with a
GET
request to /boards/c439b9ba-b8f2-47c0-b369-872214668d7d
# GET http://127.0.0.1/boards/c439b9ba-b8f2-47c0-b369-872214668d7d
{
"id": "c439b9ba-b8f2-47c0-b369-872214668d7d",
"title": "Kanban",
"open": true,
"lists": [
{
"id": "71340b94-c02f-4bef-9989-9486d68cf28e",
"title": "To Do",
"items": [
{
"id": "e1620255-88bf-4f0b-9793-4cc35e2513d7",
"title": "[Feature] Item Labels",
"description": ""
}
],
"archivedItems": []
}
],
"archivedLists": []
}
Many other endpoints are available. Check the Swagger UI at http://127.0.0.1/doc/ to learn more.
Run all test suites:
$ make test-all
Generate a code coverage report:
$ make test-coverage
The report can be found at "tests/etc/_output/coverage".
Each test suite can also be executed separately:
$ make test-unit
$ make test-integration
$ make test-functional
$ make test-api
This section is about the project's patterns and design.
Source code files are grouped by feature:
- src
- Board
- Application
- Domain
- Infrastructure
- Item
- Application
- Domain
- Infrastructure
- ...
- Board
There's also the Shared
namespace that stores the user interface layer (which is decoupled from all features) and components like the event store.
This topic explains how the most relevant components of each layer in the hexagonal architecture works and how they relate to each other. The layers will be presented in the Flow of Control order, from the User Interface to the Infrastructure, passing through the Application and the Domain.
In the User Interface layer there are two web controllers used by the outside world to "drive" the application: CommandController and QueryController. These are called Primary or Driving Adapters in the hexagonal architecture context.
The write model web controller. It deserializes HTTP requests into commands and dispatches them using the command bus.
This single class is able to dispatch all existing commands, so no other controller is needed for the write model.
That's done by declaring the command class
associated to the route in the command_class
route attribute:
# config/routes/board.yml
command::create-board:
path: /
methods: POST
defaults:
_controller: Taranto\ListMaker\Shared\Ui\Web\Controller\CommandController
_format: json
command_class: Taranto\ListMaker\Board\Application\Command\CreateBoard
The read model web controller. It uses the query bus to dispatch queries.
Just like the CommandController
, this controller is able to handle all queries thanks to a route attribute. In this case the attribute
is called query_class
:
# config/routes/board.yml
query::boards-overview:
path: /
methods: GET
defaults:
_controller: Taranto\ListMaker\Shared\Ui\Web\Controller\QueryController
_format: json
query_class: Taranto\ListMaker\Board\Application\Query\BoardsOverview
Code that drives the business core. Command and query classes can be found here alongside their handlers (Primary/Driving Ports of the application).
Write model use cases and their handlers. Take the MoveItemHandler
as an example:
namespace Taranto\ListMaker\Item\Application\Command;
// ...
final class MoveItemHandler
{
private $itemRepository;
public function __construct(ItemRepository $itemRepository)
{
$this->itemRepository = $itemRepository;
}
public function __invoke(MoveItem $command): void
{
$item = $this->itemRepository->get($command->aggregateId());
if ($item === null) {
throw ItemNotFound::withItemId($command->aggregateId());
}
$item->move($command->position(), $command->listId());
$this->itemRepository->save($item);
}
}
Most other handlers follows the workflow seen above:
- Load the aggregate;
- Change the aggregate by calling its methods;
- Save the aggregate.
Read model use cases and their handlers. A query handler returns results by using Finder classes.
namespace Taranto\ListMaker\Board\Application\Query;
// ...
final class BoardByIdHandler
{
private $boardFinder;
public function __construct(BoardFinder $boardFinder)
{
$this->boardFinder = $boardFinder;
}
public function __invoke(BoardById $query): ?array
{
return $this->boardFinder->byId($query->boardId());
}
}
Finders are used by query handlers to query the projections. They are Secondary/Driven Ports, and their implementations (adapters) are defined in the infrastructure layer.
namespace Taranto\ListMaker\Board\Application\Query\Finder;
interface BoardFinder
{
public function openBoards(): array;
public function closedBoards(): array;
public function byId(string $boardId): ?array;
}
Command and query validation is done by the command bus validation middleware.
The Domain layer contains the event sourced domain model. Many of the DDD building blocks like Aggregates, Repositories and Value Objects are used.
Aggregate classes extends the AggregateRoot, which provides the following methods:
// src/Shared/Domain/Aggregate/AggregateRoot.php
/**
* Instantiates the aggregate from an event stream. For each event the "apply" method is called.
*/
public static function reconstituteFrom(AggregateHistory $aggregateHistory): self { /* ... */ }
/**
* Stores an event and calls "apply".
*/
protected function recordThat(DomainEvent $event): void { /* ... */ }
/**
* Changes the aggregate state according to the domain event.
*/
protected function apply(DomainEvent $event): void { /* ... */ }
/**
* Pops and returns all stored events.
*/
public function popRecordedEvents(): DomainEvents { /* ... */ }
When an aggregate public method like changeTitle
is called, a domain event is created, recorded and applied. Applying an event
calls a when
method like whenBoardTitleChanged
:
// src/Board/Domain/Board.php
public function changeTitle(Title $title): void
{
// ...
$this->recordThat(
new BoardTitleChanged((string) $this->aggregateId, (string) $title)
);
}
public function whenBoardTitleChanged(BoardTitleChanged $event): void
{
$this->title = $event->title();
}
The project has 3 aggregates right now: Board
, ItemList
and Item
. It's worth noting that ItemList
and Item
at
first glance should be entities belonging to the Board
aggregate, but that would lead us to concurrence issues:
we don't want to lock the whole board when a user is working on an item, for example.
Aggregate repositories are write model repositories, so they contain only two methods: save
and get
.
namespace Taranto\ListMaker\Board\Domain;
interface BoardRepository
{
public function save(Board $board): void;
public function get(BoardId $boardId): ?Board;
}
Almost all aggregate's properties are implemented as value objects. By being immutable, self-validating and meaningful, they contribute to the creation of a strong domain model.
namespace Taranto\ListMaker\Shared\Domain\ValueObject;
final class Position
{
private $position;
public static function fromInt(int $position): self
{
return new self($position);
}
private function __construct(int $position)
{
if ($position < 0) {
throw new \InvalidArgumentException('Position must be greater than or equals 0.');
}
$this->position = $position;
}
public function toInt(): int
{
return $this->position;
}
public function equals(Position $other): bool
{
return $this->position === $other->position;
}
}
Components that connects the core code to databases, third party tools and libraries.
There is a repository implementation for each aggregate. Persistence is delegated to the AggregateRepository.
namespace Taranto\ListMaker\Board\Infrastructure\Persistence\Repository;
// ...
final class BoardRepository implements BoardRepositoryInterface
{
private $aggregateRepository;
public function __construct(AggregateRepository $aggregateRepository)
{
$this->aggregateRepository = $aggregateRepository;
}
public function save(Board $board): void
{
$this->aggregateRepository->save($board);
}
public function get(BoardId $boardId): ?Board
{
return $this->aggregateRepository->get(Board::class, $boardId);
}
}
The project features a MySql event store implementation that uses Optimistic Concurrency Control to persist the events.
// src/Shared/Infrastructure/Persistence/EventStore/MySqlEventStore.php
public function commit(IdentifiesAggregate $aggregateId, DomainEvents $events, AggregateVersion $expectedVersion): void
{
try {
$this->connection->beginTransaction();
$this->checkForConcurrency($aggregateId, $expectedVersion);
$this->insertDomainEvents($events, $expectedVersion);
$this->connection->commit();
} catch (\Exception $e) {
$this->connection->rollBack();
throw $e;
}
}
The AggregateRepository uses the event store to persist the events and to reconstitute the aggregates from them.
Every projector extends the Projector and listens to domain events. They hold MongoDB Collection classes to persist the documents.
// src/Board/Infrastructure/Persistence/Projection/BoardProjector.php
protected function projectBoardCreated(BoardCreated $event): void
{
$this->boardsCollection->insertOne([
'id' => (string) $event->aggregateId(),
'title' => (string) $event->title(),
'open' => true,
'lists' => [],
'archivedLists' => []
]);
}
Every project use case is either a command or a query, allowing independent scaling between read and write workloads, optimized queries and high scalability through Eventual Consistency.
There are 3 message buses:
- command.bus: Used by the write model to dispatch the commands;
- query.bus: Used by the read model to retrieve the query results;
- event.bus: Propagates the write model events so the read model can be updated.
Once the domain events are persisted by the event store, they are asynchronously dispatched through the event bus. These events are added as messages on a RabbitMQ queue and consumed by the projectors through the workers.
The workers runs on a dedicated docker container called "events_consumer" in the docker-compose file, and the message consumption process is easily achieved with Supervisor and Symfony's messenger component.
See how the domain events are dispatched in the MessageDispatcherEventStore.
Check the Supervisor config here.
Take the response from the endpoint shown in the step 4 of the Quick Start as an example. It returns a board, with all of its lists and items (which are different aggregates), without doing any kind of joins or other expensive operations. There's also the GET /boards endpoint, which works in the same way and returns an overview of all boards:
# GET /boards
[
{
"id": "09dd6677-2d55-435d-b069-940b77fdd82c",
"title": "Backlog",
"open": true
},
{
"id": "a24f5503-c2ba-4e98-9cab-9e415efa84d1",
"title": "Sprint 21",
"open": true
},
{
"id": "2431e111-5386-438b-9da6-83d40dde159f",
"title": "Legacy",
"open": false
}
]
These data (called projections) are modeled to satisfy the front-end needs, so it won't have a hard time parsing the payload or sending multiple HTTP requests to the server in order to render the board and its contents. They can be easily created by adding event handlers (projectors) that listens to domain events.
See the BoardProjector and the BoardOverviewProjector.
This project is inspired by many books, articles and other projects. Specially by:
- Implementing Domain-Driven Design by Vaughn Vernon;
- Domain-Driven Design in PHP by Carlos Buenosvinos, Christian Soronellas, and Keyvan Akbary;
- prooph;
- Buttercup.Protects.
Renan Taranto (renantaranto@gmail.com)