Where to put business logic with side effects in DDD?

Viewed 343

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.

4 Answers

You could model "validate this order from this customer" as a saga and have a DiscountPermissionForCustomer aggregate (with operations like "enable discount", "disable discount"). The saga then performs 1-3 via the DiscountCode aggregate and if passed, then performs step 4 via the DiscountPermissionForCustomer aggregate.

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.

I don't think I would necessarily come to the conclusion that I'd have an anaemic domain model just because I need a Domain Service to orchestrate an activity that is beyond the scope of a single aggregate.

It seems that you would not want to waste I/O on a database query to get the customer status if the DiscountCode fails one of the validations that you've already implemented in 'isApplicableToOrder'.

So, your domain service can load the Order and DiscountCode and perform the first three validations with a call to isApplicableToOrder and then either load a dedicated aggregate for the customer profile to check customer permission or use a Repository method:

Customer GetCustomerWithDiscountCodePermission(
    CustomerId customerId, 
    DiscountCode code);

If this returns a Customer then customer has permission, otherwise not.

Depending on the likelihood of tests 1-3 passing versus the other test 4, it may even make sense to perform the customer check first before spending the I/O on loading the Order Aggregate and Discount Code aggregate for checking the order properties.

That's a judgment call. I'd perform the check that is most likely to fail first, but either way I really don't think using a Domain Service implies an anaemic model.

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!

Except for the Order type (which has a Customer with UsableDiscountCodes rather than just a CustomerId), the following simplified types (in F# for fun and clarity) are derived from the question sample:

type DiscountCode = { Id: DiscountCodeId; ... }
type Item = { Id: ItemId; CanBeDiscounted: bool; ... }

type Customer =
    { Id: CustomerId
      ...
      UsableDiscountCodes: List<DiscountCode> }

type Order =
    { Items: List<Item>
      Customer: Customer }

type IsExpired = DiscountCode -> bool
type HasAlreadyBeenUsed = DiscountCode -> bool
type CanItemBeDiscounted = Item -> bool
type CanCustomerUseDiscountCode = Customer -> DiscountCode -> bool

Taking these types as a starting point, if we look at the dependency relations between them, the DiscountCode is the least dependent type while the Order is the most dependent. i.e., DiscountCode doesn't need to know about anything (other than itself), while Order knows at least about the Items and Customer types directly, and the DiscountCode transitively. Representing this graphically (where the elements that "know about" or "depend on" are above, and the ones that are "known by" are below) might look like this:

            ┌───────┐
    ┌───────┤ Order ├────────┐
    │       └───────┘        │
    ▼                        ▼
┌──────┐               ┌────────────┐
│ Item │               │  Customer  │
└───-──┘               └─────┬──────┘
                             │
        ┌──────────────┐     │
        │ DiscountCode │◄────┘
        └──────────────┘

In light of this, the Order (or maybe a "CheckOut") subdomain might be a better context for the isDiscountCodeApplicableToOrder function. The Order subdomain calls the DiscountCode subdomain to determine rules 1 and 3, and then directly determines rules 2 and 4, as they involve the Items and the Customer.

let isDiscountCodeApplicableToOrder order discountCode =
  (not (isExpired discountCode)) // Rule 1
  && (not (hasAlreadyBeenUsed discountCode)) // Rule 3
  && (List.forall canItemBeDiscounted order.Items) // Rule 2
  && canCustomerUseDiscountCode order.Customer discountCode // Rule 4

This is a pure function, as all domain logic should ideally be. If you really really need to defer the loading of the Customer, for performance reasons for example (as suggested in other answers), you can pass a getCustomer function as dependency; this function would only be called if all other checks have succeeded. e.g.,

type Order' =
    { Items: List<Item>
      CustomerId: CustomerId }

let isDiscountCodeApplicableToOrder' getCustomer order discountCode =
  (not (isExpired discountCode)) // Rule 1
  && (not (hasAlreadyBeenUsed discountCode)) // Rule 3
  && (List.forall canItemBeDiscounted order.Items) // Rule 2
  && canCustomerUseDiscountCode (getCustomer order.CustomerId) discountCode // Rule 4

Note that whether the Order holds a Customer or CustomerId is a minor point in terms of the type dependencies manifest in the function types, and as represented in the graph.

Related