When we begin our journey into object-oriented programming, we clearly understand that classes should contain methods that represent key behaviors of the entity. However, over time and with the adoption of layered architecture patterns, we tend to offload all business logic to service classes, leaving entities with nothing more than “get” and “set” methods.

When learning the object-oriented programming paradigm, we often start with simple examples that help us extract methods, inheritance, and interfaces. One that comes to mind is the classic “Vehicle class,” from which “Bicycle,” “Truck,” and “Car” extend—each with a specific number of wheels and a concrete implementation of movement methods. These examples clearly illustrate the essence of OOP: having a class with methods that provide the inherent behavior of the class.

Let me pose the following question: Who hasn’t arrived at a project only to find domain classes that are just “containers of getters and setters”? As Martin Fowler points out in his article “Anemic Domain Model”, we often rely on models that push business logic into services rather than domain classes. This goes against the fundamental idea of object-oriented design and is what he defines as an anti-pattern. As he explains, we incur all the costs, such as mapping to external data sources, while losing the benefits by moving business logic outside the domain into services.

According to Evans and Fowler, the service layer should be thin, and the business logic should reside in the domain, so we don’t miss out on the benefits it provides.

Reading this made me reflect and realize that over time we are swept along by new ways of working, and in trying to adopt them, we often forget the advantages of patterns we used successfully in the past.

That’s why I want to share the experience of a real-world case where moving away from an anemic model and enriching the domain with business logic made the service layer more understandable and also helped reduce code duplication.

I encourage everyone to consider alternative perspectives, to emphasize application design before diving into development, and to remember that code is always a legacy we leave for those who come after us—who, more often than not, will be our future selves with no memory of having touched that application.

Use Case

A major company in the food retail sector needed to develop an application whose purpose was to monitor real-time profits, understand the origin of those profits, enhance strengths, and improve weaknesses.

The client provided us with an API service that delivers product cost and net selling price, and asked us to calculate the profit of each of their ice cream products and evaluate their profitability, grouped by flavor, as a first approximation of the MVP.

Given this scenario, we found it appropriate to define a domain with a class to hold sales data, which we called SquisheeSale, and another to hold the profit and cost for each flavor.

public record SquisheeProfitability(Float profitability, Float profitAmount) {

}

public record SquisheeSale(FlavourEnum flavour, String shopId, float price, float cost) {

}

We defined a service containing all the business logic for the use case, naming it SquisheeByFlavourService.

@Service
@RequiredArgsConstructor
public class SquisheeByFlavourService {

  private final SquiseePort squiseePort;

  public Map<FlavourEnum, SquisheeProfitability> getSquisheeProfitabilityByFlavour() {
    return squiseePort.getSquiseeByFlavour().stream()
        .collect(Collectors.groupingBy(SquisheeSale::flavour, collectingAndThen(reducing(
                    (s1, s2) -> new SquisheeSale(s1.flavour(), null, s1.price() + s2.price(),
                        s1.cost() + s2.cost())),
                this::getSquiseeProfitability
            ))
        );
  }

  private SquisheeProfitability getSquiseeProfitability(Optional<SquisheeSale> sale) {
    return sale.map(s ->
            new SquisheeProfitability((s.price() - s.cost()) / s.cost(), s.price() - s.cost()))
        .orElse(new SquisheeProfitability(null, null));
  }

}

In a second iteration, the client requested to also see the profit for each of their stores. Therefore, we defined a new service for this use case, which we named SquisheeByShopService:

@Service
@RequiredArgsConstructor
public class SquiseeByShopService {

  private final SquiseePort squiseePort;

  public Map<String, SquisheeProfitability> getSquisheeProfitabilityByShop() {
    return squiseePort.getSquiseeByShop().stream()
        .collect(Collectors.groupingBy(SquisheeSale::shopId, collectingAndThen(reducing(
                    (s1, s2) -> new SquisheeSale(s1.flavour(), null, s1.price() + s2.price(),
                        s1.cost() + s2.cost())),
                this::getSquiseeProfitability
            ))
        );
  }

  private SquisheeProfitability getSquiseeProfitability(Optional<SquisheeSale> sale) {
    return sale.map(s ->
            new SquisheeProfitability((s.price() - s.cost()) / s.cost(), s.price() - s.cost()))
        .orElse(new SquisheeProfitability(null, null));
  }

}

We could continue defining more use cases, but we believe these two are enough to highlight several issues with the initial design approach. The first one is obvious: we’ve duplicated the profit calculation logic identically in both use cases. Of course, there are ways to unify this logic—by introducing common classes or using a range of design patterns—but those are not the focus of this discussion.

I’d like to add that with different perspectives and design considerations, we might encounter similar issues in other scenarios. For example, we might have assumed our domain is just the SquisheeProfitability class. In that case, the port should have been enriched with the logic to aggregate by key (store or flavor) using, say, a “mapper” that transforms the API service entity into the domain model. However, that would still keep the duplication alive by pushing our business logic into outer layers, treating it merely as transformation logic.

But if we shift our perspective and enrich the domain with new methods, our domain becomes a class capable of computing its own profit and profitability.

public record SquisheeSale(FlavourEnum flavour, String shopId, float price, float cost) {

  public SquisheeSale() {
    this(null, null, 0F, 0F);
  }

  public SquisheeSale accumulate(SquisheeSale other) {
    return new SquisheeSale(
        firstNonNull(this.flavour, other.flavour()),
        firstNonNull(this.shopId, other.shopId()),
        this.price + other.price,
        this.cost + other.cost);
  }

  public Float getProfitability() {
    return (this.price - this.cost) / this.cost;
  }

  public Float getProfit() {
    return this.price - this.cost;
  }

}

In this way, the business logic becomes much more streamlined, allowing the domain object to be passed to the output ports and granting it the rightful responsibility of deciding what data the consuming component will need.

@Service
@RequiredArgsConstructor
public class SquiseeByShopService {

  private final SquiseePort squiseePort;

  public Map<String, SquisheeSale> getSquisheeProfitabilityByShop() {
    return squiseePort.getSquiseeByShop().stream()
        .collect(Collectors.groupingBy(
            SquisheeSale::shopId, Collectors.reducing(new SquisheeSale(), SquisheeSale::accumulate)));
  }

}

@Service
@RequiredArgsConstructor
public class SquisheeByFlavourService {

  private final SquiseePort squiseePort;

  public Map<FlavourEnum, SquisheeSale> getSquisheeProfitabilityByFlavour() {
    return squiseePort.getSquiseeByFlavour().stream()
        .collect(Collectors.groupingBy(SquisheeSale::flavour,
            Collectors.reducing(new SquisheeSale(), SquisheeSale::accumulate)));
  }

}

Conclusion

We’ve analyzed how the use of behaviorless classes, known as anemic models, can be considered an anti-pattern, as they deprive us of the benefits that come with placing logic inside our domain.

Using a practical example makes this concept much more tangible and, personally, I hope this experience helps you continue growing in the path toward writing cleaner code.

References

Tell us what you think.

Comments are moderated and will only be visible if they add to the discussion in a constructive way. If you disagree with a point, please, be polite.

Subscribe