On Aggregates and Domain Service interaction
Some time ago, I was asked where I put I/O operations when dealing with aggregates.
The context was a CQRS and Event Sourced architecture, but in general, the approach that I prefer also applies to most imperative ORM entity code (assuming a proper data-mapper is involved).
Scenario
Let's use a practical example:
Feature: credit card payment for a shopping cart checkout
Scenario: a user must be able to check out a shopping cart
Given the user has added some products to their shopping cart
When the user checks out the shopping cart with their credit card
Then the user was charged for the shopping cart total price
Scenario: a user must not be able to check out an empty shopping cart
When the user checks out the shopping cart with their credit card
Then the user was not charged
Scenario: a user cannot check out an already purchased shopping cart
Given the user has added some products to their shopping cart
And the user has checked out the shopping cart with their credit card
When the user checks out the shopping cart with their credit card
Then the user was not charged
The scenario is quite generic, but you should be able to see what the application is supposed to do.
An initial implementation
I will take an imperative command + domain-events approach, but we don't need to dig into the patterns behind it, as it is quite simple.
We are looking at a command like following:
final class CheckOutShoppingCart
{
public static function from(
CreditCardCharge $charge,
ShoppingCartId $shoppingCart
) : self {
// ...
}
public function charge() : CreditCardCharge { /* ... */ }
public function shoppingCart() : ShoppingCartId { /* ... */ }
}
If you are unfamiliar with what a command is, it is just the object that our frontend or API throws at our actual application logic.
Then there is an aggregate performing the actual domain logic work:
final class ShoppingCart
{
// ...
public function checkOut(CapturedCreditCardCharge $charge) : void
{
$this->charge = $charge;
$this->raisedEvents[] = ShoppingCartCheckedOut::from(
$this->id,
$this->charge
);
}
// ...
}
If you are unfamiliar with what an aggregate is, it is the direct object in our interaction (look at the sentences in the scenario). In your existing applications, it would most likely (but not exclusively) be an entity or a DB record or group of entities/DB records that you are considering during a business interaction.
We need to glue this all together with a command handler:
final class HandleCheckOutShoppingCart
{
public function __construct(Carts $carts, PaymentGateway $gateway)
{
$this->carts = $carts;
$this->gateway = $gateway;
}
public function __invoke(CheckOutShoppingCart $command) : void
{
$shoppingCart = $this->carts->get($command->shoppingCart());
$payment = $this->gateway->captureCharge($command->charge());
$shoppingCart->checkOut($payment);
}
}
This covers the "happy path" of our workflow, but we still lack:
- The ability to check whether the payment has already occurred
- Preventing payment for empty shopping carts
- Preventing payment of an incorrect amount
- Handling of critical failures on the payment gateway
In order to do that, we have to add some "guards" that prevent the interaction. This is the approach that I've seen being used in the wild:
final class HandleCheckOutShoppingCart
{
// ...
public function __invoke(CheckOutShoppingCart $command) : void
{
$cartId = $command->shoppingCart();
$charge = $command->charge();
$shoppingCart = $this->carts->get($cartId);
// these guards are injected callables. They throw exceptions:
($this->nonEmptyShoppingCart)($cartId);
($this->nonPurchasedShoppingCart)($cartId);
($this->paymentAmountMatches)($cartId, $charge->amount());
$payment = $this->gateway->captureCharge($charge);
$shoppingCart->checkOut($payment);
}
}
As you can see, we are adding some logic to our command handler here. This is usually done because dependency injection on the command handler is easy. Passing services to the aggregate via dependency injection is generally problematic and to be avoided, since an aggregate is usually a "newable type".
With this code, we are able to handle most unhappy paths, and eventually also failures of the payment gateway (not in this article).
The problem
While the code above works, what we did is adding some domain-specific logic to the command handler. Since the command handler is part of our application layer, we are effectively diluting these checks into "less important layers".
In addition to that, the command handler is required in tests that consume the above specification: without the command handler, our logic will fail to handle the unhappy paths in our scenarios.
For those that are reading and practice CQRS+ES: you also know that those guards aren't always simple to implement! Read models, projections... Oh my!
Also: what if we wanted to react to those failures, rather than just stop execution? Who is responsible or that?
If you went with the TDD way, then you already saw all of this coming: let's fix it!
Moving domain logic back into the domain
What we did is putting logic from the domain layer (which should be in the aggregate) into the application layer: let's turn around and put domain logic in the domain (reads: in the aggregate logic).
Since we don't really want to inject a payment gateway as a constituent part of our aggregate root (a newable shouldn't have non-newable depencencies), we just borrow a brutally simple concept from functional programming: we pass the interactor as a method parameter.
final class ShoppingCart
{
// ...
public function checkOut(
CheckOutShoppingCart $checkOut,
PaymentGateway $paymentGateway
) : void {
$charge = $checkOut->charge();
Assert::null($this->payment, 'Already purchased');
Assert::greaterThan(0, $this->totalAmount, 'Price invalid');
Assert::same($this->totalAmount, $charge->amount());
$this->charge = $paymentGateway->captureCharge($charge);
$this->raisedEvents[] = ShoppingCartCheckedOut::from(
$this->id,
$this->charge
);
}
// ...
}
The command handler is also massively simplified, since all it does is forwarding the required dependencies to the aggregate:
final class HandleCheckOutShoppingCart
{
// ...
public function __invoke(CheckOutShoppingCart $command) : void
{
$this
->shoppingCarts
->get($command->shoppingCart())
->checkOut($command, $this->gateway);
}
}
Conclusions
Besides getting rid of the command handler in the scenario tests, here is a list of advantages of what we just implemented:
- The domain logic is all in one place, easy to read and easy to change.
- We can run the domain without infrastructure code (note: the payment gateway is a domain service)
- We can prevent invalid interactions to happen without having to push verification data across multiple layers
- Our aggregate is now able to fullfill its main role: being a domain-specific state machine, preventing invalid state mutations.
- If something goes wrong, then the aggregate is able to revert state mutations.
- We can raise domain events on failures, or execute custom domain logic.
The approach described here fits any kind of application where there is a concept of Entity or Aggregate. Feel free to stuff your entity API with business logic!
Just remember that entities should only be self-aware, and only context-aware in the context of certain business interactions: don't inject or statically access domain services from within an entity.