CQRS + Event Sourcing in PHP

Preview:

Citation preview

CQRSCommand Query Responsibility Segregation

Manel López Torrent

Ingeniero informáticaDesarrollo software

Agile

@mloptormalotor@gmail.com

EDUMENT

http://cqrs.nu

DDD in PHP

https://github.com/dddinphp/blog-cqrs

https://github.com/dddinphp/last-wishes-gamify

¿CQRS?

CQRSPatrón de diseño de app

Lecturas / Escrituras

Modelo rico

Mejor rendimiento

Mejor escalabilidad

Objetivos

Greg Younghttps://goodenoughsoftware.net/@gregyoung

DDD (Domain Driven Design)

Modelo rico VS Modelo Anémico

Patrones tácticos

Arquitectura de capas

Arquitectura hexagonal

Requisitos

Cafetería

- Cuandos los clientes entran en el café se sientan en una mesa , un camarero/a , abre una cuenta para esa mesa

- Los clientes pueden ordenar bebida y/o comidas del menú

- Una vez ordenadas las bebidas pueden ser servidas de inmediato.

- La comida debe ser preparada en cocina. Una vez se ha preparado puede ser servida

- Cuando los clientes terminan de comer pagan la cuenta ( pueden dejar propina ) y la cuenta se cierra

- No se puede cerrar una cuenta si hay bebida o comida pendientes

Abrir

Ordenar

Preparar

Servir

Pagar

Cerrar

Camarero

Mesa

Cuenta

Bebida / Comida

Trasladar el modelo a objetos ( DDD táctico )

Creamos tests para asegurar su corrección

Arquitectura hexagonal para conectar el modelo con el mundo

exterior

Application Layer

DomainModel

ApplicationService 1

Request

InfrastructureLayer DataStoreApplication

Service 2

ApplicationService n

Response

<?php

class Tab

{

static public function open($table, $waiter): Tab {}

public function placeOrder($orderedItems) {}

public function serveDrinks($drinksServed) {}

public function prepareFood($foodPrepared) {}

public function serveFood($foodServed) {}

public function close(float $amount) {}

}

interface TabRepository

{

public function getById(TabId $tabId);

public function save(Tab $tab);

}

<?php

class OrderedItem

{

public function __construct(int $menuNumber, bool $IsDrink, float $price) {}

public function getMenuNumber(): int {}

public function isDrink(): bool {}

public function getPrice(): float {}

}

interface OrderedItemsRepository

{

public function findById($id): OrderedItem;

}

OK

Nuevos requisitos

Maître

Barman

Cocina

<?php

interface TabRepository

{

public function getById(TabId $tabId);

public function save(Tab $tab);

}

<?php

interface TabRepository

{

public function getById(TabId $tabId);

public function save(Tab $tab);

public function getTabsByWaiter($waiter);

public function getTabsWithDrinkPending();

public function getTabsWithFoodPrepared();

public function getTabsOpen();

public function getTabsClosed(DateTime $date);

}

<?php

use Doctrine\ORM\Query

interface TabRepository

{

public function getById(TabId $tabId);

public function save(Tab $tab);

public function getTabsByQuery(Query $query)

}

<?php

interface TabRepository

{

public function getById(TabId $tabId);

public function save(Tab $tab);

public function getTabsBySpeficication(Specification $s);

}

<?php

class MysqlTabRepository implements TabRepository { ... }

class RedisTabRepository implements TabRepository { ... }

class MongoDBTabRepository implements TabRepository { ... }

¿?

ORM

Frameworks

Contaminamos el modelo

SRP

SRP

Command Query Responsibility Segregation

Read Model Write Model

Read Model Write Model

Lógica de negocio

Read Model Write Model

Query Command

Commands

OpenTab

PlaceOrder

MarkDrinksServed

MarkFoodPrepared

MarkFoodServed

CloseTab

Querys

AllTabs

OneTab

AllTabsByWaiter

InfrastructureLayer

Application Layer

CommandHandlerCommand

CommandHandler

QueryHandle

Read Model

Model

Command

QueryDataStore

Response

QueryHandle

Query

Response

Controladores Comando Bus Command Handler

CommandCommandHandler

class OpenTabCommand

{

private $tabId;

private $tableNumber;

private $waiterId;

public function __construct($tabId, $tableNumber, $waiterId)

{

$this->tabId = $tabId;

$this->tableNumber = $tableNumber;

$this->waiterId = $waiterId;

}

public function getTabId() { }

public function getTableNumber() { }

public function getWaiterId() { }

}

class OpenTabHandler

{

private $tabRepopsitory;

public function __construct(TabRepository $tabRepository)

{

$this->tabRepopsitory = $tabRepository;

}

public function handle(OpenTabCommand $command)

{

$newTab = Tab::openWithId(

TabId::fromString($command->getTabId()),

$command->getTableNumber(),

$command->getWaiterId()

);

$this->tabRepopsitory->add($newTab);

}

}

QueryQueryHandler

class OneTabQuery

{

public $id;

public function __construct($id)

{

$this->id = $id;

}

}

class OneTabQueryHandler

{

private $tabsRepository;

private $dataTransformer;

public function __construct(

TabsRepository $tabsRepostiory,

DataTranformer $dataTransformer

) {

$this->tabsRepository = $tabsRepostiory;

$this->dataTransformer = $dataTransformer;

}

public function handle(OneTabQuery $query)

{

$tab = $this->tabsRepository->find($query->id);

$this->dataTransformer->write($tab);

return $this->dataTransformer->read();

}

}

Modelo Escritura

Tab

TabRepository

OrderedItem

OrderedItemRepository

Modelo Lectura

TabView

TabViewRepository

OrderedItemView

OrderedItemViewRepository

Modelo Escritura

Tab

TabRepository

OrderedItem

OrderedItemRepository

Modelo Lectura

TabView

TabViewRepository

OrderedItemView

OrderedItemViewRepository

Lógica neg

ocio Modelo

Anémico

<?php

interface TabRepository

{

public function getById(TabId $tabId);

public function save(Tab $tab);

}

interface TabViewRepository

{

public function getTabsByWaiter($waiter);

public function getTabsWithDrinkPending();

public function getTabsWithFoodPrepared();

public function getTabsOpen();

public function getTabsClosed(DateTime $date);

}

Modelo rico

Mejor rendimiento

Mejor escalabilidad

¿Mejor rendimiento?

InfrastructureLayer

Application Layer

CommandHandlerCommand

CommandHandler

QueryHandle

Read Model

Model

Command

QueryDataStore

Response

QueryHandle

Query

Response

Model InfrastructureLayer

DataStoreInfrastructure

Layer

DataStore

Application Layer

CommandHandlerCommand

CommandHandler

QueryHandle

Read Model

Command

Query

Response

QueryHandle

Query

Response

<?php

class RedisTabRepository implements TabRepository { ... }

class MysqlTabViewRepository implements TabViewRepository { ... }

Modelo rico

Mejor rendimiento

Mejor escalabilidad

¿Mejor escalabilidad?

Model InfrastructureLayer

DataStoreInfrastructure

Layer

DataStore

Application Layer

CommandHandlerCommand

CommandHandler

QueryHandle

Read Model

Command

Query

Response

QueryHandle

Query

Response

WRITE SERVICE

READ SERVICE

APIGATEWA

Y

READSERVICE

WRITESERVICE

READSERVICE

READSERVICE

Modelo rico

Mejor rendimiento

Mejor escalabilidad

¿Consistencia de los datos?

InfrastructureLayer

Application Layer

CommandHandlerCommand

CommandHandler

QueryHandle

Read Model

Model

Command

Query

DataStore

Response

QueryHandle

Query

Response

InfrastructureLayer

DataStore

Eventos del dominio

Se ha abierto una cuenta

Se ordenan bebidas y comida

La comida está preparada

Las bebidas se han servido

La comida se ha preparado

La comida se ha servidor

Se ha cerrado una cuenta

TabOpened

DrinksOrdered

FoodOrdered

DrinksServed

FoodPrepared

FoodServed

TabClosed

<?php

class DrinksOrdered extends TabEvent

{

private $items;

public function __construct(TabId $id, $items)

{

$this->id = $id;

$this->items = $items;

}

public function getItems()

{

return $this->items;

}

}

Command Model

Evento

BU

SModel

Evento

Evento

Model Evento Listenner

BU

S

DataStore

Model Listenner

BU

S

Event sourcing

El estado de nuestro sistema

es la suma de todos los eventos

ocurridos en el.

A1 {x = 1y = 2

}ID X Y

A1 1 2

A2 3 5 A2 {x = 3y = 5

}

A2, y = 5

A1, y = 2

A2, x = 3

A1 , y = 7

A2, x = null y = null

A1 , x = 1

A1, x = null y = null

A1 {x = nully = null

}

Tiempo

A2, y = 5

A1, y = 2

A2, x = 3

A1 , y = 7

A2, x = null y = null

A1 , x = 1

A1, x = null y = null

A1 {x = nully = null

}

A1 {x = 1y = null

}

Tiempo

A2, y = 5

A1, y = 2

A2, x = 3

A1 , y = 7

A2, x = null y = null

A1 , x = 1

A1, x = null y = null

A1 {x = nully = null

}

A1 {x = 1y = null

}

A2 {x = nully = null

}

Tiempo

A2, y = 5

A1, y = 2

A2, x = 3

A1 , y = 7

A2, x = null y = null

A1 , x = 1

A1, x = null y = null

A1 {x = nully = null

}

A1 {x = 1y = null

}

A1 {x = 1y = 7

}

A2 {x = nully = null

}

Tiempo

A2, y = 5

A1, y = 2

A2, x = 3

A1 , y = 7

A2, x = null y = null

A1 , x = 1

A1, x = null y = null

A1 {x = nully = null

}

A1 {x = 1y = null

}

A1 {x = 1y = 7

}

A2 {x = nully = null

}

A2 {x = 3y = null

}

Tiempo

A2, y = 5

A1, y = 2

A2, x = 3

A1 , y = 7

A2, x = null y = null

A1 , x = 1

A1, x = null y = null

A1 {x = nully = null

}

A1 {x = 1y = null

}

A1 {x = 1y = 7

}

A1 {x = 1y = 2

}

A2 {x = nully = null

}

A2 {x = 3y = null

}

Tiempo

A2, y = 5

A1, y = 2

A2, x = 3

A1 , y = 7

A2, x = null y = null

A1 , x = 1

A1, x = null y = null

A1 {x = nully = null

}

A1 {x = 1y = null

}

A1 {x = 1y = 7

}

A1 {x = 1y = 2

}

A2 {x = nully = null

}

A2 {x = 3y = null

}

A2 {x = 3y = 5

}

Tiempo

A2, y = 5

A1, y = 2

A2, x = 3

A1 , y = 7

A2, x = null y = null

A1 , x = 1

A1, x = null y = null

A1 {x = nully = null

}

A1 {x = 1y = null

}

A1 {x = 1y = 7

}

A1 {x = 1y = 2

}

A2 {x = nully = null

}

A2 {x = 3y = null

}

A2 {x = 3y = null

}

Historia

Auditoria

Fácil persistencia

Reconstruimos nuestros agregado a partir de un

flujo de eventos

Command Model

Evento

BU

SModel

Evento

Evento

EventStore

Proyecciones

ID X Y

A1 1 2

A2 3 5

A1, y = 2

A1 , y = 7

A1 , x = 1

A3, y = 5

A2, x = 3

ID X Y

A1 1 2

A2 3 5

A1, x = 2

A1, y = 2

A1 , y = 7

A1 , x = 1

A3, y = 5

A2, x = 3

ID X Y

A1 1 2

A2 3 5

UPDATE table_name SET x=2 WHERE id = A1 A1, x = 2

A1, y = 2

A1 , y = 7

A1 , x = 1

A3, y = 5

A2, x = 3

ID X Y

A1 2 2

A2 3 5

A1, x = 2

A1, y = 2

A1 , y = 7

A1 , x = 1

Inconsistencia Eventual

¿Dónde generamos los eventos?

Agregados

Entidad raíz

Id del agregado

Almacenar los eventos

Reconstruir desde flujo de eventos

<?php

class Tab

{

static public function open($table, $waiter): Tab {}

public function placeOrder($orderedItems) {}

public function serveDrinks($drinksServed) {}

public function prepareFood($foodPrepared) {}

public function serveFood($foodServed) {}

public function close(float $amount) {}

}

<?php

class Tab

{

// TabOpened

static public function open($table, $waiter): Tab {}

// DrinksOrdered , FoodOrdered

public function placeOrder($orderedItems) {}

// DrinksServed

public function serveDrinks($drinksServed) {}

// FoodPrepared

public function prepareFood($foodPrepared) {}

// FoodServed

public function serveFood($foodServed) {}

// TabClosed

public function close(float $amount) {}

}

<?php

class Tab {

static public function open($table, $waiter): Tab

{

$id = TabId::create();

$newTab = new Tab($id, $table, $waiter);

DomainEventPublisher::instance()->publish(

new TabOpened($id, $table, $waiter)

);

return $newTab;

}

}

<?php

class Tab {

static public function open($table, $waiter): Tab

{

$id = TabId::create();

$newTab = new Tab($id, $table, $waiter);

DomainEventPublisher::instance()->publish(

new TabOpened($id, $table, $waiter)

);

return $newTab;

}

}

<?php

class Tab extends Aggregate {

static public function open($table, $waiter): Tab

{

$id = TabId::create();

$newTab = new Tab($id, $table, $waiter);

$this->recordThat(new TabOpened($id, $table, $waiter));

return $newTab;

}

}

abstract class Aggregate implements AggregateRoot

{

private $recordedEvents = [];

protected function recordThat(DomainEvent $aDomainEvent)

{

$this->recordedEvents[] = $aDomainEvent;

}

public function getRecordedEvents(): DomainEvents

{

return new DomainEvents($this->recordedEvents);

}

public function clearRecordedEvents()

{

$this->recordedEvents = [];

}

}

abstract class Aggregate implements AggregateRoot

{

public static function reconstituteFrom(AggregateHistory $anAggregateHistory) {

$anAggregate = static::createEmptyWithId(

$anAggregateHistory->getAggregateId()

);

foreach ($anAggregateHistory as $anEvent) {

$anAggregate->apply($anEvent);

}

return $anAggregate;

}

private function apply($anEvent)

{

$method = 'apply' . ClassFunctions::short($anEvent);

$this->$method($anEvent);

}

}

class Tab extends Aggregate {

public function applyDrinksServed(DrinksServed $drinksServed)

{

array_walk($drinksServed->getItems(),

function($drinkServedNumber) {

$item = $this->outstandingDrinks[$drinkServedNumber];

unset($this->outstandingDrinks[$drinkServedNumber]);

$this->servedItems[$drinkServedNumber] = $item;

});

}

}

class Tab extends Aggregate {

public function applyDrinksServed(DrinksServed $drinksServed)

{

array_walk($drinksServed->getItems(),

function($drinkServedNumber) {

$item = $this->outstandingDrinks[$drinkServedNumber];

unset($this->outstandingDrinks[$drinkServedNumber]);

$this->servedItems[$drinkServedNumber] = $item;

});

}

}

Refactorizar agregados

<?php

class Tab {

public function serveDrinks($drinksServed)

{

$this->assertDrinksAreOutstanding($drinksServed);

array_walk($drinksServed, function($drinkServedNumber) {

$item = $this->outstandingDrinks[$drinkServedNumber];

unset($this->outstandingDrinks[$drinkServedNumber]);

$this->servedItems[$drinkServedNumber] = $item;

});

}

}

<?php

class Tab extends Aggregate {

public function serveDrinks($drinksServed)

{

$this->assertDrinksAreOutstanding($drinksServed);

array_walk($drinksServed, function($drinkServedNumber) {

$item = $this->outstandingDrinks[$drinkServedNumber];

unset($this->outstandingDrinks[$drinkServedNumber]);

$this->servedItems[$drinkServedNumber] = $item;

});

$this->recordThat(new DrinksServed(

$this->getAggregateId(),

$drinksServed

));

}

}

<?php

class Tab extends Aggregate {

public function applyDrinksServed(DrinksServed $drinksServed)

{

array_walk($drinksServed->getItems(),

function($drinkServedNumber) {

$item = $this->outstandingDrinks[$drinkServedNumber];

unset($this->outstandingDrinks[$drinkServedNumber]);

$this->servedItems[$drinkServedNumber] = $item;

});

}

}

<?php

class Tab extends Aggregate {

public function serveDrinks($drinksServed)

{

$this->assertDrinksAreOutstanding($drinksServed);

array_walk($drinksServed, function($drinkServedNumber) {

$item = $this->outstandingDrinks[$drinkServedNumber];

unset($this->outstandingDrinks[$drinkServedNumber]);

$this->servedItems[$drinkServedNumber] = $item;

});

$this->recordThat(new DrinksServed(

$this->getAggregateId(),

$drinksServed

));

}

}

<?php

class Tab extends Aggregate {

public function serveDrinks($drinksServed)

{

$this->assertDrinksAreOutstanding($drinksServed);

$drinksServedEvend = new DrinksServed(

$this->getAggregateId(),

$drinksServed

);

$this->recordThat($drinksServedEvend);

$this->apply($drinksServedEvend);

}

}

class Tab extends Aggregate {

public function serveDrinks($drinksServed)

{

$this->assertDrinksAreOutstanding($drinksServed);

$this->applyAndRecordThat(new DrinksServed(

$this->getAggregateId(),

$drinksServed

));

}

}

class Tab extends Aggregate {

public function serveDrinks($drinksServed)

{

$this->assertDrinksAreOutstanding($drinksServed);

$this->applyAndRecordThat(new DrinksServed(

$this->getAggregateId(),

$drinksServed

));

}

}

1 - Comprobamos que el evento se puede aplicar

class Tab extends Aggregate {

public function serveDrinks($drinksServed)

{

$this->assertDrinksAreOutstanding($drinksServed);

$this->applyAndRecordThat(new DrinksServed(

$this->getAggregateId(),

$drinksServed

));

}

}

1 - Comprobamos que el evento se puede aplicar2 - Lo Aplicamos y lo guardamos

Repositorio

Recuperar flujo de eventos

Persistir eventos guardados

Publicar el flujo eventos

<?php

interface AggregateRepository

{

public function get(IdentifiesAggregate $aggregateId):

AggregateRoot;

public function add(RecordsEvents $aggregate);

}

<?php

class TabEventSourcingRepository implements TabRepository

{

private $eventStore;

private $projector;

public function __construct(

EventStore $eventStore,

$projector

) {

$this->eventStore = $eventStore;

$this->projector = $projector;

}

}

Event Store

<?php

interface EventStore

{

public function commit(DomainEvents $events);

public function getAggregateHistoryFor(IdentifiesAggregate $id);

}

Serializar

{ "type": "TabOpened", "created_on": 1495579156, "data": { "id" : "8b486a7b-2e32-4e17-ad10-e90841286722", "waiter" : "Jhon Doe", "table" : 1 }}

{ "type": "DrinksOrdered", "created_on": 1495579200, "data": { "id" : "8b486a7b-2e32-4e17-ad10-e90841286722", "items" : [1,2] }}

8b486a7b-2e32-4e17-ad10-e90841286722

Proyecciones

<?php

interface Projection

{

public function eventType();

public function project($event);

}

<?php

class TabOpenedProjection implements Projection

{

private $pdo;

public function __construct($pdo)

{

$this->pdo = $pdo;

}

public function project($event)

{

$stmt = $this->pdo->prepare("INSERT INTO tabs (tab_id, waiter, tableNumber, open) VALUES

(:tab_id, :waiter, :tableNumber, 1)");

$stmt->execute([

':tab_id' => $event->getAggregateId(),

':waiter' => $event->getWaiterId(),

':tableNumber' => $event->getTableNumber(),

]);

}

public function eventType()

{

return TabOpened::class;

}

}

<?php

class Projector

{

private $projections = [];

public function register(array $projections)

{

foreach ($projections as $projection) {

$this->projections[$projection->eventType()] = $projection;

}

}

public function project(DomainEvents $events)

{

foreach ($events as $event) {

if (!isset($this->projections[get_class($event)]))

throw new NoProjectionExists();

$this->projections[get_class($event)]->project($event);

}

}

}

Modelo lectura

Modelo anémico

DTO

Entidades generadas ORM

Repositorios generados ORM

Frameworks

There are no dumb questions ...

https://github.com/malotor/cafe_events

PHP 7.1

Phpunit 6

Docker

☁ events_cafe [master] tree -L 1.├── README.md├── bootstrap.php├── build├── cache├── cli-config.php├── composer.json├── composer.lock├── coverage├── docker-compose.yml├── phpunit.xml├── public├── resources├── scripts├── src├── tests└── vendor

☁ events_cafe [master] tree -L 2 srcsrc├── Application│ ├── Command│ ├── DataTransformer│ └── Query├── Domain│ ├── Model│ └── ReadModel└── Infrastructure ├── CommandBus ├── Persistence ├── Serialize └── ui

☁ events_cafe [master] tree -L 2 src/Applicationsrc/Application├── Command│ ├── CloseTab.php│ ├── CloseTabHandler.php│ ├── MarkDrinksServedCommand.php│ ├── MarkDrinksServedHandler.php│ ├── MarkFoodServedCommand.php│ ├── MarkFoodServedHandler.php│ ├── OpenTabCommand.php│ ├── OpenTabHandler.php│ ├── PlaceOrderCommand.php│ ├── PlaceOrderHandler.php│ ├── PrepareFoodCommand.php│ └── PrepareFoodHandler.php├── DataTransformer│ ├── DataTranformer.php│ └── TabToArrayDataTransformer.php└── Query ├── AllTabsQuery.php ├── AllTabsQueryHandler.php ├── OneTabQuery.php └── OneTabQueryHandler.php

☁ events_cafe [master] tree -L 4 src/Infrastructuresrc/Infrastructure├── CommandBus│ └── CustomInflector.php├── Persistence│ ├── Domain│ │ └── Model│ │ ├── DoctrineOrderedItemRepository.php│ │ ├── InMemoryTabRepository.php│ │ └── TabEventSourcingRepository.php│ ├── EventStore│ │ ├── EventStore.php│ │ ├── PDOEventStore.php│ │ └── RedisEventStore.php│ └── Projection│ ├── BaseProjection.php│ ├── DrinksOrderedProjection.php│ ├── Projection.php│ ├── Projector.php│ ├── TabOpenedProjection.php│ └── TabProjection.php├── Serialize│ ├── JsonSerializer.php│ └── Serializer.php└── ui └── web └── app.php

☁ events_cafe [master] tree -L 2 src/Domainsrc/Domain├── Model│ ├── Aggregate│ ├── Events│ ├── OrderedItem│ └── Tab└── ReadModel ├── Items.php └── Tabs.php

☁ events_cafe [master] tree -L 2 src/Domain/Modelsrc/Domain/Model├── Aggregate│ ├── Aggregate.php│ └── AggregateId.php├── Events│ ├── DrinksOrdered.php│ ├── DrinksServed.php│ ├── FoodOrdered.php│ ├── FoodPrepared.php│ ├── FoodServed.php│ ├── TabClosed.php│ ├── TabEvent.php│ └── TabOpened.php├── OrderedItem│ ├── OrderedItem.php│ ├── OrderedItemNotExists.php│ └── OrderedItemsRepository.php└── Tab ├── DrinkIsNotOutstanding.php ├── FoodIsNotPrepared.php ├── FoodNotOutstanding.php ├── MustPayEnoughException.php ├── Tab.php ├── TabHasUnservedItems.php ├── TabId.php ├── TabNotExists.php ├── TabNotOpenException.php └── TabRepository.php

<?php

$app->post('/tab', function (Request $request) use ($app) {

// …

$command = new Command\OpenTabCommand(

\Ramsey\Uuid\Uuid::uuid4(),

$data['table'],

$data['waiter']

);

$app['command_bus']->handle($command);

// …

})

<?php

$app->get('/tab/{id}', function (Request $request, $id) use ($app)

{

$query = new Query\OneTabQuery($id);

$response = $app['query_bus']->handle($query);

return $app->json([

'tab' => $response

]);

});

Gracias y ..

Que la fuerza os acompañe.

Recommended