Repositories, read layers, domain services in DDD and CQRS

Differences between each one

Posted by Martín Peveri on April 27, 2024 · 4 mins read

Repositories, read layers, domain services in DDD and CQRS

Differences between each one

In this article, I would like to talk about the topics mentioned in the title. You might wonder why this title, mixing DDD building blocks and CQRS concepts. Well, the answer is somewhat related to my personal experience working in DDD environments with CQRS, where I see concepts being mixed. For example, repositories are used when they should be domain services or read layers, etc.

Repositories

Repositories should only be used for aggregates, as it is the way to access and persist them. Example:

Having an Order aggregate


class Order extends AggregateRoot {
    id: OrderId;
    items: OrderItem[];
  
    constructor(id: OrderId, items: OrderItem[]) {
      this.id = id;
      this.items = items;

      super()
    }
  
    //…
  }

Its repository should be something like this:


interface OrderRepository {
    getById(id: OrderId): Order | undefined;
    save(order: Order): void;
    // …
  }

Repositories in DDD are the storage location to persist and retrieve aggregates. Only aggregates have repositories, and from the domain perspective, they are collections of aggregates. They have to respect the ubiquitous language since they are in the domain.

Read layers

This CQRS concept is basically a way to obtain data for a CQRS Query, which can be built on top of the domain model using the write model. It lives in the application layer with the queries and is optimized query models; it can be used as an intermediate step to have projections or to query projections as well.

An example could be:


interface FindSuggestionsTermReadLayer {
  find(userId: UserId): Promise;
}

The query handler would be:


@QueryHandler(FindSuggestionsTermQuery)
export default class FindSuggestionsTermQueryHandler implements IQueryHandler {
  constructor(
    @Inject(FIND_SUGGESTIONS_TERM_READ_LAYER)
    private readonly findSuggestionTermReadLayer: FindSuggestionsTermReadLayer,
  ) {}

  async execute(query: FindSuggestionsTermQuery): Promise {
    const user = UserId.of(query.userId);
    const terms = await this.findSuggestionTermReadLayer.find(user);
    return FindSuggestionsTermQueryResponse.fromTerms(terms);
  }
}

The query response can return what the read layer returns since they are also primitives. They are optimized for queries. Then the implementation will query the persistence system and return the object optimized for the read model.

Domain services

A domain service is an operation that fulfills a domain task. An indicator that a domain service needs to be created is when that logic cannot go in a Value Object (VO) or in the aggregate. Sometimes there is a tendency to create a static method in the aggregate, but that is a sign that it should be a domain service.

Before creating a domain service, keep in mind this heuristic: if the logic could be in a VO or in the aggregate, put it there.

Another case for creating a domain service is when you have an interface in the domain to send an email or something that needs to be implemented in the infrastructure layer.

It is important to name the services using the ubiquitous language as well.

Example:


  class OrderProcessor {
    constructor(private readonly orderRepository: OrderRepository, private readonly discountRepository: DiscountRepository) {}
  
    async processOrder(orderId: OrderId): Promise {
      const order = await this.orderRepository.getById(orderId);
  
      if (!order) {
        throw new OrderNotFound(orderId);
      }
  
      const applicableDiscounts = await this.discountRepository.getApplicableDiscountsFromOrder(order);
  
      order.applyDiscounts(applicableDiscounts);
      
      await void this.orderRepository.save(order);
    }
  }

As I mentioned before, sometimes we also have domain interfaces such as emailService, which function as domain services. Since sending an email when an order is processed is considered part of the domain – according to discussions with domain experts – we need to implement it within the domain, using an interface.


interface EmailService {
    sendEmail(to: string, subject: string, body: string): void;
  }

Where in infrastructure, its implementation implements sending the email.

In summary,

Repositories are only for aggregates.

If you need to make optimized queries for the read model in a query, using the write model or the read model (projections), a read layer is what you are looking for.

If you need to implement domain logic that doesn't fit into aggregates or VOs, a domain service is what you need, although there may also be domain services that end up being implemented in infrastructure.