DEV Community

Semyon Kirekov
Semyon Kirekov

Posted on • Updated on

Rich Domain Model with Spring Boot and Hibernate

The article is a long read. I recommend you to look through the Table of contents in advance. Perhaps some parts can be more intriguing than others.


I like Hibernate. Even though it's complicated (and error-prone sometimes), I consider it an extremely useful framework. If you know how to cook it, it shines.

Though Hibernate is in implementation of JPA, I'm going to say Hibernate every time I mean JPA. Just for the sake of simplicity.

Lots of Java developers use Hibernate in their projects. However, I’ve noticed a strange tendency. Java devs apply Anemic Domain Model pattern almost every time. Whenever I ask about reasons for picking up that strategy, I get answers like:

  1. We've always done it before and it works fine.
  2. Our developers got used to such architecture.
  3. Do we have any other options?

That's why I've decided to come up with this article. I want to reconsider the status quo of Anemic Domain Model usage with Hibernate and propose you something different. And that is Rich Domain Model pattern.

In this article, I'm telling you:

  1. What is Rich Domain Model?
  2. What's wrong with Anemic Domain Model and how can Rich one fix the issues?
  3. Step by step solution.
  4. What disadvantages does Rich Domain Model have?

article cover

You can check out the entire repository with code examples by this link.

Table of contents

  1. The problems with Anemic Domain Model
    1. Too smart services
    2. Possible invariants' violation
    3. Lack of encapsulation
    4. Testing is harder
  2. Rich Domain Model principle
  3. Don't add setters, getters, and public no-args constructor
    1. No-args constructor allows incostistent object instantiation
    2. Setters break encapsulation
    3. Getters break encapsulation
    4. Current result
  4. Aggregate and Aggregate root
  5. Evolution of requirements
    1. Each Pocket must possess at least one Tamagotchi
    2. Every Tamagotchi name has to be unique within a Pocket
    3. If a user deletes a Tamagotchi, they can restore it by name
  6. Querying data
    1. Manual queries
    2. Introducing toDto method
  7. Unit testing entities
    1. Pocket must always possess at least one Tamagotchi
    2. If you delete Tamagotchi, you can restore it by name
    3. If you delete multiple Tamagotchi with the same name, you can only restore the last one
  8. Integration testing
    1. Create Pocket
    2. Create Tamagotchi
    3. Update Tamagotchi
  9. Performance implications
    1. Optimization of queries
    2. Pinpointing optimized checks
  10. Database generated ID
    1. Manually fill the id
    2. Introducing business key
  11. Is Rich Domain Model always worth it?
  12. Conclusion
  13. Resources

The problems with Anemic Domain Model

Firstly, let’s discuss the domain. We’re going to develop the Tamagotchi application. Pocket may have many Tamagotchi instances, but each Tamagotchi belongs to a single Pocket. Therefore, the relationship is Pocket --one-to-many-> Tamagotchi.

Most likely the Anemic Domain Model solution would be implemented in this way:

Anemic domain model example

I bet you’ve seen a lot of similar Java code. But the solution contains many problems. Let’s discuss them one by one.

Too smart services

Anemic Domain Model requires the service layer to possess all business logic. While entities act as dummy data structures. But entities are not static. They develop during the time. We may add some fields and delete others. Or we can combine existing fields in Embeddable object.

Here, services have to know every minor detail of the entity they are working with. Because any operation may require access to different fields. Meaning that even a slight change in an entity may lead to major restructuring in many services. Actually, that breaks Open-Closed principle. The code becomes not object-oriented but procedural. We don’t use benefits of OOP paradigm. Instead, we bring additional difficulties.

Possible invariants' violation

Invariant is a business rule that allows only certain changes to entities. It guarantees that we won’t transmit entities to the wrong state. For example, suppose that Pocket may contain only three Tamagotchi by default. If you want to have more, you need to buy premium subscription. That’s an invariant. The code has to disallow adding fourth Tamagotchi to Pocket, if user don’t purchase the additional feature.

If we choose the Anemic Domain Model approach, it means that services are obligatory to check invariants and cancel operation if needed. But invariants are also not static. Imagine that the rule of three Tamagotchi within Pocket has not been introduced from the start of the project. But we want to add it now. It means that we have to check every method and function that might create a new Tamagotchi and add corresponding checks.

It becomes even worse if changes are broader. Suppose that Tamagotchi has become a part of Saga pattern. Now it contains status field that has a value of PENDING. If Tamagotchi is PENDING, you can neither delete it nor update it. Do you see where I’m going? You have to check every piece of code that updates or deletes Tamagotchi and make sure you don’t miss any check for PENDING status.

Lack of encapsulation

Encapsulation in OOP is a mechanism that restricts direct access to certain data. That makes sense. Entity might have several fields, but it doesn’t mean we want to allow changing each of them. We might change only simultaneously concrete fields. Other ones are allowed to be updated only if the entity transmits to a specific state.

Anemic Domain Model forces us to give up encapsulation and put @Getter and @Setter annotations from Lombok without considering the consequences.

And the biggest problem with violating encapsulation is that code becomes more dangerous to work with. You cannot just call setName or setStatus methods. But you have to make sure that you check specific conditions in advance. Again, invariants aren’t static. So, every mutation call to an entity is like a land mine. You don’t know what breaks next, if you miss a single condition check.

Testing is harder

Mostly developers use Hibernate in combination with Spring Boot. Meaning that services are regular Spring beans with @Transactional annotation. Usually those services contain spaghetti code of entities, repositories, and other services invocations. When it comes to testing, I see developers choose one of options:

  1. Integration testing.
  2. Mocking everything.

Don’t get me wrong. I think that integration testing is crucial. And Tescontainers library especially helped to make the process smooth. However, I think that the count of integration tests should be as minimum as possible. If you can validate something with a simple unit test, do it this way. Bringing too much integration tests into the project also leads to certain difficulties:

  1. Integration tests are harder to maintain.
  2. There is always a shared resource (database, in this case). So, tests might unexpectedly become dependent on each other. Tests might get flaky.
  3. It's tough to run integration tests in parallel.
  4. Those tests are much slower. If your project is old enough and you have many integration tests, a regular CI build can run in 30 minutes or even more.

Too many integration tests

What about mocking? I think such tests are almost useless. I don’t mean that mocking in general is a bad idea. But if you try to mock every call to Spring Data JPA repository and other service, it may occur that you don’t test the behaviour. You just verify the correct order of mocks’ invocations. So, tests become fragile and an enormous burden to maintain.

Rich Domain Model principle

On the contrary, Rich Domain Model pattern proposes a different approach. Look at the diagram below.

Rich domain model principle

As you can see, entities hold the required business logic. While services act like a thin layer that delegates call to repositories and entities.

Rich Domain Model correlates with tactical patterns of Domain Driven Design. The one that we're interested in is aggregate.

Aggregate is a cluster of domain objects that you can treat as a whole unit. For example, Pocket has many Tamagotchis. Meaning that Pocket and Tamagotchi can be a single aggregate. Aggregate root is the entity that allows direct access to aggregate and guarantees invariants’ correctness. Therefore, if we want to change something in Tamagotchi, we should only interact with Pocket.

By introducing Rich Domain Model, I want to solve these problems:

  1. Code should become more business-oriented. If logic is divided between many services, it's hard to understand the actual operation flow (especially for newcomers).
  2. Let the compiler validate your code. If an entity has a setter for every field, you should put additional check whenever you invoke it. But if an entity provides only certain amount of methods that mutates its state, it means that incorrect transition is not possible due to compiler check. In another words, if a method doesn't exist, you cannot call it. So, it's better to provide only those operations that are needed.
  3. Reduce the amount of integration tests. If it's possible, it's better to test business logic with simple unit tests. So, I want to replace some integration tests with unit ones without violating the quality assurance level.

Let's start our journey with refactoring Pocket and Tamagotchi to Rich Domain Model.

Don't add setters, getters, and public no-args constructor

Firstly, look at the initial approach of designing Pocket and Tamagotchi entities following Anemic Domain Model:

@Entity
@NoArgsConstructor
@Setter
@Getter
public class Pocket {
  @Id
  private UUID id;

  private String name;

  @OneToMany(mappedBy = "pocket")
  private List<Tamagotchi> tamagotchis = new ArrayList<>();
}

@Entity
@NoArgsConstructor
@Setter
@Getter
public class Tamagotchi {
  @Id
  private UUID id;

  private String name;

  @ManyToOne(fetch = LAZY)
  @JoinColumn(name = "pocket_id")
  private Pocket pocket;
}
Enter fullscreen mode Exit fullscreen mode

Here I’m using UUID as a primary key. I understand that there are some performance implications for it. But now client-side generated ID is crucial for a smooth transition to the Rich Domain Model. Anyway, later I’ll give you some examples with other ID types.

I bet this looks familiar. Perhaps your current project contains lots of similar declarations. What problems are there?

No-args constructor allows incostistent object instantiation

Hibernate demands each entity to provide a no-args constructor. Otherwise, the framework doesn’t work properly. And it’s one of the edgy cases that can make your code less straight-forward and more buggy.

Thankfully, there is a solution. Hibernate doesn’t need a public constructor for an entity. Instead, it can be protected. So, we can add a public static method to instantiate the entity, and leave protected constructor for Hibernate specifically. Look at the code example below:

@Entity
@NoArgsConstructor(access = PROTECTED)
@Setter
@Getter
public class Pocket {
  @Id
  private UUID id;

  private String name;

  @OneToMany(mappedBy = "pocket")
  private List<Tamagotchi> tamagotchis = new ArrayList<>();

  public static Pocket newPocket(String name) {
    Pocket pocket = new Pocket();
    pocket.setId(UUID.randomUUID());
    pocket.setName(name);
    return pocket;
  }
}

@Entity
@NoArgsConstructor(access = PROTECTED)
@Setter
@Getter
public class Tamagotchi {
  @Id
  private UUID id;

  private String name;

  @ManyToOne(fetch = LAZY)
  @JoinColumn(name = "pocket_id")
  private Pocket pocket;

  public static Tamagotchi newTamagotchi(String name, Pocket pocket) {
    Tamagotchi tamagotchi = new Tamagotchi();
    tamagotchi.setId(UUID.randomUUID());
    tamagotchi.setName(name);
    tamagotchi.setPocket(pocket);
    return tamagotchi;
  }
}
Enter fullscreen mode Exit fullscreen mode

As you can see, business code (that is likely to be in a different package) cannot instantiate Tamagotchi or Pocket with no-args constructor. It has to invoke dedicated methods newTamagotchi and newPocket that accept a specific amount of parameters.

Setters break encapsulation

I think public setters aren’t much different from regular public fields. Well, you could put some checks in a setter because it’s a method. But in reality, people tend not to go this way. Usually we just put @Setter annotation from Lombok library on top of class and that’s it.

I consider using setters in an entity a bad approach due to these reasons:

  1. Possible invariants’ violation. Some fields cannot be updated. Other ones can be updated only if the entity is being transmitted to a particular state. Pure setters forces developer to put all those checks in services.
  2. If Tamagotchi.name is String, it doesn't mean that every String value is allowed. Therefore, you also have to perform those checks outsided of an entity.
  3. A field can be a part of implementation detail. Maybe it's forbidden to update it directly. But a public setter allows this operation.

The main point is that public setters breaks the principle of compiler validation that I mentioned previously. You just provide too many options that can be called differently.

What’s the alternative? I suggest adding changeXXX methods for specific behaviour. Also, those methods should contain validation logic and throw exception if needed.

Suppose that Tamagotchi entity has a status field that can have the value of PENDING. If Tamagotchi is PENDING, it cannot be modified. Look at the code example below:

@Entity
@NoArgsConstructor(access = PROTECTED)
@Getter
public class Tamagotchi {
  @Id
  private UUID id;

  private String name;

  @ManyToOne(fetch = LAZY)
  @JoinColumn(name = "pocket_id")
  private Pocket pocket;

  @Enumerated(STRING)
  private Status status;

  public void changeName(String name) {
      if (status == PENDING) {
          throw new TamagotchiStatusException("Tamagotchi cannot be modified because it's PENDING");
      }
      if !(nameIsValid(name)) {
          throw new TamagotchiNameInvalidException("Invalid Tamagotchi name: " + name);
      }
      this.name = name;
  }

  public static Tamagotchi newTamagotchi(String name, Pocket pocket) { /* entity creation */ }
}
Enter fullscreen mode Exit fullscreen mode

The Tamagotchi.changeName method guarantees that you cannot change name if certain preconditions are violated. The code that invokes the method doesn’t need to know about specific rules. You just have to deal with exceptions.

Getters break encapsulation

Well, the previous paragraph about setters is more or less obvious. There are dozens of articles and opinions on the Internet about problems with setters. Anyway, eliminating getters sounds ridiculous, isn’t it? They don’t mutate the state of an entity. So, what’s the deal?

The problem with getters is that they also allow to break encapsulation and perform unnecessary or wrong checks. Suppose that we also want to restrict updating the name of Tamagotchi if its status is ERROR. That's the possible solution you might see during the code review:

@Service
@RequiredArgsConstructor
public class TamagotchiService {
    private final TamagotchiRepository repo;

    @Transactional
    public void changeName(UUID id, String name) {
        Tamagotchi tamagotchi = repo.findById(id).orElseThrow();
        if (tamagotchi.getStatus() == ERROR) {
            throw new TamagotchiStatusException("Tamagotchi cannot be modified because its status is ERROR");
        }
        tamagotchi.changeName(name);
    }
}
Enter fullscreen mode Exit fullscreen mode

Though Tamagotchi provides a dedicated method changeName, the check is still implemented in the service layer. I've noticed that even experienced senior developers tend to fall into anemic model mindset when there is a possibility. Because they've been working for years on different projects and most likely each one has applied Anemic Domain Model pattern. So, developers just choose the simpler and more obvious way.

However, a decision has some consequences. Firstly, the logic is divided between Tamagotchi entity and TamagotchiService (that’s the one thing we’ve wanted to avoid). Secondly, checks might be duplicated and you can miss it during the code review. And finally, some checks can be outdated in time. For example, this validation of ERROR status might become obsolete later. If you forget to eliminate it here, your code won’t act expectedly.

As I mentioned before, if you don’t need a method, just don’t add it. Getters aren’t required to perform business logic. You can put validations inside Tamagotchi.changeName method. If a getter is not present, it cannot be invoked and such a scenario won’t happen.

What about querying, then? Usually we use Hibernate entities to SELECT data, transform it into DTO, and return the result to the user. How can we do it without getters? Don’t worry, we’ll discuss this topic later in the article.

There is also one exception for this rule. You can add getters for ID. Sometimes it’s necessary to know the entity id in runtime. Later you’ll see an example of that.

Current result

We've already discussed three points:

  1. No-args constructor.
  2. Setters.
  3. Getters.

If we remove those pieces, the code will look like this:

@Entity
@NoArgsConstructor(access = PROTECTED)
public class Pocket {
  @Id
  private UUID id;

  private String name;

  @OneToMany(mappedBy = "pocket")
  private List<Tamagotchi> tamagotchis = new ArrayList<>();

  public static Pocket newPocket(String name) {
      Pocket pocket = new Pocket();
      pocket.setId(UUID.randomUUID());
      pocket.setName(name);
      return pocket;
  }
}

@Entity
@NoArgsConstructor(access = PROTECTED)
public class Tamagotchi {
  @Id
  private UUID id;

  private String name;

  @ManyToOne(fetch = LAZY)
  @JoinColumn(name = "pocket_id")
  private Pocket pocket;

  @Enumerated(STRING)
  private Status status;

  public void changeName(String name) {
      if (status == PENDING) {
          throw new TamagotchiStatusException("Tamagotchi cannot be modified because it's PENDING");
      }
      if (!nameIsValid(name)) {
          throw new TamagotchiNameInvalidException("Invalid Tamagotchi name: " + name);
      }
      this.name = name;
  }

  public static Tamagotchi newTamagotchi(String name, Pocket pocket) {
      Tamagotchi tamagotchi = new Tamagotchi();
      tamagotchi.setId(UUID.randomUUID());
      tamagotchi.setName(name);
      tamagotchi.setPocket(pocket);
      tamagotchi.setStatus(CREATED);
      return tamagotchi;
  }
}
Enter fullscreen mode Exit fullscreen mode

Aggregate and Aggregate root

Previously I’ve mentioned the Aggregate pattern. Speaking about our domain, the Pocket entity should be the Aggregate root. However, existing API allows us to access Tamagotchi entity directly. Let’s fix that.

Firstly, let's add simple CREATE/UPDATE/DELETE operations. Look at the code example below:

@Entity
@NoArgsConstructor(access = PROTECTED)
public class Pocket {
  @Id
  private UUID id;

  private String name;

  @OneToMany(mappedBy = "pocket", cascade = PERSIST, orphanRemoval = true)
  private List<Tamagotchi> tamagotchis = new ArrayList<>();

  public UUID createTamagotchi(TamagotchiCreateRequest request) {
      Tamagotchi tamagotchi = Tamagotchi.newTamagotchi(request.name(), this);
      tamagotchis.add(tamagotchi);
      return tamagotchi.getId();
  }

  public void updateTamagotchi(UUID tamagotchiId, TamagotchiUpdateRequest request) {
      Tamagotchi tamagotchi = tamagotchiById(tamagotchiId);
      tamagotchi.changeName(request.name());
  }

  public void deleteTamagotchi(UUID tamagotchiId) {
      Tamagotchi tamagotchi = tamagotchiById(tamagotchiId);
      tamagotchis.remove(tamagotchi);
  }

  private Tamagotchi tamagotchiById(UUID tamagotchiId) {
      return tamagotchis
              .stream()
              .filter(t -> t.getId().equals(tamagotchiId))
              .findFirst()
              .orElseThrow(() -> new TamagotchiNotFoundException("Cannot find Tamagotchi by ID=" + tamagotchiId));
  }

  public static Pocket newPocket(String name) {
      Pocket pocket = new Pocket();
      pocket.setId(UUID.randomUUID());
      pocket.setName(name);
      return pocket;
  }
}

@Entity
@NoArgsConstructor(access = PROTECTED)
class Tamagotchi {
  @Id
  @Getter
  private UUID id;

  private String name;

  @ManyToOne(fetch = LAZY)
  @JoinColumn(name = "pocket_id")
  private Pocket pocket;

  @Enumerated(STRING)
  private Status status;

  public void changeName(String name) {
      if (status == PENDING) {
          throw new TamagotchiStatusException("Tamagotchi cannot be modified because it's PENDING");
      }
      if (!nameIsValid(name)) {
          throw new TamagotchiNameInvalidException("Invalid Tamagotchi name: " + name);
      }
      this.name = name;
  }

  public static Tamagotchi newTamagotchi(String name, Pocket pocket) {
    Tamagotchi tamagotchi = new Tamagotchi();
    tamagotchi.setId(UUID.randomUUID());
    tamagotchi.setName(name);
    tamagotchi.setPocket(pocket);
    tamagotchi.setStatus(CREATED);
    return tamagotchi;
  }
}
Enter fullscreen mode Exit fullscreen mode

There are a lot of nuances. So, I’ll point out each of them one by one. Firstly, Pocket entity provides methods createTamagotchi, updateTamagotchi, and deleteTamagotchi as-is. You don’t retrieve any information from Tamagotchi or Pocket. You just invoke the required functionality.

I’m aware that such a technique also has performance penalties. We’ll also discuss some approaches to overcome these problems later.

Then goes Tamagotchi entity. The first thing I want you to notice is that the entity is package-private. Meaning that nobody can access Tamagotchi outside of the package. Therefore, calling Pocket directly is the only way.

Now you may think that its profit isn’t so obvious. But soon we’ll discuss the evolution of aggregate and you’ll see the benefits.

Neither Pocket nor Tamagotchi entity provides regular setters or getters. One can only invoke public methods of Pocket entity.

Evolution of requirements

As I said before, entities aren't static. Requirements change and invariants as well. So, let's look through a hypothetical process of implementing new requirements and see how it goes.

Each Pocket must possess at least one Tamagotchi

It means that we should create a Tamagotchi, when a new Pocket is instantiated. Also, if you want to delete a Tamagotchi, you have to check that it’s not the single one within the Pocket. Look at the code example below:

@Entity
@NoArgsConstructor(access = PROTECTED)
public class Pocket {
  /* fields and other methods */

  public void deleteTamagotchi(UUID tamagotchiId) {
      Tamagotchi tamagotchi = tamagotchiById(tamagotchiId);
      if (tamagothis.size() == 1) {
          throw new TamagotchiDeleteException("Cannot delete Tamagotchi because it's the single one"); 
      }
      tamagotchis.remove(tamagotchi);
  }

  public static Pocket newPocket(String name) {
      Pocket pocket = new Pocket();
      pocket.setId(UUID.randomUUID());
      pocket.setName(name);
      pocket.createTamagotchi(new TamagotchiCreateRequest("Default")); // creating default tamagotchi
      return pocket;
  }
}
Enter fullscreen mode Exit fullscreen mode

As you can see, invariants’ correctness is guaranteed within an aggregate. Even if you want to, you cannot create a Pocket with zero Tamagotchi or delete Tamagotchi if it’s a single one. And I think that it’s great. Code becomes less error-prone and easier to maintain.

Every Tamagotchi name has to be unique within a Pocket

To implement this requirement, we need to alter createTamagotchi and updateTamagotchi methods a bit. Look at the code example below:

@Entity
@NoArgsConstructor(access = PROTECTED)
public class Pocket {
  /* fields and other methods */

  public UUID createTamagotchi(TamagotchiCreateRequest request) {
      Tamagotchi tamagotchi = Tamagotchi.newTamagotchi(request.name(), this);
      tamagotchis.add(tamagotchi);
      validateTamagotchiNamesUniqueness();
      return tamagotchi.getId();
  }

  public void updateTamagotchi(UUID tamagotchiId, TamagotchiUpdateRequest request) {
      Tamagotchi tamagotchi = tamagotchiById(tamagotchiId);
      tamagotchi.changeName(request.name());
      validateTamagotchiNamesUniqueness();
  }

  private void validateTamagotchiNamesUniqueness() {
      Set<String> names = new HashSet<>();
      for (Tamagotchi tamagotchi : tamagotchis) {
          if (!names.add(tamagotchi.getName()) {
              throw new TamagotchiNameInvalidException("Tamagotchi name is not unique: " + tamagotchi.getName());
          }
      }
  }
}
Enter fullscreen mode Exit fullscreen mode

You’ve probably noticed that I added a getter for Tamagotchi.name field. Because Pocket and Tamagotchi form a single aggregate, it’s fine to provide getters. Because Pocket might need this information. However, Tamagotchi should not request anything from Pocket. It’s also better to mark this getter as package-private. So, no one can access it outside of the package.

I understand that validateTamagotchiNamesUniqueness doesn't perform well. Don't worry, we'll discuss workarounds later in the Performance implications section.

Once again, the domain model guarantees that each Tamagotchi name is unique within a Pocket. What is interesting is that API hasn’t changed a bit. The code that invokes those public methods (likely domain services) doesn’t have to change logic.

If a user deletes a Tamagotchi, they can restore it by name

This one is tricky and involves soft deletion. It also has additional points:

  1. If Tamagotchi with the same name already exists, a user cannot restore the one they've deleted.
  2. If a user deletes multiple Tamagotchis with the same name, they can only restore the last one.

I'm not a fan of soft deletion that involves adding isDeleted column by many reasons. Instead, I will introduce a new entity DeletedTamagotchi that contains the state of deleted Tamagotchi. Look at the code example below.

@Entity
@NoArgsConstructor(access = PROTECTED)
@Getter
class DeletedTamagotchi {
  @Id
  private UUID id;

  private String name;

  @ManyToOne(fetch = LAZY)
  @JoinColumn(name = "pocket_id")
  private Pocket pocket;

  @Enumerated(STRING)
  private Status status;

  public static DeletedTamagotchi newDeletedTamagotchi(Tamagotchi tamagotchi) {
      DeletedTamagotchi deletedTamagotchi = new DeletedTamagotchi();
      deletedTamagotchi.setId(UUID.randomUUID());
      deletedTamagotchi.setName(tamagotchi.getName());
      deletedTamagotchi.setPocket(tamagotchi.getPocket());
      deletedTamagotchi.setStatus(tamagotchi.getStatus());
      return deletedTamagotchi;
  }
}
Enter fullscreen mode Exit fullscreen mode

Tamagotchi entity is rather simple, so DeletedTamagotchi contains the same fields. However, if the original entity were more complicated, it couldn’t be the case. For example, you could save the state of Tamagotchi in Map<String, Object> fields that transforms to JSONB in the database.

Also, DeletedTamagotchi entity is package-private like Tamagotchi. So, the presence of this entity is an implementation detail. The other parts of the code don’t need to know this and interact with DeletedTamagotchi directly. Instead, it’s better to provide a single method Pocket.restoreTamagotchi without additional details.

Now let's alter Pocket entity to the new requirements. Look at the code example below:

@Entity
@NoArgsConstructor(access = PROTECTED)
public class Pocket {
  /* fields and other methods */

  @OneToMany(mappedBy = "pocket", cascade = PERSIST, orphanRemoval = true)
  private List<DeletedTamagotchi> deletedTamagotchis = new ArrayList<>();

  public void deleteTamagotchi(UUID tamagotchiId) {
      Tamagotchi tamagotchi = tamagotchiById(tamagotchiId);
      if (tamagothis.size() == 1) {
          throw new TamagotchiDeleteException("Cannot delete Tamagotchi because it's the single one"); 
      }
      tamagotchis.remove(tamagotchi);
      addDeletedTamagotchi(tamagotchi);
  }

  private void addDeletedTamagotchi(Tamagotchi tamagotchi) {
      Iterator<DeletedTamagotchi> iterator =  deletedTamagotchis.iterator();
      // if Tamagotchi with the same has been deleted,
      // remove information about it
      while (iterator.hasNext()) {
          DeletedTamagotchi deletedTamagotchi = iterator.next();
          if (deletedTamagotchi.getName().equals(tamagotchi.getName()) {
              iterator.remove();
              break;
          }
      }
      deletedTamagotchis.add(
          newDeletedTamagotchi(tamagotchi)
      );
  }

  public UUID restoreTamagotchi(String name) {
      DeletedTamagotchi deletedTamagotchi = deletedTamagotchiByName(name);
      return createTamagotchi(new TamagotchiCreateRequest(deletedTamagotchi.getName()));
  }
}
Enter fullscreen mode Exit fullscreen mode

The deleteTamagotchi method also creates or replaces a DeletedTamagotchi record. Meaning that every other code that calls the method for whatever reason doesn't violate the new requirement about soft deletion because it's been implemented internally.

To perform the required business operation, you should just invoke Pocket.restoreTamagotchi. We hid all the complex details behind the scenes. What’s even better is that DeletedTamagotchi is not a part of public API. Meaning that it can be easily modified or even removed, if it’s not needed anymore.


As you can see, placing business logic within an aggregate has significant benefits. However, it's not the end of the story. There are still some concerns we need to deal with. And the next one is querying data.

Querying data

When we deal with Hibernate, usually we use public getters to transform entity into DTO and return it to the user. However, only Pocket entity is public now, and it doesn’t provide any getters (aside from Pocket.getId()). How do we perform queries in this case? I can suggest several approaches.

Manual queries

The obvious solution is just writing regular JPQL or SQL statements. Hibernate uses reflection and doesn’t demand public getters for fields. This may work if you start a project from scratch. But if you already relying on getters to retrieve information from the entity and put it into DTO, then transition might be overwhelming. That’s why we have a second option.

Introducing toDto method

An entity can provide toDto or similar method that returns its internal representation as a separate data structure. It’s similar to Memento design pattern. Look at the code example below:

@Entity
@NoArgsConstructor(access = PROTECTED)
@Setter(PRIVATE)
public class Pocket {
  /* other fields and methods */

  public PocketDto toDto() {
    return new PocketDto(
        id,
        name,
        tamagotchis.stream()
            .map(Tamagotchi::toDto)
            .toList()
    );
  }
}

@Entity
@NoArgsConstructor(access = PROTECTED)
@Setter(PRIVATE)
@Getter
class Tamagotchi {
  /* other fields and methods */

  public TamagotchiDto toDto() {
    return new TamagotchiDto(id, name, status);
  }
}
Enter fullscreen mode Exit fullscreen mode

The returned DTO is an immutable object that couldn’t affect entities’ state. Besides, the approach is also helpful for unit testing. Let’s move on to this part.

Unit testing entities

We're going to test these scenarios:

  1. Pocket must always possess at least one Tamagotchi.
  2. If you delete Tamagotchi, you can restore it by name.
  3. If you delete multiple Tamagotchi with the same name, you can only restore the last one.

The entire test suite is available by this link.

Pocket must always possess at least one Tamagotchi

Look at the unit tests below.

class PocketTest {

  @Test
  void shouldCreatePocketWithTamagotchi() {
    Pocket pocket = Pocket.newPocket("My pocket");

    PocketDto dto = pocket.toDto();

    assertEquals(1, dto.tamagotchis().size());
  }

  @Test
  void shouldForbidDeletionOfASingleTamagotchi() {
    Pocket pocket = Pocket.newPocket("My pocket");
    PocketDto dto = pocket.toDto();
    UUID tamagotchiId = dto.tamagotchis().get(0).id();

    assertThrows(
        TamagotchiDeleteException.class,
        () -> pocket.deleteTamagotchi(tamagotchiId)
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The first one checks that Pocket is being created with a single Tamagotchi. Whilst the second one validates that you cannot delete Tamagotchi if it’s a single one.

What I like about those tests is that they are unit ones. No database, no Testcontainers, just regular JUnit and we've validated business logic successfully. Cool! Let's move forward.

If you delete Tamagotchi, you can restore it by name

This one is a bit more complicated. Look at the code example below.

class PocketTest {

  @Test
  void shouldDeleteTamagotchiById() {
    Pocket pocket = Pocket.newPocket("My pocket");
    UUID tamagotchiId = pocket.createTamagotchi(new TamagotchiCreateRequest("My tamagotchi", CREATED));

    pocket.deleteTamagotchi(tamagotchiId);

    PocketDto dto = pocket.toDto();
    assertThat(dto.tamagotchis())
        .noneMatch(t -> t.name().equals("My tamagotchi"));
  }

  @Test
  void shouldRestoreTamagotchiById() {
    Pocket pocket = Pocket.newPocket("My pocket");
    UUID tamagotchiId = pocket.createTamagotchi(new TamagotchiCreateRequest("My tamagotchi", CREATED));
    pocket.deleteTamagotchi(tamagotchiId);

    pocket.restoreTamagotchi("My tamagotchi");

    PocketDto dto = pocket.toDto();
    assertThat(dto.tamagotchis())
        .anyMatch(t -> t.name().equals("My tamagotchi"));
  }
}
Enter fullscreen mode Exit fullscreen mode

The shouldDeleteTamagotchiById checks that deletion works as expected. The other one validates restoreTamagotchi method behaviour.

If you delete multiple Tamagotchi with the same name, you can only restore the last one

This one is the most challenging. Look at the code example below.

class PocketTest {

  @Test
  void shouldRestoreTheLastTamagotchi() {
    Pocket pocket = Pocket.newPocket("My pocket");
    UUID tamagotchiId = pocket.createTamagotchi(new TamagotchiCreateRequest("My tamagotchi", CREATED));
    pocket.deleteTamagotchi(tamagotchiId);
    tamagotchiId = pocket.createTamagotchi(new TamagotchiCreateRequest("My tamagotchi", PENDING));
    pocket.deleteTamagotchi(tamagotchiId);

    pocket.restoreTamagotchi("My tamagotchi");

    PocketDto dto = pocket.toDto();
    assertThat(dto.tamagotchis())
        .anyMatch(t ->
            t.name().equals("My tamagotchi")
            && t.status().equals(PENDING)
        );
  }
}
Enter fullscreen mode Exit fullscreen mode

Here we do these steps:

  1. Create Pocket.
  2. Create Tamagotchi with name of My tamagotchi and status CREATED.
  3. Delete Tamagotchi.
  4. Create Tamagotchi with name of My tamagotchi and status PENDING.
  5. Restore Tamagotchi by name My tamagotchi.
  6. Verify that the last one Tamagotchi has been restored (with status of PENDING).

Here are tests' run result:

Unit tests results

Rich Domain Model pattern allows us to test complex business scenarios with simple unit tests. I think it’s outstanding. However, integration tests are also important because we need to store data in DB not in RAM. Let’s discuss this part of the equation.

Integration testing

We use entities with a conjunction of repositories (Spring Data ones usually). Let’s write some use cases and test them:

  1. Create Pocket.
  2. Create Tamagotchi.
  3. Update Tamagotchi.

The entire test suite is available by this link.

Create Pocket

Look at the service example below:

@Service
@RequiredArgsConstructor
public class PocketService {

  private final EntityManager em;

  @Transactional
  public UUID createPocket(String name) {
    Pocket pocket = Pocket.newPocket(name);
    em.persist(pocket);
    return pocket.getId();
  }
}
Enter fullscreen mode Exit fullscreen mode

Time to write some integration tests. Look at the code snippet below:

@Testcontainers
@DataJpaTest
@AutoConfigureTestDatabase(replace = NONE)
@Transactional(propagation = NOT_SUPPORTED)
@Import(PocketService.class)
class PocketServiceIntegrationTest {

  @Container
  @ServiceConnection
  public static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:13");

  @Autowired
  private TransactionTemplate transactionTemplate;
  @Autowired
  private TestEntityManager em;
  @Autowired
  private PocketService pocketService;

  @BeforeEach
  void cleanDatabase() {
    // there is cascade constraint in the database deleting tamagotchis and deleted_tamagotchis
    transactionTemplate.executeWithoutResult(
        s -> em.getEntityManager().createQuery("DELETE FROM Pocket ").executeUpdate()
    );
  }

  @Test
  void shouldCreateNewPocket() {
    UUID pocketId = pocketService.createPocket("New pocket");

    PocketDto dto = transactionTemplate.execute(
        s -> em.find(Pocket.class, pocketId).toDto()
    );
    assertEquals("New pocket", dto.name());
  }
}
Enter fullscreen mode Exit fullscreen mode

I use Testcontainers library to start PosgtreSQL in Docker. Flyway migration tool creates tables before tests run.

You can check out the migrations by this link.

I guess this snippet is not that complicated. So, let's go next.

Create Tamagotchi

Look at the code service implementation below:

@Service
@RequiredArgsConstructor
public class PocketService {
  /* other fields and methods */

  @Transactional
  public UUID createTamagotchi(UUID pocketId, TamagotchiCreateRequest request) {
    Pocket pocket = em.find(Pocket.class, pocketId);
    return pocket.createTamagotchi(request);
  }
}
Enter fullscreen mode Exit fullscreen mode

As you can see, the Rich Domain Model pattern demands to declare services as thin layer that are easy to understand and test. And here is the test itself:

/* same Java annotations */
class PocketServiceIntegrationTest {
  /* initialization... */  

  @Test
  void shouldCreateTamagotchi() {
    UUID pocketId = pocketService.createPocket("New pocket");

    UUID tamagotchiId = pocketService.createTamagotchi(
        pocketId,
        new TamagotchiCreateRequest("my tamagotchi", CREATED)
    );

    PocketDto dto = transactionTemplate.execute(
        s -> em.find(Pocket.class, pocketId).toDto()
    );
    assertThat(dto.tamagotchis())
        .anyMatch(t ->
            t.name().equals("my tamagotchi")
                && t.status().equals(CREATED)
                && t.id().equals(tamagotchiId)
        );
  }
}
Enter fullscreen mode Exit fullscreen mode

This one is a bit more interesting. Firstly, we create a Pocket and then add a Tamogotchi inside it. Assertions checks that expected Tamagotchi is present in result DTO.

Update Tamagotchi

This one is the most intriguing. Check out the implementation below:

@Service
@RequiredArgsConstructor
public class PocketService {
  /* other fields and methods */

  @Transactional
  public void updateTamagotchi(UUID tamagotchiId, TamagotchiUpdateRequest request) {
    UUID pocketId = em.createQuery(
            "SELECT t.pocket.id AS id FROM Tamagotchi t WHERE t.id = :tamagotchiId",
            UUID.class
        )
        .setParameter("tamagotchiId", tamagotchiId)
        .getSingleResult();

    Pocket pocket = em.find(Pocket.class, pocketId);
    pocket.updateTamagotchi(tamagotchiId, request);
  }
}
Enter fullscreen mode Exit fullscreen mode

API demands to pass tamagotchiId. But the domain model allows us to update Tamagotchi only through Pocket because the latter is the aggregate root. So, we determine pocketId with additional query to DB and then select Pocket aggregate by its id. Test is also quite interesting:

/* same Java annotations */
class PocketServiceIntegrationTest {
  /* other fields and methods */

  @Test
  void shouldUpdateTamagotchi() {
    UUID pocketId = pocketService.createPocket("New pocket");
    UUID tamagotchiId = pocketService.createTamagotchi(
        pocketId,
        new TamagotchiCreateRequest("my tamagotchi", CREATED)
    );

    pocketService.updateTamagotchi(
        tamagotchiId,
        new TamagotchiUpdateRequest("another tamagotchi", PENDING)
    );

    PocketDto dto = transactionTemplate.execute(
        s -> em.find(Pocket.class, pocketId).toDto()
    );
    assertThat(dto.tamagotchis())
        .anyMatch(t ->
            t.name().equals("another tamagotchi")
                && t.status().equals(PENDING)
                && t.id().equals(tamagotchiId)
        );
  }
}
Enter fullscreen mode Exit fullscreen mode

The steps are:

  1. Create Pocket.
  2. Create Tamagotchi.
  3. Update Tamagotchi.
  4. Validate the result DTO.

Here is the execution result for all integration tests:

Integration test run results

Nothing complicated, don't you think?

Performance implications

Rich Domain Model brings overhead for sure. However, there are
some workarounds to reach compromise.

Optimization of queries

Firstly, let's have a look at PocketService.updateTamagotchi method again:

@Service
@RequiredArgsConstructor
public class PocketService {
  /* other fields and methods */

  @Transactional
  public void updateTamagotchi(UUID tamagotchiId, TamagotchiUpdateRequest request) {
    UUID pocketId = em.createQuery(
            "SELECT t.pocket.id AS id FROM Tamagotchi t WHERE t.id = :tamagotchiId",
            UUID.class
        )
        .setParameter("tamagotchiId", tamagotchiId)
        .getSingleResult();

    Pocket pocket = em.find(Pocket.class, pocketId);
    pocket.updateTamagotchi(tamagotchiId, request);
  }
}
Enter fullscreen mode Exit fullscreen mode

The problem is that we retrieve all existing Tamagotchi instances for a specified Pocket when we actually want to update a single one. Look at the log below:

select t1_0.pocket_id from tamagotchi t1_0 where t1_0.id=?

select p1_0.id,p1_0.name from pocket p1_0 where p1_0.id=?

select t1_0.pocket_id,t1_0.id,t1_0.name,t1_0.status
from tamagotchi t1_0 where t1_0.pocket_id=?
Enter fullscreen mode Exit fullscreen mode

We can change queries to restrict the transmission of unnecessary data. Look at the code example below:

@Service
@RequiredArgsConstructor
public class PocketService {
  /* other fields and methods */

  @Transactional
  public void updateTamagotchi(UUID tamagotchiId, TamagotchiUpdateRequest request) {
    Pocket pocket = em.createQuery(
            """
                SELECT p FROM Pocket p
                LEFT JOIN FETCH p.tamagotchis t
                WHERE t.id = :tamagotchiId
                """,
            Pocket.class
        ).setParameter("tamagotchiId", tamagotchiId)
        .getSingleResult();
    pocket.updateTamagotchi(tamagotchiId, request);
  }
}
Enter fullscreen mode Exit fullscreen mode

Instead of selecting all existing Tamagotchi instances for the specified Pocket, we retrieve Pocket and the only associated Tamagotchi instance by specified id. Log also looks differently:

select 
p1_0.id,
p1_0.name,
t1_0.pocket_id,
t1_0.id,
t1_0.name,
t1_0.status
from pocket p1_0
left join tamagotchi t1_0 on p1_0.id=t1_0.pocket_id
where t1_0.id=?
Enter fullscreen mode Exit fullscreen mode

Even if Pocket contains thousands of Tamagotchi, it won’t affect the performance of the application. Because it will retrieve only a single one. If you run test cases from the previous paragraph, they will also pass successfully.

Pinpointing optimized checks

Nevertheless, the previous technique has limitations. To understand this, let's write another test. As we've already discussed, the business rule demands that each Tamagotchi must have a unique name within Pocket. Let's test this behaviour. Look at the code snippet below:

@Test
void shouldUpdateTamagotchiIfThereAreMultipleOnes() {
    UUID pocketId = pocketService.createPocket("New pocket");
    UUID tamagotchiId = pocketService.createTamagotchi(
        pocketId,
        new TamagotchiCreateRequest("Cat", CREATED)
    );
    pocketService.createTamagotchi(
        pocketId,
        new TamagotchiCreateRequest("Dog", CREATED)
    );

    assertThrows(
        TamagotchiNameInvalidException.class,
        () -> pocketService.updateTamagotchi(tamagotchiId, new TamagotchiUpdateRequest("Dog", CREATED))
    );
}
Enter fullscreen mode Exit fullscreen mode

There are two Tamagotchi with names of Cat and Dog. We try to rename Cat to Dog. Here, we expect to get TamagotchiNameInvalidException. Because business rule should validate this scenario. But if you run the test, you’ll get this result:

Expected com.example.demo.domain.exception.TamagotchiNameInvalidException to be thrown, but nothing was thrown.
Enter fullscreen mode Exit fullscreen mode

Why is that? Look again at Pocket.updateTamagotchi method declaration:

public void updateTamagotchi(UUID tamagotchiId, TamagotchiUpdateRequest request) {
    Tamagotchi tamagotchi = tamagotchiById(tamagotchiId);
    tamagotchi.changeName(request.name());
    tamagotchi.changeStatus(request.status());
    validateTamagotchiNamesUniqueness();
}

private void validateTamagotchiNamesUniqueness() {
    Set<String> names = new HashSet<>();
    for (Tamagotchi tamagotchi : tamagotchis) {
      if (!names.add(tamagotchi.getName())) {
        throw new TamagotchiNameInvalidException(
            "Tamagotchi name is not unique: " + tamagotchi.getName());
      }
    }
}
Enter fullscreen mode Exit fullscreen mode

As you can see, Pocket aggregate expects to have access for all Tamagotchi to validate the business rule. But we changed the query to select only a single Tamagotchi (the one we want to update). That’s why the exception is not raised. Because there is always a single Tamagotchi on the list and we cannot violate the uniqueness.

I see people trying to remove such validations from an aggregate. But I think you shouldn't do that. Instead, it's better to perform another optimized check in the service level in advance. To understand this approach, look at the schema below:

Pinpointing optimized check

Aggregate should always be valid. You can’t predict all likely future outcomes. Maybe you’ll call Pocket in another scenario. So, if you drop a check from an aggregate completely, you may accidentally violate business rule.

Nevertheless, we live in a real world where performance matters. It’s much better to execute a single exists SQL statement then retrieve all Tamagotchi instances from the database. So, you put optimized check specifically where it’s needed. But you also leave the aggregate untouched.

Look at the final code snippet of PocketService.updateTamagotchi method:

@Transactional
public void updateTamagotchi(UUID tamagotchiId, TamagotchiUpdateRequest request) {
    boolean nameIsNotUnique = em.createQuery(
            """
                SELECT COUNT(t) > 0 FROM Tamagotchi t
                WHERE t.id <> :tamagotchiId AND t.name = :newName
                """,
            boolean.class
        ).setParameter("tamagotchiId", tamagotchiId)
        .setParameter("newName", request.name())
        .getSingleResult();

    if (nameIsNotUnique) {
      throw new TamagotchiNameInvalidException("Tamagotchi name is not unique: " + request.name());
    }

    UUID pocketId = em.createQuery(
            "SELECT t.pocket.id AS id FROM Tamagotchi t WHERE t.id = :tamagotchiId",
            UUID.class
        )
        .setParameter("tamagotchiId", tamagotchiId)
        .getSingleResult();

    Pocket pocket = em.find(Pocket.class, pocketId);
    pocket.updateTamagotchi(tamagotchiId, request);
}
Enter fullscreen mode Exit fullscreen mode

Firstly, we check that any other Tamagotchi (aside from the one we want to update) already has the same name. If that’s true, we throw an exception. If you run the previous test again and check log, you’ll see that only COUNT query has been invoked:

select count(t1_0.id)>0
from tamagotchi t1_0
where t1_0.id!=? and t1_0.name=?
Enter fullscreen mode Exit fullscreen mode

Anyway, I don't recommend you to overuse this approach. You should treat it like an accurately pinned patch. In other words, put it only where it's needed. Otherwise, I'd prefer relying on domain logic and leave code in services as simple as possible.

Database generated ID

Previously I’ve mentioned that I’ll show you examples of client generated ID. However, sometimes we want to use other ID types. For example, sequence generated ones. Is this Rich Domain Model pattern also applicable to these ID types? It is, but there are also some concerns.

Firstly, have a look at Pocket and Tamagotchi entities using IDENTITY generation strategy:

@Entity
@NoArgsConstructor(access = PROTECTED)
@Setter(PRIVATE)
public class Pocket {
  @Id
  @GeneratedValue(strategy = IDENTITY)
  private Long id;

  /* other fields aren't important */

  public Long createTamagotchi(TamagotchiCreateRequest request) {
    Tamagotchi newTamagotchi = Tamagotchi.newTamagotchi(request.name(), request.status(), this);
    tamagotchis.add(newTamagotchi);
    validateTamagotchiNamesUniqueness();
    // always returns null
    return newTamagotchi.getId();
  }

  /* other methods aren't important */

  public static Pocket newPocket(String name) {
    Pocket pocket = new Pocket();
    pocket.setName(name);
    pocket.createTamagotchi(new TamagotchiCreateRequest("Default", CREATED));
    return pocket;
  }
}

@Entity
@NoArgsConstructor(access = PROTECTED)
@Setter(PRIVATE)
@Getter
class Tamagotchi {
  @Id
  @GeneratedValue(strategy = IDENTITY)
  private Long id;

  /* other fields and methods aren't important */

  public static Tamagotchi newTamagotchi(String name, Status status, Pocket pocket) {
    Tamagotchi tamagotchi = new Tamagotchi();
    tamagotchi.setName(name);
    tamagotchi.setPocket(pocket);
    tamagotchi.setStatus(status);
    return tamagotchi;
  }
}
Enter fullscreen mode Exit fullscreen mode

As you can see, we don’t assign ID directly anymore. Instead, we leave the field with null value and let Hibernate fill it later. Unfortunately, this decision breaks the logic of Pocket.createTamagotchi method. We do not set ID during the creation of Tamagotchi object. So, the invocation of Tamagotchi.getId always returns null (until you flush changes to the database).

There are several ways to fix this issue.

Manually fill the id

You can eliminate @GeneratedValue annotation usage and pass the ID value directly in the constructor. In this case, you have to invoke SELECT nextval('mysequence') statement and pass its result to an entity. Look at the code example below:

@Entity
@NoArgsConstructor(access = PROTECTED)
@Setter(PRIVATE)
public class Pocket {
  @Id
  private Long id;

  /* other fields aren't important */

  public Long createTamagotchi(long tamagotchiId, TamagotchiCreateRequest request) {
    Tamagotchi newTamagotchi = Tamagotchi.newTamagotchi(tamagotchiId, request.name(), request.status(), this);
    tamagotchis.add(newTamagotchi);
    validateTamagotchiNamesUniqueness();
    // always returns null
    return newTamagotchi.getId();
  }

  /* other methods aren't important */

  public static Pocket newPocket(long id, String name) {
    Pocket pocket = new Pocket();
    pocket.setId(id);
    pocket.setName(name);
    pocket.createTamagotchi(new TamagotchiCreateRequest("Default", CREATED));
    return pocket;
  }
}

@Entity
@NoArgsConstructor(access = PROTECTED)
@Setter(PRIVATE)
@Getter
class Tamagotchi {
  @Id
  private Long id;

  /* other fields and methods aren't important */

  public static Tamagotchi newTamagotchi(long id, String name, Status status, Pocket pocket) {
    Tamagotchi tamagotchi = new Tamagotchi();
    tamagotchi.setId(id);
    tamagotchi.setName(name);
    tamagotchi.setPocket(pocket);
    tamagotchi.setStatus(status);
    return tamagotchi;
  }
}
Enter fullscreen mode Exit fullscreen mode

The advantage is that your entity classes do not depend on some Hibernate magic and you can still validate business cases with regular unit tests. But you also make your code more verbose. Because you to have pass IDs manually.

Anyway, this approach is worth considering.

I found this option in this article. Actually, the author demands to stop use Hibernate at all. Even though I like Hibernate, I found some arguments intriguing.

Introducing business key

Sometimes passing IDs manually is nearly impossible. Maybe it requires too much refactoring that is unbearable. Or maybe your application works with MySQL which doesn't support sequences but only auto increment columns.

Though you can emulate sequences in MySQL by creating a regular table, this approach is not well performant.

In this case, you can introduce business key. That is a separate value that can identify an entity uniquely. Though it doesn’t mean that the business key must be globally unique. For example, if you point to Tamagotchi by name and it’s only unique within a Pocket, then you can identify Tamagotchi by a combination of (pocket_business_key, tamagothic_name).

Nevertheless, each business key should be unmodifiable. Otherwise, you can compromise the identity of your entities. So, pay good attention to this point.

Also, a good example of a business key is a slug. Look at the URL of this article. Do you see that it contains its name and some hash value? That is the slug. It assigns only once when the article is created but never changes (even if I change the article’s name). So, if your entities don’t have an obvious candidate for business key, introducing a slug might be an option.

Is Rich Domain Model always worth it?

There is no ultimate decision in software development. Every approach is just a compromise. Rich Domain Model pattern is no exception.

I started my article by explaining the problems of the Anemic Domain Model to you. They all valid and make sense. But it doesn’t mean that the Rich Domain model has no disadvantages. I can think of these:

  1. If you work with Hibernate, then the Rich Domain Model pattern is not so popular. It’s just the reality. There are dozens of articles on the Internet with Hibernate examples and total absence of the Rich Domain Model. People got used to the Anemic Domain Model and you have to take it into account.
  2. Rich Domain Model pattern may also bring some performance penalties. Some of them can be easily fixed. But others might become a headache. If your application is supposed to be high loaded, you have to make sure that invariants’ check won’t slow the response time too much.
  3. Rich Domain Model usage often leads to god object entities. Of course, it makes maintenance harder. There are ways to fix that. For example, Vaughn Vernon wrote 3 articles about effective aggregate design. However, if your entity is already a god object, it’s will be tough to refactor it.

Conclusion

In the end, I can say that I think that the Rich Domain Model acts better than the Anemic one. But don’t apply it blindly. You should also consider possible consequences and make decisions wisely.

Thank you very much for reading this long piece. I hope you've learnt something new. If you found it interesting, please share it with your friends and colleagues, press the like button, and leave your comments down below. I'll be glad to hear your opinions and discuss questions. Have a nice day!

Resources

  1. Hibernate
  2. Stop Using JPA/Hibernate
  3. Anemic Domain Model pattern
  4. Status quo
  5. Rich Domain Model pattern
  6. The entire repository with code examples
  7. Open-Closed principle
  8. Validation VS invariants
  9. Saga pattern
  10. Encapsulation (computer programming)
  11. Lombok library
  12. @Transactional annotation
  13. Spaghetti code
  14. Mockito library
  15. Tescontainers library
  16. Flaky tests
  17. My article about Unit Testing
  18. Tactical DDD
  19. DDD Aggregate
  20. Why does JPA require No-Args Constructor for Domain Objects
  21. What does it mean to write a buggy code
  22. Are soft deletes a good idea?
  23. PostgreSQL, Datatype JSON
  24. JPQL
  25. Memento design pattern
  26. Flyway migration tool
  27. PostgreSQL, create sequences
  28. Hibernate, IDENTITY generation strategy
  29. Auto increment columns in MySQL
  30. Surrogate key VS natural key differences
  31. God object
  32. Effective aggregate design by Vaughn Vernon
  33. JPA flush

Top comments (7)

Collapse
 
kauanmocelin profile image
Kauan Mocelin

Nice explanation about the theme, but considering tradeoffs of rich domain with hibernate orm, don´t be better goes to a concentric archictecture(clean arch, hexagonal, etc) directly?
Sounds like a lack of single responsability, mixing domain rules with persistent data operations.

Collapse
 
kirekov profile image
Semyon Kirekov • Edited

@kauan_amarante In my point of view, Rich Domain Model with Hibernate is the same thing as hexagonal architecture. Consider such Hibernate entity:

@Entity
@NoArgsConstructor(access = PROTECTED)
@Setter(PRIVATE)
public class Pocket {
  @Id
  private Long id;

  /* other fields aren't important */

  public Long createTamagotchi(long tamagotchiId, TamagotchiCreateRequest request) {
    Tamagotchi newTamagotchi = Tamagotchi.newTamagotchi(tamagotchiId, request.name(), request.status(), this);
    tamagotchis.add(newTamagotchi);
    validateTamagotchiNamesUniqueness();
    // always returns null
    return newTamagotchi.getId();
  }

  /* other methods aren't important */

  public static Pocket newPocket(long id, String name) {
    Pocket pocket = new Pocket();
    pocket.setId(id);
    pocket.setName(name);
    pocket.createTamagotchi(new TamagotchiCreateRequest("Default", CREATED));
    return pocket;
  }
}
Enter fullscreen mode Exit fullscreen mode

As you can see, Pocket entity is a regular Java class. There are no persistent data operations. You can verify Pocket operations with simple unit tests.

Annotations are just hints but not executable code. So, Hibernate treats them as something that should be persisted. But this implementation is hidden behind the scenes.

If you try to go for canonical hexagonal architecture, you will have two options:

  1. Implement your own Hibernate-like framework.
  2. Create separate domain and Hibernate classes.

Implement your own Hibernate-like framework

Suppose that Pocket is a simple Java class with no Hibernate annotations. Look at the code snippet below:

@Setter(PRIVATE)
@AllArgsConstructor
public class Pocket {
  private Long id;

  /* other fields aren't important */

  public Long createTamagotchi(long tamagotchiId, TamagotchiCreateRequest request) {
    Tamagotchi newTamagotchi = Tamagotchi.newTamagotchi(tamagotchiId, request.name(), request.status(), this);
    tamagotchis.add(newTamagotchi);
    validateTamagotchiNamesUniqueness();
    // always returns null
    return newTamagotchi.getId();
  }

  /* other methods aren't important */
}
Enter fullscreen mode Exit fullscreen mode

There are no much difference from Hibernate entity (except that this class has only all-args constructor). At the same time, you still have to translate entity changes to the database. Therefore, you would have to implement your own dirty checking mechanism. And this is a really complicated task.

I understand that Hibernate's dirty checking algorithm can be not so efficient. But it's generic for each scenario. You can write something that suits your requirements better. However, such infrastructure code will increase the complexity of your application severely. And most likely you would want to move this logic to a separate project (i.e. create your own Hibernate).

Create separate domain and Hibernate classes.

Theoretically you could have two classes Pocket and PocketEntity. The first one is the domain class (look at the example in the previous paragraph). The second one is the Hibernate entity. In this case, persistence layer interacts with PocketEntity and the business logic touches only Pocket.

I've seen some examples on the Internet. But to be honest, I don't see any value with this approach. Pocket and PocketEntity are most likely identical. So, you would need code which responsibilty is dumb mapping PocketEntity to Pocket and vice-versa. Why would you need this if you can just interact with single Pocket directly?

Collapse
 
kauanmocelin profile image
Kauan Mocelin

I understand your point of view, it is all about "Create separate domain and Hibernate classes." and I think your domain is a little bit "simple" yet, database persistence entity and domain entity are different things, one cannot depends each other. A simple example is: for your database record you need something that identifies(like PK) but for your domain entity it's irrelevant, meaning the PK exists only in database context.

I think that way you can achieve the single responsibility principle and increase manutenability, even that is only hibernate "annotations" it's different concerns.

Thread Thread
 
kirekov profile image
Semyon Kirekov • Edited

A simple example is: for your database record you need something that identifies(like PK) but for your domain entity it's irrelevant, meaning the PK exists only in database context.

An entity describes its behaviour by public methods but not the fields. If you need database PK which is not part of the contract, you can make it a private field and don't expose it with a getter. Hibernate can work with private fields that have no getters or setters.

Collapse
 
os2 profile image
Marc

don't really look like a hexagonal architecture

Image description

more info
reflectoring.io/spring-hexagonal/

anyway, with microservice way, anemic seem the way to go

with mapstruct, spring data, lombok a lot of code showed in this article don't need to be wrote

Collapse
 
kauegatto profile image
Kauê Gatto

Amazing article, thanks! I could really learn a lot from it

Some points I'd also think is worth mentioning:

Chapter: Don't add setters, getters, and public no-args constructor -> Don't add LOMBOK setters, getters, and public no-args constructor

  1. Creating setters and getters (even lombok getters and setters) is generally fine if the set / get of the attribute has no rules associated with it (yet) - You can override it later on, I kind of disagree with the changeXXX method approach and would do the validation inside the public setter itself, but it's really more secure than a default setter without the business logic, and I think that's the main point in here.

  2. Another point of getters: Be careful when "getting" a list, make sure you give the user a copy of the list, not the list itself, otherwise, the person will be able to avoid business rules set on the setList method.

  3. Also, be careful when adding builders, by standard, lombok builders will not protect your entity, however, there are some alternatives:
    You can make sure your .builder() method that provides you an entityBuilder receives as parameters the required fields, or use lombok.NonNull, which I personally don't like, since it's a runtime check.

Collapse
 
rzenkov profile image
Roman

Thanks for the articles, I started reading from the end and missed something. It is correctly noted that the rich model is rarely used in real projects; mostly I see a transactional script. And often developers do not understand how and why to use Hibernate, especially the relationships between entities and this is a pain.