Aggregate state and union types¶
Let's launch this new documentation by showing how the new Just
specification opens an interesting Aggregate design.
A state can easily be described via an enum in an Aggregate property, and since 2.0.0
this is supported by default. But sometimes you need to attach extra information. Before 2.2.0
, even if technically possible, this is kind of messy type wise.
Let's take the example of a Blueprint
Aggregate for a house. This blueprint can be:
- in draft with the architect that last modified it
- pre-approved by an architect
- approved by both an architect and a client
And we need to be able to list aggregates for one of those states.
We could design it this way:
use Formal\ORM\Id;
final readonly class Blueprint
{
/**
* @param Id<self> $id
* @param ?Id<Architect> $architect
* @param ?Id<Client> $client
*/
private function __construct(
private Id $id,
private State $state,
private ?Id $architect,
private ?Id $client,
) {}
/**
* @param Id<Architect> $archiect
*/
public static function new(Id $archiect): self
{
return new self(
Id::new(self::class),
State::draft,
$architect,
null,
);
}
/**
* @param Id<Architect> $architect
*/
public function preApprove(Id $architect): self
{
return new self(
$this->id,
State::preApproved,
$architect,
$this->client,
);
}
/**
* @param Id<Client> $client
*/
public function approve(Id $client): self
{
return new self(
$this->id,
State::preApproved,
$this->architect,
$client,
);
}
public function doStuff(): void
{
match ($this->state) {
State::draft => null, // todo
State::preApproved => null, // todo
State::approved => null, // todo
};
}
}
You can easily query aggregates by state via a simple specification on the state
property.
However type wise this is not great because in the Blueprint::doStuff()
method Psalm can't know that for each state the associated properties are not null
. You need to either add extra null checks that are useless or add @psalm-suppress
annotations that may hide real errors in the future.
With 2.2.0
we can redesign the aggregate this way:
use Formal\ORM\{
Id,
Definition\Contains,
};
use Innmind\Immutable\Maybe;
final readonly class Blueprint
{
/**
* @param Id<self> $id
* @param Maybe<Draft> $draft
* @param Maybe<PreApproved> $preApproved
* @param Maybe<Approved> $approved
*/
private function __construct(
private Id $id,
#[Contains(Draft::class)]
private Maybe $draft,
#[Contains(PreApproved::class)]
private Maybe $preApproved,
#[Contains(Approved::class)]
private Maybe $approved,
) {}
/**
* @param Id<Architect> $archiect
*/
public static function new(Id $archiect): self
{
return new self(
Id::new(self::class),
Maybe::just(new Draft($architect)),
Maybe::nothing(),
Maybe::nothing(),
);
}
/**
* @param Id<Architect> $architect
*/
public function preApprove(Id $architect): self
{
return new self(
$this->id,
Maybe::nothing(),
Maybe::just(new PreApproved($architect)),
$this->approved,
);
}
/**
* @param Id<Client> $client
*/
public function approve(Id $client): self
{
$architect = $this->preApproved->match(
static fn($preApproved) => $preApproved->architect(),
static fn() => throw new \LogicException('Not pre-approved'),
);
return new self(
$this->id,
Maybe::nothing(),
Maybe::nothing(),
Maybe::just(new Approved($architect, $client)),
);
}
public function doStuff(): void
{
$state = $this
->draft
->otherwise(fn() => $this->preApproved)
->otherwise(fn() => $this->approved)
->match(
static fn($state) => $state,
static fn() => throw new \LogicException('Not reachable'),
);
match (true) {
$state instanceof Draft => $state->architect(),
$state instanceof PreApproved => $state->architect(),
$state instanceof Approved => $state->client(),
};
}
}
This new design has 2 benefits:
- in the
approve
method we are forced to explicit the previous state to access the architect - in the
doStuff
method Psalm is now aware that the ids exist in each state
And it can be queried via this specification:
use Formal\ORM\Specification\Just;
use Innmind\Specification\{
Comparator,
Composable,
Sign,
};
/**
* @psalm-immutable
*/
final readonly class State implements Comparator
{
use Composable;
public static function draft(): Just
{
return Just::of('draft', new self);
}
public static function preApproved(): Just
{
return Just::of('preApproved', new self);
}
public static function approved(): Just
{
return Just::of('approved', new self);
}
public function property(): string
{
return 'architect';
}
public function sign(): Sign
{
return Sign::isNotNull;
}
public function value(): mixed
{
return null;
}
}
By checking the architect
is not null allows to check if the entity exist in the storage.