Search code examples
domain-driven-design

Where to put business logic with side effects in DDD?


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:

  1. Is the discount count expired?
  2. Are there items in the order that can not be discounted?
  3. Has this discount code already been used by someone else?
  4. (Is the customer allowed to use this discount code?)

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.


Solution

  • 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!