-
Notifications
You must be signed in to change notification settings - Fork 11
Description
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?! :)