DDD/CQRS/ES
with prooph
Who am I
@steph_py
architect @
Today
Quick view of theses concepts
Implements it step by step
Be pragmatic, use only what you need
Domain
Driven
Design
Ubiquitous Language
File structure
Application
Domain
Infra
UI
Controllers, Console commands, Form types, DTOs
Symfony Kernel, CQRS Commands/Handlers, ...
Your domain logic ... the most important part.
Doctrine, Redis, Guzzle, ....
File structure
.
├── App
│ ├── Command
│ │ └── CreateUserCommand.php
│ ├── CommandHandler
│ │ └── CreateUser.php
│ └── SymfonyBundle
│ └── MyBundle.php
├── Domain
│ ├── User.php
│ └── UserRepositoryInterface.php
├── Infra
│ └── DoctrineORM
│ ├── Resources
│ │ └── User.orm.xml
│ └── UserRepository.php
└── UI
├── Controller
│ └── UserController.php
└── Rest
└── UserNormalizer.php
Model driven design
The model should express your domain
class User
{
private $firstname;
private $lastname;
private $enabled;
public function setFirstname(string $firstname): User
{
$this->firstname = $firstname;
return $this;
}
public function getFirstname(): string
{
return $this->firstname;
}
public function setLastname(string $lastname): User
{
$this->lastname = $lastname;
return $this;
}
public function getLastname(): string
{
return $this->lastname;
}
public function setEnabled(bool $enabled): User
{
$this->enabled = $enabled;
return $this;
}
public function getEnabled(): bool
{
return $this->enabled;
}
}
<?php
// user creation
$user = new User();
$user->setFirstname('Chuck');
$user->setLastname('Norris');
$user->setEnabled(false);
// user rename
$user->setFirstname('Bruce');
$user->setLastname('Lee');
// user enable
$user->setEnabled(true);
User state can be inconsistent
Do not express the intention
SGBD automatism ?
class User
{
private $firstname;
private $lastname;
private $enabled;
public function __construct(string $firstname, string $lastname)
{
$this->firstname = $firstname;
$this->lastname = $lastname;
$this->enabled = false;
}
public function rename(string $firstname, string $lastname): void
{
$this->firstname = $firstname;
$this->lastname = $lastname;
}
public function enable(): void
{
$this->enabled = true;
}
public function getFirstname(): string
{
return $this->firstname;
}
public function getLastname(): string
{
return $this->lastname;
}
public function isEnabled(): bool
{
return $this->enabled;
}
}
<?php
// user creation
$user = new User('Chuck', 'Norris');
// user rename
$user->rename('Bruce', 'Lee');
// user enable
$user->enable();
User state is always consistent
Express the domain
At this moment, we can't disable an user ? It's ok ...
Can't have auto-generated code
Please ... don't be lazy
Value object
- Model without an identity.
- Immutable state
- Edition should create a new instance
- Some examples: (dates, prices, addresses, ...)
$start = new \DateTime('08:00:00');
$end = $start->modify('12:00:00');
var_dump($start); // ... 12:00:00 <--- OOPS
$start = new \DateTimeImmutable('08:00:00');
$end = $start->modify('12:00:00');
var_dump($start); // ... 08:00:00
Entity
- Model with an identity.
- Avoid auto-generated Ids
use Ramsey\Uuid\UuidInterface;
class User
{
private $id;
private $firstname;
public function __construct(UuidInterface $id, $firstname, ...)
{
$this->id = $id;
$this->firstname = $firstname;
}
}
Aggregate
Group models around an aggregate root
Create relation only between aggregate roots
EventSourcing needs
Bounded contexts
It's a subdomain
Bounded contexts
Models can be different between bounded contexts
Microservice approach
Isolated: A model cannot communicate with a model outside of its BC.
Communication between BC via Events/Commands/Queries (SAGA)
Models are lighter
Responsability separation
Easier to find a feature
Prooph
http://getprooph.org/
The CQRS and Event Sourcing Components for PHP
- 1st commit in 2014
- +70 repositories in github
- Active collaborators
- Can be used with Symfony/Laravel
- Great documentation
Trending repositories
prooph/service-bus
prooph/event-store
prooph/event-sourcing
prooph/proophessor-do
Command
Query
Responsibility Segregation
Separate writes and reads in your application.
Title Text
Buses
In computer architecture, a bus is a communication system that transfers data between components inside a computer, or between computers.
Command bus: 1 handler which returns nothing
Query bus: 1 finder which returns a promise
Event bus: 0,n handler which returns nothing
Can be executed through
- direct
- amqp
- http
- ...
prooph_service_bus:
command_buses:
synchronous_command_bus: ~
asynchronous_command_bus:
router:
# Should implement Prooph\ServiceBus\Async\MessageProducer
async_switch: 'your_producer_service'
<?php
$bus = $container->get('prooph_service_bus.asynchronous_command_bus');
$bus->dispatch(
Acme\Command\RegisterUser::withData('uuid ...', 'chuck', 'chuck@norris.tld')
);
Async producer through bernard/bernard in
Command
<?php
namespace Prooph\ProophessorDo\Model\User\Command;
/** uses **/
final class RegisterUser extends Command implements PayloadConstructable
{
use PayloadTrait;
public static function withData(UuidInterface $userId, string $name, string $email): RegisterUser
{
return new self([
'user_id' => (string) $userId,
'name' => $name,
'email' => $email,
]);
}
public function userId(): UuidInterface
{
return Uuid::fromString($this->payload['user_id']);
}
protected function setPayload(array $payload): void
{
Assertion::keyExists($payload, 'user_id');
Assertion::uuid($payload['user_id']);
Assertion::keyExists($payload, 'name');
Assertion::string($payload['name']);
Assertion::keyExists($payload, 'email');
$validator = new EmailAddressValidator();
Assertion::true($validator->isValid($payload['email']));
$this->payload = $payload;
}
}
Command Handler
<?php
declare(strict_types=1);
namespace Prooph\ProophessorDo\Model\User\Handler;
/** uses **/
class RegisterUserHandler
{
public function __invoke(RegisterUser $command): void
{
// Do your logic here.
}
}
Prooph\ProophessorDo\Model\User\Handler\RegisterUserHandler:
arguments:
public: true
tags:
- { name: 'prooph_service_bus.synchronous_command_bus.route_target', message_detection: true }
Define the command handler as service.
Route it on your bus
Query finder
<?php
namespace Prooph\ProophessorDo\Model\User\Handler;
/** uses **/
class GetAllUsersHandler
{
public function __invoke(GetAllUsers $query, Deferred $deferred = null)
{
//$users = ...;
if (null === $deferred) {
return $users;
}
$deferred->resolve($users);
}
}
<?php
$users = [];
$this->queryBus
->dispatch(new GetAllUsers())
->then(
function (\stdClass $result = null) use (&$users) {
$users = $result;
}
);
Usage:
Why ?
- Separate Write & Read (Apps do 90% of read)
- Async/Sync writes, switch is easy.
- Reuse your commands/queries
- Writes depends only on a bus
- Cache management
Event
Sourcing
Store events to define the present
- added 2 “Product 1“ to its cart
- added 1 “Product 2“ to its cart
- removed “Product 1“ from its cart
user_id | product | quantity |
---|---|---|
xxx | Product 2 | 1 |
Legacy approach
It misses a lot of informations
What happens if business ask how many Product 1 has been added to a cart ?
Database storage:
ES approach
[{
"uuid": "956b8f20-818f-4cd7-af42-6f97e20a7c77",
"event": "ProductAddedToCart",
"created_at": "2018-02-26T13:37:00Z",
"payload": {"product": "product 1", "quantity": "2"}}
},
{
"uuid": "956b8f20-818f-4cd7-af42-6f97e20a7c77",
"event": "ProductAddedToCart",
"created_at": "2018-02-26T15:38:00Z",
"payload": {"product": "product 2", "quantity": "1"}}
},
{
"uuid": "956b8f20-818f-4cd7-af42-6f97e20a7c77",
"event": "ProductRemovedFromCart",
"created_at": "2018-02-26T15:39:00Z",
"payload": {"product": "product 1"}}
}]
Event store
+
Projection(s)
user_id | product | quantity |
---|---|---|
xxx | Product 2 | 1 |
Read: Projections
Write: Reconstitute the aggregate root
“What about performances ?
If I have 5000 events by aggregate root“
You can drop then recreate your snapshots ...
Create a snapshot of your aggregate root each X events ...
Snapshots
Create as many projections as you want
Projections
Updated asynchronously
On demand: Create, update, reconstruct ...
Without ES
Client: Yesterday at 08:30pm client X had
a 500 when he tried to add product Y to its cart
- logs
- database
- ...
- var_dump($x); exit;
With ES
Client: Yesterday at 08:30pm client X had
a 500 when he tried to add product Y to its cart
- logs
- replay all events until failing event
- reproduce error ...
- Add a test with this scenario ...
In Prooph
All theses concepts are implemented \o/
EventStore (product) not implemented
PDO event store driver available
A bit of code to use it.
In Prooph
1 event stream per aggregate
1 event stream per aggregate type
1 event stream for all
With PDO
1 event stream = 1 table
Strategies:
prooph_event_store:
stores:
cart_store:
event_store: Prooph\EventStore\Pdo\MySqlEventStore
repositories:
cart_list:
repository_class: Acme\Cart\Infra\Repository\EventStoreCartList
aggregate_type: Acme\Cart\Domain\Cart
aggregate_translator: prooph_event_sourcing.aggregate_translator
projection_managers:
cart_projection_manager:
event_store: Prooph\EventStore\Pdo\MySqlEventStore
connection: 'doctrine.pdo.connection'
projections:
cart_projection:
read_model: Acme\Cart\Infra\Projection\CartReadModel
projection: Acme\Cart\Infra\Projection\CartProjection
Projections
Aggregate root
Symfony
final class Cart extends AggregateRoot implements Entity
{
// ....
public function addProduct(
UuidInterface $productId,
int $quantity
): User {
$self->recordThat(ProductWasAddedToCart::withData(
$this->cartId,
$productId,
$quantity
));
}
protected function whenProductWasAddedToCart(ProductWasAddedToCart $event): void
{
if ($this->products->has($event->getProductId()) {
$this->products->get($event->getProductId())->incrementQuantity($event->getQuantity());
return;
}
$this->products->add($event->getProductId(), $event->getQuantity());
}
//....
}
Aggregate root
final class ProductWasAddedToCart extends AggregateChanged
{
public static function withData(UuidInterface $cartId, UuidInterface $productId, int $quantity)
{
return self::occur($cartId->toString(), [
'product_id' => $productId->toString(),
'quantity' => $quantity
]);
return $event;
}
public function getCartId(): UuidInterface
{
return Uuid::fromString($this->aggregateId());
}
public function getProductId(): UuidInterface
{
return Uuid::fromString($this->payload['user_id']);
}
// ....
}
Event
<?php
namespace Acme\Cart\Infra\Repository;
final class EventStoreCartList extends AggregateRepository implements CartList
{
public function save(Cart $cart): void
{
$this->saveAggregateRoot($cart);
// store all new events raised into event store.
}
public function get(UuidInterface $uuid): ?Cart
{
return $this->getAggregateRoot($uuid->toString());
// fetch all events from event store then handle them.
}
}
Cart list (repository)
<?php
namespace Acme\Cart\Domain\Repository;
interface CartList
{
public function save(Cart $cart): void;
public function get(UuidInterface $uuid): ?Cart;
}
class AddProductToCartHandler
{
private $cartList;
public function __construct(CartList $cartList)
{
$this->cartList = $cartList;
}
public function __invoke(AddProductToCart $command): void
{
$cart = $this->cartList->get($command->getCartId());
if (!$cart) {
throw CartNotFound::withCartId($command->getCartId());
}
$cart->addProduct($command->getProductId(), $command->getQuantity());
$this->cartList->save($cart);
}
}
Command handler
<?php
final class CartReadModel extends AbstractReadModel
{
public function init(): void
{
$sql = <<<EOF
CREATE TABLE `cart_items` (
`cart_id` varchar(36) COLLATE utf8_unicode_ci NOT NULL,
`product_id` varchar(36) COLLATE utf8_unicode_ci NOT NULL,
// .........
EOF;
$statement = $this->connection->prepare($sql);
$statement->execute();
}
public function isInitialized(): bool {} // is table exists
public function reset(): void {} // truncate
public function delete(): void {} // drop table
protected function insert(array $data): void
{
$this->connection->insert('cart_items', $data);
}
protected function delete(array $data, array $identifier): void {} // do delete
}
Projections: read model
final class CartProjection implements ReadModelProjection
{
public function project(ReadModelProjector $projector): ReadModelProjector
{
$projector->fromStream('event_stream')
->when([
ProductWasAddedToCart::class => function ($state, ProductWasAddedToCart $event) {
$readModel = $this->readModel();
$readModel->stack('insert', [
'cart_id' => $event->getCartId()->toString(),
'product_id' => $event->getProductId()->toString(),
'quantity' => $event->getQuantity()
]);
},
ProductWasRemovedFromCart::class => function ($state, ProductWasRemovedFromCart $event) {
$readModel = $this->readModel();
$readModel->stack(
'delete',
[
'cart_id' => $event->getCartId()->toString(),
'product_id' => $event->getProductId()->toString(),
],
);
},
]);
return $projector;
}
}
Projections
Projections
Projection listening ...
php bin/console event-store:projection:run cart_projection
Projection states in your data storage
id | name | position | state | status |
---|---|---|---|---|
1 | cart_projection | {"event_stream“: 14} | [] | running |
Catch all business data
Cache with projections
Tests: definition, fast execution
Evolve yours projections with past events
Async projection update
You are the time master !
Commands+Events+Projections can be heavy
Today with prooph
Miss EventStore implementation
Move fast, current version: 6.2.2
No stable release (1.x) for Symfony at this moment
Easy to use
Nice documentation/examples
Unit tested
Active & serious contributors
Questions ?
Documentation
VIP: @martin fowler, @eric evans, @mathias verraes
DDD/CQRS/ES
By Stéphane PY
DDD/CQRS/ES
- 2,281