-
Notifications
You must be signed in to change notification settings - Fork 11
Description
We have a Contract Domain Model holding a huge collection of Timesheets. I just added some simple Symfony code snippets using Doctrine Entities and Annotations.
All Entities are later moved to Acme\Contract\Domain\Model namespace and XML Mapping inside Acme\Contract\Infrastructure\Persistence\Doctrine etc..
use Acme\Contract\ContractBundle;
/**
* @ORM\Entity
*/
class Contract
{
/**
* @ORM\Id
* @ORM\Column(type="integer")
* @ORM\GeneratedValue(strategy="AUTO")
*/
public $id;
/**
* @ORM\Column(type="string", name="status")
*/
protected $status;
/**
* @ORM\OneToMany(targetEntity="Timesheet", mappedBy="contract", cascade={"persist"})
* @todo Use LAZY loading?
*/
private $timesheets;
public function addTimesheet(Timesheet $timesheet) {
// Policy
if ($this->isClosed()) {
throw new ContractAlreadyClosedException();
}
$this->timesheets[] = $timesheet;
}
}use Acme\Contract\ContractBundle;
/**
* @ORM\Entity
*/
class Timesheet
{
/**
* @ORM\Id
* @ORM\Column(type="integer")
* @ORM\GeneratedValue(strategy="AUTO")
*/
private $id;
/**
* @ORM\ManyToOne(targetEntity="Contract", inversedBy="timesheets")
* @ORM\JoinColumn(name="contract_id", referencedColumnName="id")
*/
private $contract;
}Currently we are using CQRS and a single Symfony app. Using Contract as the only AR forces our policy on the invariant:
use Acme\Contract\Application\Service;
class WriteTimeSheetHandler
{
public function handle($command)
{
$contract = $this->contractRepository->find($command->contractId());
$timesheet = new Timesheet($command->start(), $command->end());
$contract->addTimesheet($timesheet); // Check invariant
}
}There can be many related timesheet aggregates. This gives us the hint that Timesheet itself should be handled as an Aggregate Root (AR):
- http://stackoverflow.com/questions/37662541/ddd-do-i-really-need-to-load-all-objects-in-an-aggregate-performance-concerns
Since we have to query timesheets it already has its own repository - so it is an AR too.
Another solution we thought about is creating a new Contract Domain Model inside a different Bounded Context where the Timesheet will be living in. This would lead us to Eventual Consistency but it would be possible with our current infrastructure where bounded contexts are shared across the app.
As suggested by @mgonzalezbaile:
use Acme\Contract\Application\Service;
class WriteTimesheetHandler
{
public function handle($command)
{
$timesheet = new Timesheet($command->start(), $command->end());
$this->timesheetRepository->save($timesheet);
$this->raiseEvent(TimesheetCreated($contractId));
}
}class TimesheetCreatedListener
{
public function __constuct($event)
{
$contract = $this->contractRepository->find($event->contractId());
$timesheet = $this->timesheetRepository->find($event->timesheetId());
$contract->addTimesheet($timesheet); // Check invariant
$this->contractRepository->save($contract);
}
}But this way the Contract still has the relations to existing timesheets and all of them would be loaded.
Other thought on large aggregates by @voroninp:
Instead of loading all the timesheets we thought about removing them completely from the Contract. The only connection would be on the Timesheet aggregate via reference (ContractId):
class Timesheet
{
private $contractId;
public function __construct(Contract $contract, $start, $end)
{
// Policy - move to domain service using repository?
if ($contract->isClosed()) {
throw new ContractAlreadyClosedException();
}
$this->contractId = ContractId::create($contract->id()); // UUID value object soon
$this->start = $start;
$this->end = $end;
}
}Still we would load the Contract Aggregate and pass it to the Timesheet aggregate in order to check the invariant.
But passing the Contract AR to the Timesheet constructor doesn't feel good.
Another variation is moving the policy out of the Timesheet into a Domain Service. This Domain Service CanOnlyAddTimesheetToPendingContract could receive the contract repository. I think this is absolutely legal in DDD.
We would only have to pass the ContractId to the constructor instead of the full domain model.
Though I like this approach @VoiceofUnreason states here for a similar example:
Which is to say, if the rules for modifying a Timesheet depend on the current state of the Employee entity, then Employee and Timesheet definitely need to be part of the same aggregate, and the aggregate as a whole is responsible for ensuring that the rules are followed.
An aggregate has one root entity; identifying it is part of the puzzle. If an Employee has more than one Timesheet, and they are both part of the same aggregate, then Timesheet is definitely not the root. Which means that the application cannot directly modify or dispatch commands to the timesheet - they need to be dispatched to the root object (presumably the Employee), which can delegate some of the responsibility.
Related: