-
-
Save webdevilopers/687c8b34d68e97a8f93df5db09242406 to your computer and use it in GitHub Desktop.
<?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); | |
} | |
} |
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.
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 ;)
Moved to:
Came from:
/cc @JulianMay
An
EmploymentContract
is an event-sourced Aggregate Root (A+ES). It holds a reference to a A+ESPerson
.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 inPersonReadModel
.The new handler would then look like this: