php-ddd
php-ddd copied to clipboard
Creating event-sourced aggregate roots through CQRS read models
Came from:
- https://twitter.com/webdevilopers/status/1282582468915331072
- https://gist.github.com/webdevilopers/687c8b34d68e97a8f93df5db09242406
/cc @JulianMay
An EmploymentContract is an event-sourced Aggregate Root (A+ES). It holds a reference to a A+ES Person.
<?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();
}
}
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.
<?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);
}
}
The idea is to move the creation of the contract to the Read Model for the Person as demonstrated in PersonReadModel.
Based on this article by @udidahan:
- http://udidahan.com/2009/06/29/dont-create-aggregate-roots/
Or the example from "Implementing Domain-Driven Design" by @VaughnVernon:
- https://www.oreilly.com/library/view/implementing-domain-driven-design/9780133039900/ch11lev2sec2.html
public class Forum extends Entity {
...
public Discussion startDiscussion(
DiscussionId aDiscussionId, Author anAuthor, String aSubject) {
if (this.isClosed()) {
throw new IllegalStateException("Forum is closed.");
}
Discussion discussion = new Discussion(
this.tenant(), this.forumId(), aDiscussionId, anAuthor, aSubject);
DomainEventPublisher.instance().publish(new DiscussionStarted(...));
return discussion;
}
as mentioned by @sofiaguyang:
- https://stackoverflow.com/questions/41971722/can-an-aggregate-root-factory-method-return-a-command-instead-of-publishing-an-e
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);
}
<?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(),
...
);
}
}
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.
Please do not @ people who have not explicitly consented to it. Thanks.
(Copy/Pasted from https://gist.github.com/webdevilopers/687c8b34d68e97a8f93df5db09242406 )
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);
EDIT: you could probably clean it up by injecting the "policy" as a dependency, which encapsulates the 'contractsDetailsRepository' (as long as you don't need it to be 'pure'):
public function __invoke(SignEmploymentContract $command): void
{
$constraintSatisfied = $this->newEmploymentContractMayNotOverlap->isSatisfiedBy($command);
if(!$constraintSatisfied)
{
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 ;)
Please do not @ people who have not explicitly consented to it. Thanks.
Sorry @udidahan for this faux-pas. Same for twitter. Just wanted to ask you for your feedback on this regarding your suggestion in your mentioned article.