On “Rich” Domain Entities in DDD and Use Cases in Clean Architecture

George
Technical blog from UNIL engineering teams
14 min readSep 26, 2024
Photo by Karim Ghantous on Unsplash

In this article we return to the topic of business rules implementation in Use Cases, especially with regard to the well-known recommendation from DDD which stipulates that our domain model must be “rich” in terms of business logic and behavior.

We have looked already at the relationship between Use Cases and Domain Entities layers, in a previous article. There we have looked in details on how Use Cases and Domain Entities have different concerns in Clean Architecture when it comes to dealing with business logic. Here we focus on a somewhat different, but related, aspect. We examine orchestration logic performed by Use Cases and business logic performed in at the level of model entities proper, in the light of what became known in DDD as “Anemic Domain” anti-pattern.

It is best to proceed by an example. Let’s say we have Customer and Product in our domain. A customer may buy a certain number of units of a given product. The total price of the resulting Order will depend on the following business rule (quite made-up obviously):

If the order is placed on a Monday for a product allowing a discount for customers older than 45 years of age, then the total of the order is at the half of the normal price.

Procedural implementation

Let’s start with a straightforward implementation in a procedural-style language which does not have sophisticated OO features. Here is a pseudocode for a subroutine which implements our use case:

array<long, long, int, double> customer_buys_product(long customer_id, long product_id, int number_of_units){

// get customer from the DB: ID, name, age
array<long, string, int> customer = load_customer(customer_id);
int customer_age = customer[2];

// get product from the DB: ID, name, whether a discount applies
array<long, string, bool, double> product = load_product(product_id);
bool does_discount_apply = product[2];
double unit_base_price = product[3];

// get the day of the week from the system
enum<MON,TUE,WEN,THU,FRI,SAT,SUN> day_of_week = system_day_of_week();

/*
Our business logic: calculate the total price for the order taking
into the account a possible discount.
*/
double totalPrice;
if (does_discount_apply && customer_age > 45 && day_of_week == MON){
totalPrice = (number_of_units * unit_base_price) / 2;
}
else {
totalPrice = number_of_units * unit_base_price;
}

// save new order
save_order(customer_id, product_id, number_of_units, totalPrice);

// return information for the new order to the caller
return array(customer_id, product_id, number_of_units, totalPrice);
}

Here are the interesting things to notice with this implementation.

  • We are dealing with literals, strings, and arrays only.
  • We can perform calls to the external services (i.e. database calls, system call for the current date) from the implementation gathering all required information before we perform business logic proper.
  • Business logic is somewhat embedded into the code, although we can refactor it easily into a call to a dedicated subroutine.

Another interesting thing to observe is that this implementation is not actually all that bad. On the contrary, it is quite readable and understandable. Of course, the main problem is the lack of Object-Oriented (or DDD-like) constructs which can greatly improve the implementation. For example, a Value Object for the system date with “richer” behavior would be nice, as well as encapsulating attributes of Customer, Product, and Order.

Object-Oriented (or DDD-like) implementation with domain entities

Let’s now rewrite the above implementation but, now, using a Object-Oriented language (Java). We focus only on the relevant parts, leaving details of the implementation for some referenced types aside. Here are our domain entities modeling customers, products, and orders. We are not talking about aggregates here, for now, nor are we concerned with how exactly Customer and Product instances will appear in our system (we’ll come back to this later). For the reference, here is a GitHub repository with the full code for this example.

First the model for a customer.

@Getter
@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE)
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
class Customer {

@EqualsAndHashCode.Include
CustomerId id;
String name;
Age age;

@Builder
Customer(CustomerId id, String name, Age age) {
this.id = Validate.notNull(id);
this.name = Validate.notBlank(name);
this.age = Validate.notNull(age);
}

}

And the model for a product.

@Getter
@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE)
@EqualsAndHashCode(onlyExplicitlyIncluded = true) @ToString
class Product {

@EqualsAndHashCode.Include
ProductId id;
String name;
BigDecimal unitBasePrice;
Boolean discountApplicable;

@Builder
public Product(ProductId id, String name, BigDecimal unitBasePrice, Boolean discountApplicable) {
this.id = Validate.notNull(id);
this.name = Validate.notNull(name);
this.unitBasePrice = Validate.strictlyPositive(unitBasePrice);
this.discountApplicable = Validate.notNull(discountApplicable);
}

}

We already can make an interesting and somewhat counterintuitive observation.

In our opinion, the models above can already (without even implementing the business logic) be considered as “rich” in DDD sense of the term.

This is already far from being “an anemic domain” implementation. Let’s see all the “richness” which we get with this.

  • We are modeling identifiers as a wrapper Value Objects which allows for descriptive logic following Ubiquitous Language.
  • We are constructing always-valid entities using validating constructors, which greatly improves creation of instances of Customer and Product which follow business rules (“Enterprise Level” rules as Robin C. Martin would put it), such as: age or price cannot be negative or names cannot be blank.
  • Lombok helps us with the technicalities of comparing Value Objects by value of all constituent properties and Entities by their IDs only.
  • We also creating immutable entities (no setters) with proper encapsulation.

Business rule

Of course, some DDD purists will (rightfully) consider the above entities as “anemic”. It’s fair enough — as we have not yet implemented the business logic proper. Let’s do it now.

Our thought may proceed along the following lines. In the real world (and in our example business) a customer buys some number of units of a product. Leaving aside the different aspects of the activity “buy”, we can naturally introduce the following business method into Customer entity.

public class Customer {
// avoiding "magic" constant
private final static BigDecimal DISCOUNT_RATE = BigDecimal.valueOf(2);

/**
* Customer buys specified {@code numberOfUnits} of a given {@code product}.
* Returns a new {@linkplain Order} with total price of the order. A product
* discount is applied if necessary.
*
* @param numberOfUnits number of units of product to buy
* @param product product to buy
* @return new order
*/
public Order buy(Integer numberOfUnits, Product product) {

// get the current day of the week
DayOfWeek dayOfWeek = LocalDate.now().getDayOfWeek();

// calculate total price by checking if discount applies
BigDecimal totalPrice;
if (product.getDiscountApplicable()
&& this.age.strictlyGreaterThan(new Age(45))
&& dayOfWeek == DayOfWeek.MONDAY) {
// with the discount
totalPrice = BigDecimal.valueOf(numberOfUnits)
.multiply(product.getUnitBasePrice())
.divide(DISCOUNT_RATE, RoundingMode.HALF_EVEN);
} else {
// without the discount
totalPrice = BigDecimal.valueOf(numberOfUnits)
.multiply(product.getUnitBasePrice());
}

// make new order and return
return Order.builder()
.id(new OrderId(new Random().nextLong()))
.customerId(this.id)
.productId(product.getId())
.numberOfUnits(numberOfUnits)
.totalPrice(totalPrice)
.build();
}
}

Well, this does look very “rich” indeed. We are following Ubiquitous Language practically to the letter.

Given a scenario: “Create new order when customer buys 4 units of a product”.

Actual implementation of the scenario with the entities above, will be:

Order newOrder = customer.buy(4, product);

There are some other good things about this implementation.

  • It is quite readable and understandable. All business logic is in one business method of Customer entity.
  • Great use of various Value Objects : LocalDate, DayOfWeek, BigDecimal, etc. which contribute to the readability and maintainability of the code.

But, in our opinion, there are some subtle caveats with great implications, as well.

Relations between domain entities

Looking at our model more rigorously in terms of DDD concepts, we can see that, in our example, Customer , Product , and Order are aggregates or — to be precise — Aggregate Roots. Moreover, we can state the following:

  • Implementation respects Vernon’s rule-of-thumb for aggregate design. No entities store any references to any (other) aggregate roots. For example, there is a logical dependency between Order and Product implemented as ProductId Value Object property (an identifier) in Order aggregate root.
  • Law of Demeter is respected (in the business method of Customer) as we are not accessing anything other than instance variable of Customer, public methods of Product (parameter to the method), and the various objects created within the method itself — all of which is not prescribed by the rule.

All is good so far. But what about the fact that we have created a dependency (a logical one) between two aggregate roots: Customer and Product. This may not seem to be much, but let’s look at it closer. We have introduced a hard-coded relation between Customer and Product with this code in our implementation above:

product.getDiscountApplicable() && this.age.strictlyGreaterThan(new Age(45))

This does not seem much but we creating a relation which relies on a direct connection between a (private) instance variable of one entity ( this.age ) and a public method of an entity from another aggregate ( product.getDiscountApplicable() ). In itself this is not wrong at all, but this does increase the instability of the core of our application. Here is why:

The formula for calculating the instability of a component (a class in our case) is as following:

Math function: instability (I) of component equals degree of efferent coupling (Ce) divided by the sum of Ce and degree of afferent coupling (Ca).
Instability (I) as a function of degree of efferent coupling (Ce) and degree afferent coupling (Ca). Image generated by AsciiMath

So instability (I) is directly proportional to the degree of efferent coupling (Ce) of a component (number of other components it depends on) and inversely proportional to the sum of (Ce) and the degree of afferent coupling of the component (Ca), i.e. the number of other components which depend on the component at hand.

Having introduced Product as a parameter to the business method of Customer, and having an instance of Order returned from the method, we have increased the efferent coupling of Customer and, consequently, we have increased the overall instability of the core (model) of our application.

This goes against the precept of Clean Architecture which stipulates that we should strive to keep our model (center layer of “the onion”) as stable as possible.

Asking for the day of the week

Another potential issue with the above implementation has to deal with the responsibility of getting the information from a system call (to get the current date). It seems trivial enough, but the question may arise as to which one of Customer or Product should ask the system for the current date.

Indeed we may rewrite Product as following:

public class Product {

// other code omitted

@Getter(AccessLevel.NONE)
Boolean discountApplicable;

// system call is part of the logic for availability of the discount
public Boolean getDiscountApplicable() {
return discountApplicable
&& LocalDate.now().getDayOfWeek() == DayOfWeek.MONDAY;
}

// other code omitted
}

What we have done is that we have moved the logic related to time (which day of the week we are at) to Product as part of the check for the availability of the discount.

It goes to show two things. First — that a complex business logic may be spread inadvertently between the entities of different aggregates. That may increase cognitive complexity of the overall system. In our example, looking at the new “buy” method now we do not right away see that there is a check for the day of the week involved in the business logic at all.

Secondly, there is a question of making a system call from an aggregate in the first place. This is innocent enough in our case (and wonderfully handled by the built-in Java library), but what if we are dealing with a distributed system spanning multiple time zones and customers habitually doing some midnight shopping? What we would want to have in such a case, is a service which we could use while processing our business rule. This service can even contact an external system, if we have some centralized time-keeping in place.

The similar concern arises when we need to make a valid ID for the new Order we have created at the end of the business method. A careful observer has certainly noticed how we have skimmed over this point by simply wrapping a new instance of OrderId around a random Long. This is, of course, inadequate in a real-world system. Just like the system call for LocalDate, we may here need to call a service to provide a unique identifier with a reasonable guarantee that it would not collide with any IDs of already persisted orders.

Well, canonical DDD does not advocate for using such services directly from the aggregates themselves. This why we are not using a repository directly from an aggregate either, by the way. So there is no question of having the interfaces of such auxiliary services wired into aggregate, for example.

Our dilemma

We seem to have a dilemma here. The “good-old” procedural code, which we saw at the beginning, does not suffer from the logic of business rules being spread across several entities. It is actually more comprehensible in the way it presents the logic for the business rule. Moreover, it does not have any restrictions on issuing calls to any services (sub-routines, libraries) which it may need in order to assemble all the necessary inputs for the business logic processing (i.e. call to time service or IDs generator). So, we can say that it is complete. But it suffers from the lack of reusability, the lack of encapsulation, and the lack of (natural) expressiveness.

On the other hand, the DDD-like approach, which we just saw above, has all the expressiveness of a “rich” domain model compatible with Ubiquitous Language. It is quite reusable thanks for the carefully designed Value Objects. But, as we have mentioned, it lacks completeness (we do not call external services directly from an entity) and it may have a tendency to get business logic entangled in a web of intricate interdependencies between entities, with the effect being an increase of instability of the core and an increase of cognitive complexity of the code.

To solve this dilemma we may get a bit of help from… philosophy. Indeed, we can present things as three different moments of the same dialectical development.

Thesis: Complete procedural implementation with simple to follow sequential logic but lacking expressiveness, encapsulation, and reusability.

Anti-thesis: Expressive Object-Oriented or DDD-like implementation using the power and the reusability of domain entities and Value Objects but lacking in completeness and clear presentation of inter-aggregate business logic.

Synthesis: Some other paradigm must appear here which would bring the essential moment of the thesis back through the contradiction but on the higher level.

Use Cases in Clean Architecture

We postulate that Use Cases in Clean Architecture are exactly the synthetical solution to our dilemma. To explain why it is so, let us begin by looking at how we may implement a use case for our example using the usual techniques from Clean DDD approach.

@FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE)
@RequiredArgsConstructor
public class BuyProductUseCase implements BuyProductInputPort {

BuyProductPresenterOutputPort presenter;
PersistenceOperationsOutputPort persistenceOps;
DateTimeOperationsOutputPort dateTimeOps;
IdGeneratorOperationsOutputPort idGeneratorOps;

@Override
public void customerBuysProductWithDiscountIfApplicable(Long customerIdArg, Long productIdArg, Integer numberOfUnits) {
// transaction demarcation and security checks are omitted for clarity

try {
// convert parameters into Value Objects as an equivalent of input validation
CustomerId customerId = new CustomerId(customerIdArg);
ProductId productId = new ProductId(productIdArg);

// load aggregates involved in our scenario
Customer customer = persistenceOps.obtainCustomerById(customerId);
Product product = persistenceOps.obtainProductById(productId);

// get the current day of the week by calling an output port (external service)
DayOfWeek dayOfWeek = dateTimeOps.obtainCurrentDayOfWeek();

// get the next available ID for the new order by calling an output port
OrderId orderId = idGeneratorOps.obtainNextAvailableOrderId();

// perform our business logic, as before, in "buy" method of "Customer"
BigDecimal totalPrice;
if (product.getDiscountApplicable()
&& customer.getAge().strictlyGreaterThan(new Age(45))
&& dayOfWeek == DayOfWeek.MONDAY) {
// with the discount
totalPrice = BigDecimal.valueOf(numberOfUnits)
.multiply(product.getUnitBasePrice())
.divide(DISCOUNT_RATE, RoundingMode.HALF_EVEN);
} else {
// without the discount
totalPrice = BigDecimal.valueOf(numberOfUnits)
.multiply(product.getUnitBasePrice());
}

// create and persist new order
Order order = Order.builder()
.id(orderId)
.customerId(customerId)
.productId(productId)
.numberOfUnits(numberOfUnits)
.totalPrice(totalPrice)
.build();
persistenceOps.saveOrder(order);

// present successful outcome to the user
presenter.presentSuccessfulNewPurchaseOrderCreation(orderId, customer.getName(), product.getName(), numberOfUnits);

} catch (Exception e) {
// present error back to the user
presenter.presentError(e);
}

}
}

We can immediately see how the use case above corresponds closely to the procedural solution we have given at the beginning of the article. Instead of calling a business method of an entity (Customer in our example) we also, as in the case of the subroutine, simply perform the business logic directly in the method of the use case.

We also benefit of the completeness of the solution here. Customer and Product do not appear as if from a thin air (as in the Object-Oriented solution), but are simply loaded from the persistence gateway through a dedicated output port. The same goes for obtaining the day of the week and the next available identifier for Order. Use Cases layer has no difficulty to cooperate with any services (or adapters) on the right-hand side of the application hexagon.

But, as in any dialectical resolution of a contradiction, we do not simply return to our thesis — procedural code. We attain a “higher” level with our solution. This improvement springs forth from the techniques of Object-Oriented and DDD paradigms which we employ profusely in Clean Architecture. By far the largest benefit we can observe at this level, is that we are able to employ always-valid domain entities and Value Objects, which gives us the guarantee of enforcing intra-aggregate invariants (also a kind of business rules) automatically.

Let’s now see what has happened to the degree of coupling and instability of the solution when we moved away from the Object-Oriented (or DDD-like) approach to our use case. Well, BuyProductUseCase also has dependencies on Customer, Product, and Order. In addition, we have now dependencies on all of the output ports as well. This, undoubtedly, increases efferent coupling of our use case, rendering it more unstable. Use Cases are integral part of the domain in Clean Architecture. So it appears that we have decreased the overall stability of the core of our system. But, things are a little more subtle here.

In Clean Architecture, Use Case layer is not the same as Domain Entities layer. And it is precisely because it is an outer layer — with respect to Domain Entities layer — its instability does not have the same nefarious impact on our solution. Use Cases are meant to be less stable than entities. Moreover, different use cases are independent from each other, forming a sort of separate “slices” in our application. This allows us to change a use case more easily than if we change a domain entity from the layer below.

When implementing business rules involving inter-aggregate relations in Use Cases layer, contrary to an implementation in Domain Entities layer, we effectively moving the overall instability of the solution to the outer layer of the “onion” — there where it will have less negative impact to the maintainability of our application.

Domain services

To be honest, the solution which we presented as an Object-Oriented or DDD-like above, has some inherent flaws. First of all, Object-Oriented programming revolves around concepts of objects and messages. Object encapsulates data and related behavior, and communicates with other objects via messages (i.e. method calls). When we sending an entire object graph ( Product ) as a part of a message to another object — Customer, we are going against the original logic of communication between objects as it was intended in Object-Oriented paradigm.

Neither can we pretend that that solution can conform to the precepts of DDD (hence the “-like” suffix). And this is obviously because any DDD practitioner will instantly discern a need for a domain service, when confronted with the business rule we are using for our example.

Domain services in DDD is a very interesting topic in its own right. We can briefly state here that a true DDD solution to our problem would probably involve creation of a specification. This specification would then be employed on the given product with the age of the customer supplied as parameter to determine the applicability of the discount. Or we can encapsulate the entire total price calculation in a strategy. Either way, this will render our business rule more explicit in the solution.

However, it may be argued that, since domain services in DDD are the integral part of the model itself, so moving the instability into domain services will not have the same benefit as in the case of with the solution involving use cases. Moreover, there remains the problem of accessing an external service from a domain service — something which would probably not sit well with canonical DDD paradigm.

Discussion

In this article, we have looked in detail at the trade-offs involved when choosing to implement a non-trivial business rule either by Object-Oriented interaction between entities themselves or via a use case. The former approach is what we may see in a “rich” domain of DDD. The latter — we can observe in Clean Architecture.

We have followed a dialectical evolution of Use Case from a straightforward procedural code onwards through its negation by Object-Oriented and DDD paradigms. We have also tried to understand what this evolution entails in terms of coupling and instability in our system.

References

  • Source code used for this article
  • Article by Derek Comartin discussing software metrics
  • Related article about Use Cases and Domain Entities in Clean Architecture

--

--