DDD: The problem with domain services that need to fetch data as part of their business rules
Asked Answered
H

1

2

Suppose I have a domain service which implements the following business rule / policy:

If the total price of all products in category 'family' exceeds 1 million, reduce the price by 50% of the family products which are older than one year.

Using collection-based repositories

I can simply create a domain service which loads all products in the 'family' category using the specification pattern, then check the condition, and if true, reduce the prices. Since the products are automatically tracked by the collection-based repository, the domain service is not required to issue any explicit infrastructure calls at all – as should be.

Using persistence-based repositories

I'm out of luck. I might get away with using the repository and the specification to load the products inside my domain service (as before), but eventually, I need to issue Save calls which don't belong into the domain layer.

I could load the products in the application layer, then pass them to the domain service, and finally save them again in the application layer, like so:

// Somewhere in the application layer:
public void ApplyProductPriceReductionPolicy()
{
  // make sure everything is in one transaction
  using (var uow = this.unitOfWorkProvider.Provide())
  {
    // fetching
    var spec = new FamilyProductsSpecification();
    var familyProducts = this.productRepository.findBySpecification(spec);

    // business logic (domain service call)
    this.familyPriceReductionPolicy.Apply(familyProducts);

    // persisting
    foreach (var familyProduct in familyProducts)
    {
      this.productRepository.Save(familyProduct);
    }

    uow.Complete();
  }
}

However, I see the following issues with this code:

  • Loading the correct products is now part of the application layer, so in case I need to apply the same policy again in some other use case, I need to repeat myself.
  • The cohesion between the specification (FamilyProductsSpecification) and the policy is lost, essentially allowing someone to pass the wrong products into the domain service. Note that filtering the products (in-memory) again in the domain service does not help either, as the caller might have passed only a subset of all products.
  • The application layer has no clue which products have changed, and therefore is forced to save all of them, which might be a lot of redundant work.

Question: Is there a better strategy to deal with this situation?

I thought about something complicated like adapting the persistence-based repository such that it appears as a collection-based one to the domain service, internally keeping track of the products which were loaded by the domain service in order to save them again when the domain service returns.

Howler answered 25/4, 2021 at 14:58 Comment(0)
C
6

First of all, I think choosing a domain service for this kind of logic - which does not belong inside one specific aggregate - is a good idea.

And I also agree with you that the domain service should not be concerned with saving changed aggregates, keeping stuff like this out of domain services also allows you to be concerned with managing transactions - if required - by the application.

I would be pragmatic about this problem and make a small change to your implementation to keep it simple:

// Somewhere in the application layer:
public void ApplyProductFamilyDiscount()
{
  // make sure everything is in one transaction
  using (var uow = this.unitOfWorkProvider.Provide())
  {

    var familyProducts = this.productService.ApplyFamilyDiscount();

    // persisting
    foreach (var familyProduct in familyProducts)
    {
      this.productRepository.Save(familyProduct);
    }

    uow.Complete();
  }
}

The implementation in the product domain service:

// some method of the product domain service
public IEnumerable<Product> ApplyFamilyDiscount()
{
    var spec = new FamilyProductsSpecification();
    var familyProducts = this.productRepository.findBySpecification(spec);

    this.familyPriceReductionPolicy.Apply(familyProducts);

    return familyProducts;
}

With that the whole business logic of going through all family products older than a year and then applying the current discount (50 percent) is encapsulated inside the domain service. The application layer then is again only responsible for orchestrating that the right logic is being called in the right order. The naming and how generic you want to make the domain service methods by providing parameters might of course need tuning, but I usually try to make nothing too generic if there is only one specific business requirement anyway. So if that's the current family product discount I would than already know where exactly I need to change the implementation - in the domain service method only.

To be honest, if the application method is not getting more complex and you don't have different branches (such as if conditions) I would usually start off like you originally proposed as the application layer method also simply makes calls to domain services (in your case the repository) with the corresponding parameters and has no conditional logic in it. If it get's more complicated I would refactor it out into a domain service method, e.g. the way I proposed.

Note: As I don't know the Implementation of FamilyPriceRedcutionPolicy I can only assume that it will call the corresponding method on the product aggregates to let them apply the discount on the price. E.g. by having a method such as ApplyFamilyDiscount() on the Product aggregate. With that in mind, considering that looping through all the products and calling the discount method will be only logic outside the aggregate, having the steps of getting all products from the repository, calling the ApplyFamilyDiscount() method on all products and saving all changed products could indeed just reside in the application layer.

In terms of considering domain model purity vs. domain model completeness (see discussion below concerning the DDD trilemma) this would move the Implementation again a little more in the direction of purity but also makes a domain service questionable if looping through the products and calling the ApplyFamilyDiscount() is all it does (considering the fetching of the corresponding products via the repository is done beforehand in the application layer and the product list is already passed to the domain service). So again, there is no dogmatic approach and it is rather important knowing the different options and their trade-offs. For instance, one could also consider to let the product always calculate the current price on demand by applying all applicable possible discounts when asking for the price. But again, if such a solution would be feasible depends on the specific requirements.

Cellaret answered 26/4, 2021 at 5:32 Comment(8)
Although returning the modified objects from domain services sort of sneakily pollutes the API design of the domain layer with infrastructure concerns, I could live with this guideline, especially if one finds suitable domain-specific names for these return types (like you did with familyProducts). I don't quite get your last paragraph though. Do you suggest that having no branching in the business logic legitimizes putting it directly in the app layer? Isn't "calling something in the right order" also an important part of the business logic?Howler
Thanks for the note, that was an error in my post, I corrected it. I meant, the application method. What I meant was, if the application method (or use case) would be simple it can be a pragmatic decision to not create a separate domain service immediately and to refactor the code out into a domain service if it either needs to be reused somewhere as or if it get's more complicated. Of course this is not only related to having branches in the code, that was more like an example because conditional code often can be hint to more complexity in logic.Receiptor
In the end it is a mixture of best practices, deciding based on the situation at hand and sometimes personal preference. As It said I personally would already see the steps for discounting in a separate domain service but would not have dogmatic objections to the code you proposed in the first place if it is still the only place where this is called. But in addition to having the code encapsulated into another distinct place (e.g. a domain service method) giving this domain service method a business relevant name is also a good plus extracting in terms of readability.Receiptor
I see, thanks for the clarification. I still struggle a bit in understanding the term "use case". Probably there is a distinction between "application use cases" and "business use cases". Only the former should reside directly in the app layer, while the latter should preferably always be in some domain service (minus any pragmatic decisions to counter this ideal world), correct?Howler
This is the classic DDD trilemma between domain purity, completeness & performance.Harrisonharrod
@plalx, amazing article and blog! This is the most throughout and to-the-point discussion about several issues I've faced since learning DDD I've ever seen. What I find interesting is the favoring of "domain model purity" over "domain model completeness". @afh's solution does the opposite. Say we don't name the repository productRepository, but instead just products and name the type AggregateCollection<Product>. Sure, the call is not referentially transparent, but at least the naming hides the out-of-process nature of it – this is exactly what collection-based repos are for.Howler
I added some notes to my answer with additional thoughts that came into my mind after als considering Vladimir Khorikovs advice. Although I think it is a good idea to keep out-of-process dependencies out of domain services I still think there can be situations when allowing domain services having access to repositories can be a valid option, especially if testability of the business logic is not negatively affected.Receiptor
Great! I agree. IMO, it is crucial to load the right data before doing any of the logic on it. Therefore, I favor completeness over purity, since testing the service is straight forward by providing an in-memory-constructed collection of test products.Howler

© 2022 - 2024 — McMap. All rights reserved.