Skip to content

Where to call or pass a domain service? #60

@webdevilopers

Description

@webdevilopers

This is a screenshot from "The absolute beginner’s guide to DDD with Symfony" by @nealio82

Screenshot from 2021-08-15 12-24-10

Slides: https://speakerdeck.com/nealio82/the-absolute-beginners-guide-to-ddd-with-symfony?slide=105

My question was why the Domain Service slotConfirmationService was called inside the Command Handler instead of passing it to the named constructor of the Aggregate Root.

Our structure and code look very similar. Question about the domain service:
In similar use cases our application handler stays a pure orchestrator and instead would pass the service to the named constructor of the aggregate root. Thoughts?

Came from: https://twitter.com/webdevilopers/status/1426184442301800448

Current thread: https://twitter.com/webdevilopers/status/1426854214253400064

We had a similar use case a long time ago where we had to prevent employment contracts to overlap.
Those days we used the same approach calling a responsible unit in the handler. We did not have a service for it yet and used a repository and a separate Policy / Specification instead. Of course both could be moved to single service.

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

        if (null === $person) {
            throw new \Exception('Person not found.');
        }

        /** @var ContractDetails[] $enteredContracts */
        $enteredContracts = $this->contractsDetailsRepository->ofPersonId($person->personId());

        if (!OverlappingEmploymentContractPolicy::isSatisfiedBy(
            $command->contractId(), $command->contractType(),
            $command->employmentPeriod(), $command->employerId(), $enteredContracts
        )) {
            throw new EmploymentPeriodOverlapsException();
        }

        // ...

        $contract = EmploymentContract::sign(/*...*/);
        $this->contractCollection->save($contract);
    }
}

In new projects we would prefer this approach:

final class ContractPeriodConfirmationService
{
    public function confirm(PersonId $personId, EmploymentPeriod $period, /*...*/)
    {
        $person = $this->personDetailsRepository->ofPersonId($command->personId()->toString());

        if (null === $person) {
            throw new \Exception('Person not found.');
        }

        /** @var ContractDetails[] $enteredContracts */
        $enteredContracts = $this->contractsDetailsRepository->ofPersonId($person->personId());

        if (!OverlappingEmploymentContractPolicy::isSatisfiedBy(
            $command->contractId(), $command->contractType(),
            $command->employmentPeriod(), $command->employerId(), $enteredContracts
        )) {
            throw new EmploymentPeriodOverlapsException();
        }
    }
}
final class SignEmploymentContractHandler
{
    public function __invoke(SignEmploymentContract $command): void
    {
        $contract = EmploymentContract::sign($command->contractId, $this->contractPeriodConfirmationService, /*...*/);
        $this->contractCollection->save($contract);
    }
}
final class EmploymentContract extends AggregateRoot
{
    public static function sign(ContractId $contractId, ContractPeriodConfirmationService $contractPeriodConfirmationService, /*...*/): EmploymentContract
    {
        try {
            $contractPeriodConfirmationService->(/*...*/);
        } catch (EmploymentPeriodOverlapsException $e) {
        }

        // ...
    }
}

Another solution would be to move the entire code to a separate Factory which would make the Application Service Command Handler very slim:

final class SignEmploymentContractHandler
{
    public function __invoke(SignEmploymentContract $command): void
    {
        $contract = $this->employmentContractFactory->sign($command->contractId(), /*...*/);
        $this->contractCollection->save($contract);
    }
}

Factories are recommended when creating an aggregate requires logic that does not naturally fit into the Aggregate Root or elsewhere.

Here are some very nice solutions suggested by @BrunoRommens:

Possibly related:

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions