I've scoured SO and other forums/mailing lists in the search for answers on this topic, and while I know that the answer largely depends on the domain itself, and what is acceptable from an eventual consistency point of view, I still struggle to find a good solution.
The issue has to do with where to validate the business rules proper to the domain.
My domain is an online market place. A member (with the role Seller) can post an Ad to sell an item. The Seller can specify a minimum and a maximum number of items that can be purchased in a single order, and the price of the item.
A Buyer can buy an item off of a specific ad. The following rules have to be observed:
My Market BC is the one that deals with ads and buy transactions. I designed it in the following way:
I'm struggling with how and where to validate the above business rules, which in this case span multiple aggregates. I would Ideally have a method:
$buyer->buy($adId, $quantity);
That would be called by a BuyItems command
$buyCommand = new BuyItems($adId, $qty);
On the Member aggregate.
Of the options I gather that I have:
Validate outside of the domain, in an outer layer - this means that I would validate the command before sending it into the domain. This would imply some logic leaking outside of the domain, but I would fetch the ad from a read model, validate the constraint (between min and max, ad active, user active), and then send the command. I would also do domain side validation in that case, in the form of a process manager that would issue a compensating action, or at least warn if an inconsistency occurs.
Define a service interface in the domain, and implement a service that gets the data from the read model, then validate in the command handler by calling the service. If data is invalid, then throw an exception. Domain validation would have to occur here as well, because the read model might not be consistent (again using a process manager).
Load up the Ad and Member aggregate roots in the BuyItem handler, and pass it to the $buyer->buy($ad, $member, $qty); then in the buy() method in the AR, check that qty is between min and max. Don't really feel comfortable with this option, as I feed that I'm trying to cram transactional consistency, when I don't really need it (while I need to minimize the risks of commands with out of bound qty, or inactive member, it's not a huge deal if it happens and I issue a corrective action afterwards so I'm perfectly ok with eventual consistency).
Can anyone point me to what the best option is given that scenario?
You have a business process that spans multiple Aggregates, that's for sure. For this you have two options:
Modify the Aggregates boundary by merging multiple Aggregate types into a single one. The code is simpler, the compensations are done by the database automatically by rollbacks. The scalability is not so great.
Use a Saga to model the entire process. You need to send compensating commands for each failure. This is the option that I will write about in the rest of the answer.
You basically have to choose between a single big (global) transaction and multiple smaller (local) transactions.
The Saga should contain only coordination logic, it should not enforce the business rules on its own. A hint on how to model it is this: when you add a new business rule regarding the Ad Buying Process, the Saga should not be modified.
The business rules (the invariants) should be checked by each Aggregate that owns the data needed for the validation. For example:
Rule 1: They can specify the number of items that they would like to buy, which has to be between min and max allowed by the ad - The Ad Aggregate
Rule 2: They need to be active (as members can be banned - The Buyer Aggregate
Rule 3: The ad needs to be active (Ads can be suspended) - The Ad Aggregate
Rule 1 and 3 are checked by the Ad::buyedBy($buyerId, $quantity)
and Rule 2 is checked by the Buyer::buyAd($buyerId, $quantity)
. The Saga would just glue those method calls. How does it do that it depends on you low level architecture and resilience requirements.
Supposing that you would use the style promoted by cqrs.nu where the Aggregates process Commands (they have methods like handleXXX(XXX $command)
), like I would have done, then your Aggregates and your Saga would look like this:
class Ad
{
function handleBuyAd(BuyAd $command)
{
if (!$this->active) {
throw new \Exception("Ad not active");
}
if ($command->quantity < $this->minimum || $command->quantity > $this->maximum) {
throw new \Exception("Too litle or too many");
}
yield new AdWasBuyed($this->id, $command->buyerId, $command->quantity);
}
function handleCancelAdBuy(CancelAdBuy $command)
{
yield new AdBuyinWasCancelled($this->id, $command->buyerId, $command->quantity);
}
}
class Buyer
{
function handleBuyerBuysAd(BuyerBuysAd $command)
{
if ($this->banned) {
throw new \Exception("Buyer is banned");
}
yield new BuyerBuyedAd($command->transactionId, $this->id, $command->buyerId, $command->quantity);
}
}
class BuyAdSaga
{
/** @var CommandDispather */
private $commandDispatcher; //injected
function start($transactionId, $adId, $buyerId, $quantity)
{
$this->commandDispatcher->dispatchCommand(new BuyAd($transactionId, $adId, $buyerId, $quantity));
}
function processAdWasBuyed(AdWasBuyed $event) //"process" means only once
{
try {
$this->commandDispatcher->dispatchCommand(new BuyerBuysAd($event->transactionId, $event->adId, $event->buyerId, $event->quantity));
} catch (\Exception $exception) {
// this is a compensating command
$this->commandDispatcher->dispatchCommand(new CancelAdBuy($event->transactionId, $event->adId, $event->buyerId, $event->quantity));
}
}
}
The commands contain a $transationId
used to identity the process of buying an Ad. It can be also seen as a type of correlation Id. You may dump it.
The Saga is started by the start
method. You can also dump it and consider the Saga started by sending the first command to the Ad Aggregate. I've made it like this to be more explicit how this process starts.
If the BuyAd
command fails then no compensation is needed but if the BuyerBuysAd
command fails then a compensation is done by sending the command CancelAdBuy
to the Ad Aggregate.
Note that this Saga only reacts to events by sending commands and nothing more. It does not enforce any business invariants, it just coordinates the entire process.