Bonus I - Custom Projection

The product owner comes along with a new feature request. They need a way to look up the building a user is checked into, if any.

Exercise

Before we implement that feature you're asked to implement the check out user use case. Add a command CheckOutUser and an event UserCheckedOut. Let the Building aggregate and Building\State handle the command and make sure that DoubleCheckOutDetected can also be monitored using the monitoring UI.

Does it work? Great!

Implement a Projector

What we need is a list of usernames and a reference to the building they are checked into. A custom projection can keep track of UserCheckedIn and UserCheckedOut events to keep the list up-to-date.

First check in John again (in case he is checked out because you've successfully tested the CheckOutUser command)!

To do that we need our own EventEngine\Projecting\Projector implementation. Create a new class called UserBuildingList in src/Domain/Projector with the following content:

<?php
declare(strict_types=1);

namespace MyService\Domain\Projector;

use EventEngine\DocumentStore\DocumentStore;
use MyService\Domain\Api\Event;
use MyService\Domain\Api\Payload;
use EventEngine\Messaging\Message;
use EventEngine\Projecting\AggregateProjector;
use EventEngine\Projecting\Projector;

final class UserBuildingList implements Projector
{
    /**
     * @var DocumentStore
     */
    private $documentStore;

    public function __construct(DocumentStore $documentStore)
    {
        $this->documentStore = $documentStore;
    }

    public function prepareForRun(string $projectionVersion, string $projectionName): void
    {
        if(!$this->documentStore->hasCollection(self::generateCollectionName($projectionVersion, $projectionName))) {
            $this->documentStore->addCollection(
                self::generateCollectionName($projectionVersion, $projectionName)
            /* Note: we could pass index configuration as a second argument, see docs for details */
            );
        }
    }

    public function handle(string $projectionVersion, string $projectionName, Message $event): void
    {
        $collection = self::generateCollectionName($projectionVersion, $projectionName);

        switch ($event->messageName()) {
            case Event::USER_CHECKED_IN:
                $this->documentStore->addDoc(
                    $collection,
                    $event->get(Payload::NAME), //Use username as doc id
                    [Payload::BUILDING_ID => $event->get(Payload::BUILDING_ID)]
                );
                break;
            case Event::USER_CHECKED_OUT:
                $this->documentStore->deleteDoc($collection, $event->get(Payload::NAME));
                break;
            default:
                //Ignore unknown events
        }
    }

    public function deleteReadModel(string $appVersion, string $projectionName): void
    {
        $this->documentStore->dropCollection(self::generateCollectionName($appVersion, $projectionName));
    }

    public static function generateCollectionName(string $projectionVersion, string $projectionName): string
    {
        //We can use the naming strategy of the aggregate projector for our custom projection
        return AggregateProjector::generateCollectionName($projectionVersion, $projectionName);
    }
}

Make the projector available as a service in src/Domain/DomainServices:

<?php
declare(strict_types=1);

namespace MyService\Domain;

use MyService\Domain\Api\Aggregate;
use MyService\Domain\Api\Command;
use MyService\Domain\Api\Event;
use MyService\Domain\Api\Listener;
use MyService\Domain\Api\Projection;
use MyService\Domain\Api\Query;
use MyService\Domain\Api\Type;
use MyService\Domain\Projector\UserBuildingList;
use MyService\Domain\Resolver\BuildingResolver;

trait DomainServices
{
    public function buildingResolver(): BuildingResolver
    {
        return $this->makeSingleton(BuildingResolver::class, function () {
            return new BuildingResolver($this->documentStore());
        });
    }

    public function userBuildingListProjector(): UserBuildingList
    {
        return $this->makeSingleton(UserBuildingList::class, function () {
            return new UserBuildingList($this->documentStore());
        });
    }

    public function domainDescriptions(): array
    {
        return [
            Type::class,
            Command::class,
            Event::class,
            Query::class,
            Aggregate::class,
            Projection::class,
            Listener::class,
        ];
    }
}

And describe the projector in src/Domain/Api/Projection:

<?php

declare(strict_types=1);

namespace MyService\Domain\Api;

use EventEngine\EventEngine;
use EventEngine\EventEngineDescription;
use EventEngine\Persistence\Stream;
use MyService\Domain\Projector\UserBuildingList;

class Projection implements EventEngineDescription
{
    const USER_BUILDING_LIST = 'user_building_list';

    /**
     * @param EventEngine $eventEngine
     */
    public static function describe(EventEngine $eventEngine): void
    {
        $eventEngine->watch(Stream::ofWriteModel())
            ->withAggregateProjection(Aggregate::BUILDING);

        $eventEngine->watch(Stream::ofWriteModel())
            ->with(self::USER_BUILDING_LIST, UserBuildingList::class)
            ->filterEvents([
                Event::USER_CHECKED_IN,
                Event::USER_CHECKED_OUT,
            ]);
    }
}

Projections are deactivated by default, because we assume that you want to start with the MultiModelStore only. Added deployment complexity and eventual consistency are the main drawbacks of projections. On the other hand, they help to keep queries simple and fast. During my developer career I've spent countless hours with analyzing, migrating and improving query logic. Developers tend to put too much logic into queries. We've learned to normalize the write model to keep it consistent and throw SQL, Elastic, MongoDB, ... query power against every problem. More often than not, this ends up in large and complex queries, hard to understand, debug and expand. Sounds familiar? Projections to the rescue!

Having said this, let's activate the default write-model-projection shipped with the skeleton. It's a prooph/event-store v7 read model projection, that watches the standard write model stream of Event Engine. To activate it, uncomment the appropriate docker container in docker-compose.yml:

  event_engine_projection:
    image: prooph/php:7.2-cli
    volumes:
      - .:/app
    depends_on:
      - postgres
    command: php /app/bin/event_engine_projection.php
    # Needed so that projection is automatically restarted when new events are registered in event engine
    restart: on-failure
    env_file:
      - ./app.env

and start it with:

docker-compose up -d

If you look at the Postgres DB you should see a new table called building_list_0_1_0 with one row:

id doc
John {"buildingId": "9ee8d8a8-3bd3-4425-acee-f6f08b8633bb"}

If the table is empty make sure that you've checked in John. If that's the case, your projection might have a problem. Check the troubleshooting section of the skeleton README.

The write-model-projection is a single long-running php process. It polls the event store for new events and forwards them to Event Engine. Event Engine checks its projection descriptions and invokes all projectors interested in the forwarded events. If you add another projection and want to fill it with past data, you can run the script docker-compose run --rm php php bin/reset.php. Projections are versioned (version can be defined as a third argument in the projection description, default is 0.1.0). This way, it's possible to generate a new read model version during deployment while the old version is still available (version is part of the table name).

Look up

We can add a new query, resolver and corresponding type definitions to complete the look up feature.

src/Domain/Api/Type

<?php

declare(strict_types=1);

namespace MyService\Domain\Api;

use MyService\Domain\Model\Building;
use EventEngine\EventEngine;
use EventEngine\EventEngineDescription;
use EventEngine\JsonSchema\JsonSchema;
use EventEngine\JsonSchema\Type\ObjectType;

class Type implements EventEngineDescription
{
    const HEALTH_CHECK = 'HealthCheck';
    const USER_BUILDING = 'UserBuilding'; //<-- new type

    /* ... */

    private static function userBuilding(): ObjectType
    {
        return JsonSchema::object([
            'user' => Schema::username(),
            'building' => Schema::building()->asNullable(), //<-- type ref to building, can be null
        ]);
    }

    /**
     * @param EventEngine $eventEngine
     */
    public static function describe(EventEngine $eventEngine): void
    {
        $eventEngine->registerType(Aggregate::BUILDING, Building\State::__schema());

        $eventEngine->registerType(self::USER_BUILDING, self::userBuilding()); //<-- type registration
    }
}

src/Domain/Api/Schema

<?php

declare(strict_types=1);

namespace MyService\Domain\Api;

use EventEngine\JsonSchema\JsonSchema;
use EventEngine\JsonSchema\Type\ArrayType;
use EventEngine\JsonSchema\Type\StringType;
use EventEngine\JsonSchema\Type\TypeRef;
use EventEngine\JsonSchema\Type\UuidType;

class Schema
{
    /* ... */

    public static function username(): StringType
    {
        return JsonSchema::string()->withMinLength(1);
    }

    public static function userBuilding(): TypeRef
    {
        return JsonSchema::typeRef(Type::USER_BUILDING);
    }

    /* ... */
}

src/Domain/Resolver/UserBuildingResolver

<?php
declare(strict_types=1);

namespace MyService\Domain\Resolver;

use EventEngine\DocumentStore\DocumentStore;
use EventEngine\Messaging\Message;
use EventEngine\Querying\Resolver;
use MyService\Domain\Api\Payload;

final class UserBuildingResolver implements Resolver
{
    /**
     * @var DocumentStore
     */
    private $documentStore;

    /**
     * @var string
     */
    private $userBuildingCollection;

    /**
     * @var string
     */
    private $buildingCollection;

    public function __construct(DocumentStore $documentStore, string $userBuildingCol, string $buildingCol)
    {
        $this->documentStore = $documentStore;
        $this->userBuildingCollection = $userBuildingCol;
        $this->buildingCollection = $buildingCol;
    }

    public function resolve(Message $query): array
    {
        $userBuilding = $this->documentStore->getDoc(
            $this->userBuildingCollection,
            $query->get(Payload::NAME)
        );

        if(!$userBuilding) {
            return [
                'user' => $query->get(Payload::NAME),
                'building' => null
            ];
        }

        $building = $this->documentStore->getDoc(
            $this->buildingCollection,
            $userBuilding['buildingId']
        );

        if(!$building) {
            return [
                'user' => $query->get(Payload::NAME),
                'building' => null
            ];
        }

        return [
            'user' => $query->get(Payload::NAME),
            'building' => $building['state'],
        ];
    }
}

src/Domain/DomainServices

<?php
declare(strict_types=1);

namespace MyService\Domain;

use MyService\Domain\Api\Aggregate;
use MyService\Domain\Api\Command;
use MyService\Domain\Api\Event;
use MyService\Domain\Api\Listener;
use MyService\Domain\Api\Projection;
use MyService\Domain\Api\Query;
use MyService\Domain\Api\Type;
use MyService\Domain\Projector\UserBuildingList;
use MyService\Domain\Resolver\BuildingResolver;
use MyService\Domain\Resolver\UserBuildingResolver;

trait DomainServices
{
    public function buildingResolver(): BuildingResolver
    {
        return $this->makeSingleton(BuildingResolver::class, function () {
            return new BuildingResolver($this->documentStore());
        });
    }

    public function userBuildingResolver(): UserBuildingResolver
    {
        return $this->makeSingleton(UserBuildingResolver::class, function () {
            return new UserBuildingResolver(
                $this->documentStore(),
                UserBuildingList::generateCollectionName(
                    '0.1.0', 
                    Projection::USER_BUILDING_LIST),
                BuildingResolver::COLLECTION
            );
        });
    }

    public function userBuildingListProjector(): UserBuildingList
    {
        return $this->makeSingleton(UserBuildingList::class, function () {
            return new UserBuildingList($this->documentStore());
        });
    }

    public function domainDescriptions(): array
    {
        return [
            Type::class,
            Command::class,
            Event::class,
            Query::class,
            Aggregate::class,
            Projection::class,
            Listener::class,
        ];
    }
}

src/Domain/Api/Query

<?php

declare(strict_types=1);

namespace MyService\Domain\Api;

use MyService\Domain\Resolver\UserBuildingResolver;
use ...

class Query implements EventEngineDescription
{
    /* ... */
    const USER_BUILDING = 'UserBuilding';

    public static function describe(EventEngine $eventEngine): void
    {
        /* ... */

        $eventEngine->registerQuery(
            self::USER_BUILDING,
            JsonSchema::object(['name' => Schema::username()])
        )
            ->resolveWith(UserBuildingResolver::class)
            ->setReturnType(Schema::userBuilding());
    }
}

Swagger - UserBuilding query

{
  "name": "John"
}

Response

{
  "user": "John",
  "building": {
    "name": "Acme Headquarters",
    "users": [
      "John"
    ],
    "buildingId": "9ee8d8a8-3bd3-4425-acee-f6f08b8633bb"
  }
}

An hour of work (with a bit more practice even less) and we are ready to ship the new feature! Rapid application development at its best! RAD is ok, but please don't skip testing! In the second bonus part of the tutorial we'll learn that Event Engine makes it easy to run integration tests. Don't miss it!

Fork me on GitHub