Skip to content

Instantly share code, notes, and snippets.

@webdevilopers
Last active July 15, 2020 08:17
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save webdevilopers/687c8b34d68e97a8f93df5db09242406 to your computer and use it in GitHub Desktop.
Save webdevilopers/687c8b34d68e97a8f93df5db09242406 to your computer and use it in GitHub Desktop.
Creating event-sourced aggregate roots through CQRS read models
<?php
namespace AcmersonnelManagement\Domain\Model\EmploymentContract;
final class EmploymentContract extends AggregateRoot
{
/** @var EmploymentContractId $employmentContractId */
private $id;
/** @var PersonId $personId */
private $personId;
/** @var EmploymentPeriod */
private $employmentPeriod;
public static function sign(
EmploymentContractId $anId, PersonId $aPersonId, PersonalData $aPersonalData, ContractType $aContractType,
DateTimeImmutable $aStartDate, ?DateTimeImmutable $anEndDate, ?DateTimeImmutable $aProbationEndDate,
WorkerCategory $aWorkerCategory, WageType $aWageType, bool $aWorkingTimeAccount,
WorkweekDays $aWorkWeekDays, WeeklyWorkingHours $aWeeklyWorkingHours,
HolidayEntitlement $aHolidayEntitlement, AdditionalLeave $additionalLeave,
JobFunctionId $aJobFunctionId, string $aJobFunctionName,
EmployerId $anEmployerId, string $anEmployerName, WorkplaceId $aWorkplaceId, string $aWorkplaceName
): EmploymentContract
{
$employmentPeriod = EmploymentPeriod::withType($aContractType, $aStartDate, $anEndDate);
$self = new self();
$self->recordThat(EmploymentContractSigned::withData(
$anId, $aPersonId, $aPersonalData, $aContractType, $employmentPeriod, $aPbationaryPeriod,
$aWorkerCategory, $aWageType, $aWorkingTimeAccount,
$aWorkWeekDays, $aWeeklyWorkingHours,
$aHolidayEntitlement, $additionalLeave,
$aJobFunctionId, $aJobFunctionName,
$anEmployerId, $anEmployerName, $aWorkplaceId, $aWorkplaceName,
new DateTimeImmutable()
));
return $self;
}
protected function apply(AggregateChanged $event): void
{
switch (get_class($event)) {
/** @var EmploymentContractSigned $event */
case EmploymentContractSigned::class:
$this->id = $event->contractId();
$this->personId = $event->personId();
$this->employmentPeriod = $event->employmentPeriod();
break;
}
}
public function aggregateId(): string
{
return $this->id->toString();
}
}
<?php
namespace Acme\PersonnelManagement\Domain\Model\Person;
final class PersonReadModel
{
private $personId;
private $personalData;
private $employmentContracts;
public function signEmploymentContract(
EmploymentContractId $contractId, ContractType $contractType,
DateTimeImmutable $startDate, ?DateTimeImmutable $endDate, ?DateTimeImmutable $aProbationEndDate,
...
): EmploymentContract {
$employmentPeriod = EmploymentPeriod::withType($contractType, $startDate, $endDate);
if (!OverlappingEmploymentContractPolicy::isSatisfiedBy(
$contractId, $contractType, $employmentPeriod, ..., $this->employmentContracts
)) {
throw new EmploymentPeriodOverlapsException();
}
return EmploymentContract::sign(
$contractId, $this->personId, $this->personalData,
$contractType, $startDate(), $endDate(), $probationEndDate(),
...
);
}
}
<?php
namespace Acme\PersonnelManagement\Application\Service\EmploymentContract;
final class SignEmploymentContractHandler
{
/** @var EmploymentContractEventStoreRepository */
private $contractCollection;
/** @var PersonDetailsRepository */
private $personDetailsRepository;
/** @var ContractDetailsRepository */
private $contractsDetailsRepository;
public function __construct(
EmploymentContractEventStoreRepository $contractCollection,
PersonDetailsRepository $personDetailsRepository,
ContractDetailsRepository $contractsDetailsRepository
)
{
$this->contractCollection = $contractCollection;
$this->personDetailsRepository = $personDetailsRepository;
$this->contractsDetailsRepository = $contractsDetailsRepository;
}
public function __invoke(SignEmploymentContract $command): void
{
$person = $this->personDetailsRepository->ofPersonId($command->personId()->toString());
$enteredContracts = $this->contractsDetailsRepository->ofPersonId($person->personId());
if (!OverlappingEmploymentContractPolicy::isSatisfiedBy(
$command->contractId(), $command->contractType(),
$command->employmentPeriod(), $command->employerId(), $enteredContracts
)) {
throw new EmploymentPeriodOverlapsException();
}
$contract = EmploymentContract::sign(
$command->contractId(), $command->personId(),
new PersonalData(...),
$command->contractType(),
$command->startDate(), $command->endDate(), $command->probationEndDate(),
$command->workerCategory(), $command->wageType(),
$command->workingTimeAccount(), $command->workweekDays(),
$command->weeklyWorkingHours(), $command->holidayEntitlement(), $command->additionalLeave(),
$command->jobFunctionId(), $jobFunction->name(),
$command->employerId(), $employer->name(),
$command->workplaceId(), $workplace->name()
);
$this->contractCollection->save($contract);
}
}
@webdevilopers
Copy link
Author

webdevilopers commented Jul 14, 2020

Came from:

/cc @JulianMay

An EmploymentContract is an event-sourced Aggregate Root (A+ES). It holds a reference to a A+ES Person.
Employment periods of contracts for a person must NOT overlap. This is ensured by a OverlappingEmploymentContractPolicy that currently is called inside the command handler. The handler has to get the existing contracts of a Person from a read model repository.

The idea is to move the creation of the contract to the Read Model for the Person as demonstrated in PersonReadModel.

The new handler would then look like this:

    public function __invoke(SignEmploymentContract $command): void
    {
        $person = $this->personDetailsRepository->ofPersonId($command->personId()->toString());

        $contract = $person->signEmploymentContract(...);
        
        $this->contractCollection->save($contract);

@webdevilopers
Copy link
Author

This scenario is made for an application that lives in a single microservice. But even if Person and Contracts were dedicated services the Contracts service could consume PersonHired events and create a "local copy" of persons and use them as a read model. Or you would move the logic back to the command handler.

But THEN I would indeed recommend to make everything event-driven and create separate events that may result in a "ContractCancelledDueToOverlapping" event.

@JulianMay
Copy link

JulianMay commented Jul 14, 2020

Disclaimer: I'm no expert or authority, I might be "wrong" on every line below ;)

Thoughts regarding terminology/structure:

  • "Policy" for me is something that is a reaction to an event ("Whenever X, then Y"), which might hold it's own aggregated state - I usually call specifications like this "Constraints", e.g.: "OverlappingEmploymentContractsConstraint" or simply name them according to the rule: "EmploymentsMayNotOverlap"
  • It feels weird for me that a ReadModel is instantiating an aggregate - I would not expect a ReadModel to be more than data and queries.
    Besides the instantiation of an EmploymentContract, the PersonReadModel seems to be 1) a factory for dependencies of the OverlappingEmploymentContractPolicy and 2) control-flow, checking the constraint

... Personally, I would probably do something like:

    public function __invoke(SignEmploymentContract $command): void
    {
        $enteredContracts = $this->contractsDetailsRepository->ofPersonId($person->personId());     

        if(!OverlappingEmploymentContractPolicy::isSatisfiedBy($command->... , $enteredContracts)) 
        {
            throw new EmploymentPeriodOverlapsException();
        }
      
        $person = $this->personDetailsRepository->ofPersonId($command->personId()->toString());
        
        $contract = EmploymentContract::sign($command->... , $person->... )
        
        $this->contractCollection->save($contract);

... you could probably clean it up by introducing a factory for the policy, moving the 'contractsDetailsRepository' to the factory (and have the policy be an object with immutable state):

    public function __invoke(SignEmploymentContract $command): void
    {
        $constraint = $this->overlappingEmploymentContracts->ofPersonId($person->personId());     
        if(!$constraint->isSatisfiedBy($command)) 
        {
            throw new EmploymentPeriodOverlapsException();
        }
      
        $person = $this->personDetailsRepository->ofPersonId($command->personId()->toString());
        
        $contract = EmploymentContract::sign($command->... , $person->... )
        
        $this->contractCollection->save($contract);

Thoughts regarding consistency/invariants:
Be aware that all code in this gist so far does not guarantee that 2 contracts for the same person do not overlap periods.
It "only" guarantees it as long as that person does not sign up for 2 different contracts at the same time - It's probably only a window of a few milliseconds, and more a theoretical edge case than anything worth coding around.
Besides "how probably is it", I find another heuristic for whether the code should care, is the severity of the consequence of such invariant failing.
If there is a way to identify and compensate for a theoretical edge case without much blowback, and the race-condition would probably never happen, I would suggest not complicating things with saga/process-manager/policies.

Just be aware that you have a guard, not an absolute guarantee ;)

@webdevilopers
Copy link
Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment