Reputation: 17683
I have an email draft as a aggregate root with the following commands: addToRecipient
, addCcRecipient
, addBccRecipient
, updateBodyText
, uploadAttachment
, removeAttachment
and in the UI I want to disable the SEND button if the draft is not ready to be sent (i.e. there is at least on to recipient and the body has text). I know I'm not allowed to query the aggregate but it is the only one that can tell me that I can or can't send the email.
If I am to apply what I know about event sourcing and CQRS, then the aggregate would emit an EmailIsReadyToBeSent
event and my UserEmailDrafts
read model would pick that and update the UI somehow but then, I would have to check after every command and send a canceling event i.e. EmailIsNotReadyToBeSent
.
This feels very complicated, what do you think?
Upvotes: 1
Views: 559
Reputation: 1086
I am going to try to extend the answer given by @plalx with an example of the Specification
pattern.
For the sake of the example I am going to use some classes from this ddd library. Specifically the ones that define the interfaces to work with the specification pattern (provided by @martinezdelariva)
First of all, let's forget about the UI and keep the focus on the domain invariants that you must satisfy. So you said that in order to send an email the email needs to:
Now let's take a look at the Application Service (use case) to see the big picture before going into the details:
class SendEmailService implements ApplicationService
{
/**
* @var EmailRepository
*/
private $emailRepository;
/**
* @var CanSendEmailSpecificationFactory
*/
private $canSendEmailSpecFactory;
/**
* @var EmailMessagingService
*/
private $emailMessagingService;
/**
* @param EmailRepository $emailRepository
* @param CanSendEmailSpecificationFactory $canSendEmailSpecFactory
*/
public function __construct(
EmailRepository $emailRepository,
CanSendEmailSpecificationFactory $canSendEmailSpecFactory,
EmailMessagingService $emailMessagingService
) {
$this->emailRepository = $emailRepository;
$this->canSendEmailSpecFactory = $canSendEmailSpecFactory;
$this->emailMessagingService = $emailMessagingService;
}
/**
* @param $request
*
* @return mixed
*/
public function execute($request = null)
{
$email = $this->emailRepository->findOfId(new EmailId($request->emailId()));
$canSendEmailSpec = $this->canSendEmailSpecFactory->create();
if ($email->canBeSent($canSendEmailSpec)) {
$this->emailMessagingService->send($email);
}
}
}
We fetch the email from the repo, check if it can be sent and send it. So let's see how the Aggregate Root (Email) is working with the invariants, here the canBeSent
method:
/**
* @param CanSendEmailSpecification $specification
*
* @return bool
*/
public function canBeSent(CanSendEmailSpecification $specification)
{
return $specification->isSatisfiedBy($this);
}
So far so good, now let's see how easy is to compound the CanSendEmailSpecification
to satisfy our invariants:
class CanSendEmailSpecification extends AbstractSpecification
{
/**
* @var Specification
*/
private $compoundSpec;
/**
* @param EmailFullyFilledSpecification $emailFullyFilledSpecification
* @param SameEmailTypeAlreadySentSpecification $sameEmailTypeAlreadySentSpec
* @param ForbiddenKeywordsInBodyContentSpecification $forbiddenKeywordsInBodyContentSpec
*/
public function __construct(
EmailFullyFilledSpecification $emailFullyFilledSpecification,
SameEmailTypeAlreadySentSpecification $sameEmailTypeAlreadySentSpec,
ForbiddenKeywordsInBodyContentSpecification $forbiddenKeywordsInBodyContentSpec
) {
$this->compoundSpec = $emailFullyFilledSpecification
->andSpecification($sameEmailTypeAlreadySentSpec->not())
->andSpecification($forbiddenKeywordsInBodyContentSpec->not());
}
/**
* @param mixed $object
*
* @return bool
*/
public function isSatisfiedBy($object)
{
return $this->compoundSpec->isSatisfiedBy($object);
}
}
As you can see we say here that, in order an email to be sent, we must satisfy that:
Find below the implementation of the two first specifications:
class EmailFullyFilledSpecification extends AbstractSpecification
{
/**
* @param EmailFake $email
*
* @return bool
*/
public function isSatisfiedBy($email)
{
return $email->hasRecipient() && !empty($email->bodyContent());
}
}
class SameEmailTypeAlreadySentSpecification extends AbstractSpecification
{
/**
* @var EmailRepository
*/
private $emailRepository;
/**
* @param EmailRepository $emailRepository
*/
public function __construct(EmailRepository $emailRepository)
{
$this->emailRepository = $emailRepository;
}
/**
* @param EmailFake $email
*
* @return bool
*/
public function isSatisfiedBy($email)
{
$result = $this->emailRepository->findAllOfType($email->type());
return count($result) > 0 ? true : false;
}
}
Thanks to the Specification pattern you are ready now to manage as many invariants as your boss asks you to add without modifying the existing code. You can create unit tests very easily for every spec as well.
On the other hand, you can make the UI as complex as you want to let the user know that the email is ready to be sent. I would create another use case ValidateEmailService
that only calls the method canBeSent
from the Aggregate Root when the user clicks on a validate button, or when the user switches from one input (filling the recipient) to another (filling the body)... that is up to you.
Upvotes: 1
Reputation: 14064
The fact that an email cannot be sent unless there is a recipient and body is bordering on applicative logic, because at the end of the day it's more a matter of fields being filled in on a form than complex domain invariants.
Rather than relying on a full cross-tier round trip querying the read model each time something changes on the screen, I would inject some knowledge of these basic rules in the UI so that the button is instantaneously re-enabled when recipient and body are specified.
Much like you aren't shocked when you see client-side logic doing required field validation on a form, actually. It's a perfectly valid and accepted tradeoff since the logic is simple and universal.
Note that this doesn't prevent you from having these rules in the aggregate as well, rejecting any command that wouldn't satisfy them.
Upvotes: 5