Skip to content

How to keep read-models up-to-date when a name property was externally changed? #56

@webdevilopers

Description

@webdevilopers

Came from:

Given a Spot context. A Spot references a Place. Both have an ID and a name.
A Spot tracks the placeName when being added. But placeNames can change quite often.
In some views domain experts want the current placeName, in some not.

Basic approach

Get the current placeName when adding Spot.

final class AddSpotHandler
{
    private PlaceReadModelRepository $placeDetailsFinder;

    private SpotRepository $spotRepository;

    public function __construct(PlaceReadModelRepository $placeDetailsFinder, SpotRepository $spotRepository)
    {
        $this->placeDetailsFinder = $placeDetailsFinder;
        $this->spotRepository = $spotRepository;
    }

    public function __invoke(AddSpot $command): void
    {
        $placeReadModel = $this->placeDetailsFinder->ofPlaceId($command->hostId(), $command->placeId());

        $spot = Spot::managed($placeReadModel, $command->spotId(), $command->name());

        $this->spotRepository->save($spot);
    }
}

Enrich the SpotAdded event with the placeName.

This is known as a "Fat event". As described here by @mathiasverraes:

final class Spot extends AggregateRoot
{
    private PlaceId $placeId;

    private SpotId $spotId;

    private SpotAccess $access;

    public static function managed(PlaceReadModel $place, SpotId $spotId, SpotName $name): Spot
    {
        $self = new self();
        $self->recordThat(SpotAdded::with(
            $place->placeId(), $place->placeName(), $spotId, $name, SpotAccess::open()
        ));

        return $self;
    }
}

Get the placeName from the enriched SpotAdded event for the projection.

final class MongoSpotDetailsProjection implements ReadModelProjection
{
    public function project(ReadModelProjector $projector): ReadModelProjector
    {
        $projector->fromStream('spot_stream')
            ->when([
                SpotAdded::class => function ($state, SpotAdded $event) {
                    /** @var MongoSpotDetailsReadModel $readModel */
                    $readModel = $this->readModel();
                    $readModel->stack('insert', [
                        'placeId' => $event->placeId()->toString(),
                        'placeName' => $event->placeName()->toString(),
                        'spotId' => $event->spotId()->toString(),
                        'name' => $event->name()->toString(),
                        'access' => $event->access()->toString(),
                    ]);
                },
            ]);

        return $projector;
    }
}

Question: How to keep the Read Model up-to-date if and when(!) the placeName changes?

1 Same context - use the Places stream - IDEAL SITUATION AND SOLUTION

final class MongoSpotDetailsProjection implements ReadModelProjection
{
    public function project(ReadModelProjector $projector): ReadModelProjector
    {
        $projector->fromStream('spot_stream', 'place_stream')
            ->when([
                SpotAdded::class => function ($state, SpotAdded $event) use ($names) {
                    /** @var MongoSpotDetailsReadModel $readModel */
                    $readModel = $this->readModel();
                    $readModel->stack('insert', [
                        'placeId' => $event->placeId()->toString(),
                        'placeName' => $event->placeName()->toString(),
                        'spotId' => $event->spotId()->toString(),
                        'name' => $event->name()->toString(),
                        'access' => $event->access()->toString(),
                    ]);
                },
                PlaceChanged::class => function ($state, PlaceChanged $event) {
                    /** @var MongoSpotDetailsReadModel $readModel */
                    $readModel = $this->readModel();
                    $readModel->stack('update', [
                        'placeId' => $event->placeId()->toString(),
                        'name' => $event->name()->toString(),
                    ]);
                },
            ]);

        return $projector;
    }
}

Done. But now let's assume that we have dedicated contexts for Place and Spot.

2 Different contexts - Transport current placeName inside Projection to get current Name (ONLY) when replaying

The Spot context does not own the Place context. It holds a local copy of placeNames.

final class MongoSpotDetailsProjection implements ReadModelProjection
{
    public function project(ReadModelProjector $projector): ReadModelProjector
    {
        $state = ['placeNames' => //...//]];
        $placeNames = ['1' => 'Place #1'];

        $projector->fromStream('spot_stream')
            ->when([
                SpotAdded::class => function ($state, SpotAdded $event) use ($names) {
                    // $state is not global, no `placeNames` key here, have to use `$names` instead using a `placeRepository` for instance

                    /** @var MongoSpotDetailsReadModel $readModel */
                    $readModel = $this->readModel();
                    $readModel->stack('insert', [
                        'placeId' => $event->placeId()->toString(),
                        'placeName' => $placeNames[$event->placeId()->toString()],
                        'spotId' => $event->spotId()->toString(),
                        'name' => $event->name()->toString(),
                        'access' => $event->access()->toString(),
                    ]);
                },
            ]);

        return $projector;
    }
}

While this solution works it does not get the placeName change when the placeChanged event actually occurs.
It only helps when the projection gets replayed.

3 Different contexts - Dedicated event stream

The Spot context does not own the Place context. It holds a local copy of placeNames. But in addition it writes the name changes into a dedicated event stream.

Inspired by the "Segregated Event Layers" post by @mathiasverraes:

final class MongoSpotDetailsProjection implements ReadModelProjection
{
    public function project(ReadModelProjector $projector): ReadModelProjector
    {
        $projector->fromStream('spot_stream', 'place_name_changes_stream')
            ->when([
                SpotAdded::class => function ($state, SpotAdded $event) use ($names) {
                    /** @var MongoSpotDetailsReadModel $readModel */
                    $readModel = $this->readModel();
                    $readModel->stack('insert', [
                        'placeId' => $event->placeId()->toString(),
                        'placeName' => $event->placeName()->toString(),
                        'spotId' => $event->spotId()->toString(),
                        'name' => $event->name()->toString(),
                        'access' => $event->access()->toString(),
                    ]);
                },
                PlaceNameChanged::class => function ($state, PlaceNameChanged $event) {
                    /** @var MongoSpotDetailsReadModel $readModel */
                    $readModel = $this->readModel();
                    $readModel->stack('update', [
                        'placeId' => $event->placeId()->toString(),
                        'name' => $event->name()->toString(),
                    ]);
                },
            ]);

        return $projector;
    }
}

This actually feels strange since the actual state change HAPPENED in a different context. This is a "redundant" copy of a fact.

4 Register placeName change explicitely inside the Spot aggregate

final class Spot extends AggregateRoot
{
    private PlaceId $placeId;

    private SpotId $spotId;

    private SpotAccess $access;

    public static function managed(PlaceReadModel $place, SpotId $spotId, SpotName $name): Spot
    {
        $self = new self();
        $self->recordThat(SpotAdded::with(
            $place->placeId(), $place->placeName(), $spotId, $name, SpotAccess::open()
        ));

        return $self;
    }

    public function registerPlaceNameChange(PlaceName $newName): void
    {
        $this->recordThat(PlaceNameChanged::with($newName));
    }
}
final class MongoSpotDetailsProjection implements ReadModelProjection
{
    public function project(ReadModelProjector $projector): ReadModelProjector
    {
        $projector->fromStream('spot_stream')
            ->when([
                SpotAdded::class => function ($state, SpotAdded $event) use ($names) {
                    /** @var MongoSpotDetailsReadModel $readModel */
                    $readModel = $this->readModel();
                    $readModel->stack('insert', [
                        'placeId' => $event->placeId()->toString(),
                        'placeName' => $event->placeName()->toString(),
                        'spotId' => $event->spotId()->toString(),
                        'name' => $event->name()->toString(),
                        'access' => $event->access()->toString(),
                    ]);
                },
                PlaceNameChanged::class => function ($state, PlaceNameChanged $event) {
                    /** @var MongoSpotDetailsReadModel $readModel */
                    $readModel = $this->readModel();
                    $readModel->stack('update', [
                        'placeId' => $event->placeId()->toString(),
                        'name' => $event->name()->toString(),
                    ]);
                },
            ]);

        return $projector;
    }
}

Though the solution works it feels strange again to make a state change of an external Place aggregate get recognized by the Spot aggregate.
Imagine thousands of spots that had to register the name change. A batch operation would have to find the linked spots and call the registerPlaceNameChange method.

5 JOIN the physical read models - PARADOX?

The projector creates a MongoDB collection or an SQL table. The read model can then be queried by a "Finder".
The "Finder" could then JOIN the local copy of place_name_changes collection / table.

final class MongoSpotDetailsFinder implements SpotReadModelRepository
{
    private Client $client;

    private Collection $collection;

    public function __construct(Client $client, string $databaseName)
    {
        $this->client = $client;
        $this->collection = $client->selectCollection($databaseName, CollectionName::SPOT_DETAILS);
    }

    public function ofPlaceId(HostId $hostId, PlaceId $placeId): Spots
    {
        // Add JOIN here
        $docs = $this->collection->find(
            ['hostId' => $hostId->toString(), 'placeId' => $placeId->toString()]
        );

        $spots = [];

        foreach ($docs as $doc) {
            $spots[] = SpotReadModel::fromArray($doc);
        }

        return new Spots($spots);
    }
}

This solution does not touch the WRITE part of the aggregates. Neither does it introduce an extra event or event stream.

It can react individually to the domain experts' demands.
For instance we could have two read models, one tracking the name changes, the other one not.
This solution would be easy to maintain.

The only downside is that it leads the concept of a Read Model ad absurdum! Since a read model should already include all the data for the presentation without further "calculation", "transformation" or "cumulation". At least ideally?! :)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions