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

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