Encapsulating interactions with the outside world in your domain model.
In my ever-lasting battle against anemic domain models I've developed a preference on how to integrate with the external concerns. I've seen many developers struggle to decide where and how implement these integrations. Often, this left applications dominated by ORM models, mutated externally, only being responsible for persisting information. In this post I'd like to show an alternative, a strategy on how to keep encapsulating behaviour while interacting with the outside world.
Anemic domain models
Before diving into the solution, it's good to get on the same page about anemic domain models. First of all, anemic domain models have absolutely nothing to do with having a low count of healthy red blood cells. Instead, anemic domain models are software models that are deprived of business logic and behaviour. It's not that there are no business rules in the system, they are merely displaced. Anemic domain model emerge when behaviour that should be inside the model is placed on the outside.
When your domain model relies on an external service, interacting with this service is an integral part of your software model. Your software model is responsible for enforcing any business rules in a consistent manner. Yet often these external interactions are pulled outside of the model, sabotaging the model's ability to enforce consistency, and breaking encapsulation.
As an example, let's pretend we're developing a food delivery platform. After adding items to our order we can finalise our order, which starts the checkout/payment flow. Since we're in the food ordering business, we'll be using an external Payment Service Provider (PSP) to cater to our payment needs.
$order = $repository->findOrder($orderId);
$psp = new PaymentServiceProvider($credentials);
$checkoutUrl = $psp->startCheckout(
$order->totalAmount(),
$order->formatDescription(),
);
$order->registerCheckoutUrl($checkoutUrl);
$repository->save($order);
return redirectToExternalUrl($checkoutUrl);
The sample above is a common sight, and suffers from external behaviour. The code or class around the object has "stolen" behaviour that should reside inside the class it interacts with. This is often referred to as feature envy. To illustrate why this is problematic, let's add a new business requirement; supporting minimum order amounts. How should we go about that?
When behaviour is on the outside of an object, it becomes a magnet that draws more behaviour out of the model. To implement our new business constraint, the new decision is placed next to the previously externalised behaviour.
$order = $repository->findOrder($orderId);
$psp = new PaymentServiceProvider($credentials);
if ( ! $order->reachedMinimumOrderAmount()) {
return redirect_back([
'error' => OrderStatus::INSUFFICIENT_AMOUNT,
]);
}
$checkoutUrl = $psp->startCheckout(
$order->totalAmount(),
$order->formatDescription(),
);
$order->registerCheckoutUrl($checkoutUrl);
$repository->save($order);
return redirectToExternalUrl($checkoutUrl);
When more behaviour is external, the model digresses into a mere store of values. It becomes nothing more than an abstract representation of rows in the database. Domain knowledge is no longer embedded in the model. Over time, such models become increasingly harder to understand, causing bugs and inconsistent application of business rules. In addition to that, it becomes increasingly more difficult to understand the system, which causes engineers to make poorer decisions and increases onboarding time for new engineers.
Embedding external interaction in the domain model
The alternative to this approach, is to embed the external interactions inside our model. Concretely this means that all of the behaviour about finalising an order should be places in a method of the Order
class. Doing so, ensures it is possible to enforce its own business rules. Of course, we still need to be able to interact with the Payment Service Provider. We accomplish this by passing our Payment Service Provider as a parameter into our method.
class Order
{
public function finalizeOrder(
PaymentServiceProvider $psp
): FinalizeSuccess|FinalizeFailure {
$totalOrderAmount = $this->calculateOrderTotalAmount();
if ($totalOrderAmount < $this->minimumOrderAmount) {
return new FinalizeFailure(OrderStatus::INSUFFICIENT_AMOUNT);
}
$this->checkoutUrl = $psp->startCheckout(
$totalOrderAmount,
$this->formatOrderDescription(),
);
return new FinalizeSucces(OrderStatus::AT_CHECKOUT, $this->checkoutUrl);
}
}
Turning our previous code into:
$order = $repository->findOrder($orderId);
$psp = new PaymentServiceProvider($credentials);
$result = $order->finalizeOrder($psp);
if ($result instanceof FinalizeSuccess) {
return redirectToExternalUrl($result->url);
}
return redirect_back([
'error' => $result->status,
]);
Notice how our consuming code has far less knowledge about the internal workings of what it means to finalise an order. When consuming code knows less about the inner workings of a model, the model is more easily changed. The blast radius of model changes becomes more contained, resulting in a higher delivery rate and ultimately more business value delivered.
Whenever new requirements are added, we should make sure to internalise them in our model. For example; we may want to re-use the checkout URL if we try to checkout the same order. We may want to force a new checkout URL if our order is altered in between. Whatever our new (business) requirements may be, we can cater to them from within our model. All of the usages will automatically comply, making them consistently enforced.
Bonus: apply Hexagonal Architecture
When reaching out to the world outside, you can define a port that encapsulates your needs. The port is an interface that expresses the need of its consumer rather than representing all of the capabilities of the implementation. Doing this has several benefits. It reduces the integration surface and exposure to the thing you're integrating with. Secondly, it allows you to hide implementation details, which reduces the integration complexity. Lastly, it reduces coupling between your domain model and the external world.
For our example, we could define our own interface which represents the capability we need from our payment provider.
interface PaymentProvider
{
public function startCheckout(int $amount, string $description): CheckoutUrl;
}
We could use any type of payment provider, regardless of the implementation details. If you PSP only provides an HTTP API you can create an implementation using a HTTP client.
class YourPaymentProvider implements PaymentProvider
{
public function __construct(HttpClient $client)
{
$this->client = $client;
}
public function startCheckout(int $amount, string $description): CheckoutUrl
{
$response = $this->client->sendRequest(
new JsonRequest(
'POST',
'/v2/payments',
['amount' => $amount, 'description' => $description],
),
);
return $response->getField('checkout_url');
}
The smaller interface and consumer-oriented simplifications allow you to easily create fake implementations. This allows you to test code that, in production, relies on external systems without relying on mocking frameworks.
class FakePaymentProvider implements PaymentProvider
{
public function __construct(private string $checkoutUrl)
{
}
public function startCheckout(int $amount, string $description): CheckoutUrl
{
return $this->checkoutUrl;
}
These fake implementation, based by contract tests, greatly speed up your test suite while retaining a high level of confidence.
Conclusion
I hope that showcasing this way of encapsulating these integration into our model helps you make your code more maintainable and easier to change. Keeping these interaction in the core of your model doesn't just make it richer, it makes it more resilient against inconsistencies and more adaptable to changes.
Until next time!