Skip to content

Instantly share code, notes, and snippets.

@sizovs
Last active December 17, 2025 12:04
Show Gist options
  • Select an option

  • Save sizovs/924e2815bdc339e6f0628592eb885e7e to your computer and use it in GitHub Desktop.

Select an option

Save sizovs/924e2815bdc339e6f0628592eb885e7e to your computer and use it in GitHub Desktop.
Clean Code: Bonus Parts and Common Questions

1. Forwarding collections

You can turn any object into a Collection by extending ForwardingCollection (available in Guava). This makes your object iterable as a normal collection. For example:

@Component
public class ImportantNotifications extends ForwardingCollection<Notification> {

  private final Collection<Notification> notifications; 

  public ImportantNotifications(@Lazy Collection<Notification> notifications) {
    this.notifications = notifications;
  }

  @Override
  protected Collection<Notification> delegate() {
    return notifications.stream().filter(it -> it.important).toList();
  }
}

@Autowired
ImportantNotifications notifications;

Or you can create your own "collections of steroids", that protect their invariants:

record Task(String name, boolean active) {}

class Project {
    
  private final Tasks tasks = new Tasks();

  public Tasks tasks() {
      return tasks;
  }

  static class Tasks extends ForwardingCollection<Task> {

    private final Collection<Task> tasks = new ArrayList<>();

    @Override
    protected Collection<Task> delegate() {
      return tasks;
    }

    @Override
    public boolean add(Task task) {
      if (task.active && tasks.stream().anyMatch(task -> task.active)) {
          throw new IllegalArgumentException("Only one active task allowed at a time");
      }
      return tasks.add(task);
    }
  }

}

2. Hybrid domain model

When people hear 'behavior goes into entities,' they get concerned—and rightly so. Remember that the goal isn’t to force behavior into entities, but to put behavior where it fits best. Shoving all behavior into entities should make everyone uncomfortable, because it's huge a commitment and not always a good fit. Not everything is best modeled as a rich domain object. Sometimes a domain service is better; sometimes a simple data class with a service around it is good enough.\

The best approach is making decision where to put behaviour pragmatically, case-by-case. You might start with a data class and a domain service, then later realize that some or all of that logic belongs in an Entity. Other times, you begin with an Entity and discover it’s better modeled as a data class + domain service. When working with existing code, if behavior is scattered across services, consider moving the parts that fit back into the Entity. Know both approaches, and decide case by case.

A hybrid model where rich objects co-exing with data classes + domain services is fine. "Everything is an entity with all the behavior” is too much. But in most enterprise apps, the common practice is to have no rich entities at all—and that’s not optimal either.

P.S. such refactoring freedom only exists when you have solid high-level tests. From a use-case perspective, it doesn’t matter whether the underlying model is rich or anemic. That’s an internal concern—and changing it shouldn’t turn all your tests red : )

3. How to handle pagination for domain objects?

We paginate aggregate roots via repositories. Using Spring Data and our BankAccount example:

Page<BankAccount> = bankAccounts.findAll(Pageable page);

Transactions mapped with @OneToMany can’t be paginated, so all of them must be read (lazily if needed) together with the BankAccount. This usually isn’t an issue, since a single bank account rarely has so many transactions that memory or read time becomes a problem—millions of transactions can be left-joined in 100-1000 ms via the indexed join column. The only challenge arises if you’re reading thousands of accounts with millions of transactions each–say, in a batch job—but in that case, you can paginate the bank accounts themselves.

One technique often used in banking is transaction snapshotting. We take periodic snapshots—say, monthly—and then, when processing, we read the last snapshot plus only the transactions that occurred afterward. This reduces memory usage and improves performance.

4. How does DDD work in the environment where Unit of Work pattern is not available?

If the underlying persistence provider, such as JPA, supports Unit of Work, the persistence context automatically tracks changes to entities, and you don’t need to call save() on persisted entities. That's the main point of Unit of Work. If you use Spring Data repositories with, say, Elastic or MongoDB, which don’t support Unit of Work, you’ll need to call save() every time you need to save changes to your entities. Apart from that, your repository will look similar to JPA. That being said, even without Unit of Work, as long as you’re using something like Spring Data, Repositories (and DDD) are possible.

The beauty of Spring Data Repositories is that it works with POJOs, so you can directly persist objects (with behaviors) or data classes w/o maintaining an extra data layer and DTO ⇔ Domain Model mapping. If you’re not using Spring Data (or similar) and instead rely on lower-level abstractions such as ElasticsearchTemplate or RestTemplate to load domain objects, you’ll likely need a custom repository that returns fully constructed domain entities, with all mapping hidden internally. Moreover, programmatic mapping object creation – from DTO, JSON or other "raw" format – doesn't mean destroying object's encapsulation, as you can play with private/package-private scopes, use constructor, etc.

Also, note that we don’t inject RestTemplate or similar into domain objects for them to 'fetch themselves'. All domain object fetching happens through repositories (that hide the protocol and other technicals under the hood).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment