Imagine we are tasked with implementing an API to check whether a discount count can be applied to an order. The Order
domain object contains the items in the basket as well as the customer id:
class Order(
val items: List<Item>,
val customerId: CustomerId
)
We also have a domain object DiscountCode
representing the discount count to be used.
There are several validation rules to check if the given discount count can be applied to the given order:
For rules 1-3 we can say that they are clearly business logic and according to DDD belong in the DiscountCode
aggregate:
class DiscountCode(
val id: DiscountCodeId,
val hasAlreadyBeenUsed: Boolean,
val startTime: LocalDateTime,
val endTime: LocalDateTime
) {
fun isApplicableToOrder(order: Order) {
return
startTime.isBefore(now) && endTime.isAfter(now) // rule 1
&& order.items.none(_.canNotBeDiscounted) // rule 2
&& !hasAlreadyBeenUsed // rule 3
}
}
We can easily load this DiscountCode
from the database and then call the above function to check if it can be used with the given order without incurring any side effects.
The question is what to do with rule 4: Rule 4 can not be checked with just the DiscountCode
class unless we embed a list of all allowed customers into the DiscountCode
class, which is unfeasable if there are thousands of customers. Similarly, we can not embed a list of allowed discount codes into the Customer
class because there may be just as many. In the database we can add a new table with valid customer + discount code tuples:
class DiscountCodeCustomerBinding(
val customerId: CustomerId,
val discountCodeId: DiscountCodeId
)
Thus, in order to check rule 4 we need to make another query to this database table.
Following DDD, where should this business logic for rule 1-3 and rule 4 live? We can not make a database query for rule 4 inside the DiscountCode
class because it is a side effect. We could move rule 1-4 into a domain service that is allowed to make database queries but now we have created an anemic domain model. Putting rule 1-3 into the DiscountCode
class and rule 4 into a separate domain service splits the logic into several places which is very error prone.
This is another good example of the domain model trilemma:
1. Purity: no out-of-process dependencies
The application service would load the state needed for the domain to make the decision and provide such state through a method argument.
e.g.
bindings = discountCustomerBindingRepo.bindingsForCode(discountCode);
discountCode.isApplicableToOrder(..., bindings);
Although that the caller could pass the wrong bindings, at least the signature reminds that this rule must be checked. We sacrifice performance and somewhat completeness to remain pure and free of out-of-process dependencies which makes the domain much easier to unit test.
2. Completeness: as little domain logic leakage as possible
You could let the domain fetch the data it needs by providing it with a service allowing it to do so.
e.g.
discount.isApplicableToOrder(..., bindingsRepository);
3. Performance
Assuming loading data in memory necessary to check the rule is not possible due to performance impacts, you could use a DB query behind an interface to check whether or not a customer is eligible for a discount.
// Pass service, favor completeness
discount.isApplicableToOrder(..., customerEligibilityRule);
// Will have an implementation in the infrastructure layer
interface DiscountCustomerEligibilityRule {
bool isEligibleForDiscount(Customer customer, DiscountCode discount);
}
OR favor purity, rule checked in application service directly...
bool eligible = customerEligibilityRule.isEligibleForDiscount(customer, discount) && discount.isApplicableToOrder(...);
You generally want to favor purity over completeness. Sometimes even though it might seem pointless, I just pass a boolean into the domain that represents the result of a rule to make sure the domain client knows that rule must be checked and the "code to customer eligibility concept" remains visible in the domain.
e.g.
isApplicableToOrder(..., bool customerEligible) {
return ... && customerEligible;
}
Note that this all assumes the model uses some kind of ACL for code eligibility, but more often you may perhaps determine eligibility based off some customer attributes.
There's obviously many ways to solve the problem, but hopefully I gave you some food for thoughts!