botilka/botilka

CQRS & Event Sourcing framework

1.2.0 2019-06-20 15:04 UTC

This package is auto-updated.

Last update: 2024-04-08 13:20:47 UTC


README

Build Status Scrutinizer Code Quality Code Coverage PHPStan Infection MSI

An modern & easy-to-use Event Sourcing & CQRS library. It's shipped with implementations built on top of Symfony components.

It can leverage API Platform to expose yours Commands and Queries via REST.

Features

  • EventStore implementation with Doctrine or MongoDB.
  • Snapshot store for performance (Doctrine & MongoDB).
  • Swagger commands & queries description (via API Platform UI).
  • REST API access to commands & queries.
  • Sync or async event handling is a matter of configuration.
  • Event replaying (allow to test domain changes).
  • Projection re-play on demand (ie. when you add a ReadModel).
  • Safe commands concurrency (optimistic locking).
  • Tested, good code coverage.

Configuration

An event store should (must) be persisted and the default implementation is not! Choose between:

  • Botilka\Infrastructure\Doctrine\EventStoreDoctrine
  • Botilka\Infrastructure\MongoDB\EventStoreMongoDB
# config/packages/botilka.yaml
botilka:
    # default implementation is 'Botilka\Infrastructure\InMemory\EventStoreInMemory', not persisted!!
    event_store: Botilka\Infrastructure\Doctrine\EventStoreDoctrine # or 'Botilka\Infrastructure\MongoDB\EventStoreMongoDB'

Botilka provide a command to create & configure the event store:

bin/console botilka:store:initialize event doctrine # or mongodb

You can force recreate, but be carefull, you will lost all the previous events:

bin/console botilka:store:initialize event doctrine -f

Usage

CQRS & EventSourcing

You'll need to create Commands, Queries, Events and so on. Read the documentation.

Async command handling

You may need to handle some or all commands asychonously. Using Symfony Messenger, you'll need to configure it:

# config/packages/messenger.yaml
framework:
    messenger:
        transports:
            async: "%env(MESSENGER_TRANSPORT_DSN)%"
        routing:
            'Botilka\Application\Command\Command': async

Snapshots

When dealing with a lots of events, snapshotting is a good way to keep up on performance. Read the documentation.

Event replaying

It you've added or changed a business rule, you may want to see how it would have behaved with the event stream, this is a use case for event replaying.

You can replay event by aggregate id or by domain.

Let's say your a bank. The BI team said they want to send a SMS each time withdrawal is made if amount is more than a value and they want to know how many SMS would have been sent. You have to:

  1. create the new event handler
  2. re-play events

The event

final class SendSMSOnWithdrawalPerformed implements EventHandler
{
    public function onWithdrawalPerformed(WithdrawalPerformed $event): void
    {
        $user = $this->userRepository->getOwner($event->getgetAccountId());
        if ($this->isMobilePhone($phoneNumber = $user->getPhoneNumber()) && $event->getAmount() > self::ALERT_AMOUNT) {
            // record the calls count somewhere, now you know how many SMS would have been sent
            $this->smsSender->send($phoneNumber);
        }
    }
}

Replay:

# by domain
bin/console botilka:event_store:replay --domain [domain name]
# or by id
bin/console botilka:event_store:replay --id [aggregate root id] # you can limit the scope with --from/-f & --to/-t

Projection replay

In the same way than replaying events, you can replay projection. If you've added a projection and you want to replay only this projection, use the --matching/-m options.

Matching is a regex matched against [ProjectorFQCN]::[method], ie. App\BankAccount\Projection\Doctrine\BankAccountProjector::sumOfDeposit

The projection

namespace App\BankAccount\Projection\Doctrine;

final class BankAccountProjector implements Projector
{
    public function sumOfDeposit(DepositPerformed $event): void
    {
        $stmt = $this->connection->prepare('UPDATE all_the_sums SET value = value + :amount WHERE type = :type');
        $stmt->prepare(['amount' => $event->getAmount(), 'type' => 'deposit']);
        $stmt->execute();
    }

    public static function getSubscribedEvents()
    {
        return [
            DepositPerformed::class => 'sumOfDeposit',
        ];
    }
}

Replay projection:

# by domain
bin/console botilka:projector:build domain [domain name]
# or by id
bin/console botilka:projector:build id [aggregate root id]  --matching sumOfDeposit # you can limit the scope with --from/-f & --to/-t

API Platform bridge

See the API Platform bridge documentation.

Testing

This project uses PHP Unit: vendor/bin/phpunit.

Functionals tests are grouped under the tag functional: vendor/bin/phpunit --group functional.

How it works

Have a look here to better understand the design choices made and how the magic stuff happens.

todo

  • Event upcasting.
  • (maybe) Saga / Process manager.
  • (maybe) Smart command retry on concurrency exception.

Resources