Proper aggregate design and complex specification query

1.1k views Asked by At

I have a lack of understanding of the DDD aggregate topic.

I do have an Offer aggregate that has navigation property to its children's collection OfferProducts. When I learned entity framework I thought I should always define navigation properties on both sides of the relation but Ardalis (maintainer of Specification package for ef https://github.com/ardalis/Specification) wrote somewhere these words which I do not understand correctly:

You want to avoid having navigation properties that span Aggregates. So you need to decide where navigation properties should go, and where non-navigation key properties should go instead.

This is how I designed my entities:

public class Offer : BaseEntity, IAggregateRoot
{
   ...
   public ICollection<OfferProduct> OfferProducts { get; private set; } = new List<OfferProduct>();
   public Guid InquiryId { get; private set; }
   public virtual Inquiry Inquiry { get; private set; } = default!;
}
public class OfferProduct : BaseEntity, IAggregateRoot
{
    ...
    public Guid OfferId { get; private set; }
    public virtual Offer Offer { get; private set; } = default!;
    public Guid InquiryProductId { get; private set; }
    public virtual InquiryProduct InquiryProduct { get; private set; } = default!;
}
public class Inquiry : BaseEntity, IAggregateRoot
{
    ...
    public ICollection<Offer> Offers { get; private set; } = new List<Offer>();
    public ICollection<InquiryProduct> Products { get; private set; } = new List<InquiryProduct>();
}
public class InquiryProduct : BaseEntity, IAggregateRoot
{
    ...
    public Guid InquiryId { get; private set; }
    public virtual Inquiry Inquiry { get; private set; } = default!;
    public ICollection<OfferProduct> OfferProducts { get; private set; } = new List<OfferProduct>();
}

Ardalis is saying that navigation properties should be defined only on one side. I do not know if it is because of some DDD principles or maybe because it has some performance drawbacks?

Repository from your Ardalis specification package only works with aggregate root.

OfferProduct entities are created only with the Offer entity and are never updated.

InquiryProduct entities are created only with the Inquiry entity and are never updated.

I have a business use case where I need to fetch OfferProducts not only belonging to one Offer but filtered by InquiryProductId so I thought the easiest way will be to mark the OfferProduct entity with IAggregateRoot interface and query it from the repository directly. But I think it's cheating and it's not correct because if I understand correctly AggregateRoot should be the only one and I should always query from the root.

I could fetch it from the Inquiry aggregate root but then my specification would have to be that complex:

public class InquiryProductOffersSpec : Specification<Inquiry, InquiryDetailsDto>, ISingleResultSpecification
{
    public InquiryProductOffersSpec(Guid inquiryId, Guid productId) =>
        Query
            .Where(i => i.Id == inquiryId)
            .Include(i => i.Products.Where(ip => ip.Id == productId))
                .ThenInclude(ip => ip.OfferProducts);
}

This probably would be more correct from the DDD perspective but the query will be less performant than simple select * from OfferProducts where inquiryProductId = 'someId'

So my questions are:

should I remove IAggregateRoot from InquiryProduct and OfferProduct entities and fetch only from the Inquiry entity? why it is better to keep navigation properties only on one side of the relation? maybe my entities and relations are designed incorrectly and that's why I am struggling with that complex query?

I will introduce the operation of the system: The system can create inquiries with its InquiryProducts, then there can be offers created for each inquiry and each offer can have some OfferProducts related to the InquiryProduct.

When writing it thought came to my mind that maybe the only AggregateRoot should be the Inquiry entity as any of the other entities can't exist without Inquiry. But In the system, I also need to fetch(search) offers independently of inquiry and I couldn't do it if I won't mark Offer with an IAggregateRoot interface.

1

There are 1 answers

2
Steve Py On

should I remove IAggregateRoot from InquiryProduct and OfferProduct entities and fetch only from the Inquiry entity?

Yes. The whole point of an aggregate root is to organize entities into top-level entities responsible for their dependents which don't really make sense to query on their own.

why it is better to keep navigation properties only on one side of the relation?

Bi-directional references should only be used when there is a clear benefit to having them. Otherwise they just lead to having multiple pathways to get to information that can either result in expensive, unexpected lazy load calls or "broken" links if Lazy Loading is disabled.

For example, a relationship between something like a Customer and Order can make sense to treat both as aggregate roots. There will be a arguable value to get information about Orders for a particular customer, and value in getting information about a Customer from a given Order. Versus scenarios like relationships like Orders and Users (Created By/Modified By) or Orders/Customers and Addresses. An Order benefits from being able to access information about a User that created or last modified it, or Address details, but it doesn't make much sense to bother tracking what Orders a user Created/Modified, or what Order a given Address might be associated with.

You can still query this information if needed through the aggregate root without relying on bi-directional references. For instance if I do happen to care about what orders a particular user did modify, I don't need the structural overhead and "mess" of:

var orders = currentUser.OrdersICreated;
// or 
var orders = currentUser.OrdersIModified;

Since most entities in a system might track something like a CreatedBy/ModifiedBy reference back to a User, it would be ridiculous to start putting bi-directional references to every collection of entities in the User entity.

Where these aren't bi-directional references... I can instead use the aggregate root if and when there is a need:

var ordersQuery = _context.Orders.Where(x => x.CreatedBy.UserId == currentUserId);

The problem with relying on bi-directional references is that you end up doing a lot of processing in-memory, which is expensive from a memory standpoint, as well as you end up dealing with potentially stale data over time. In the above example, going back to build queries rather than relying on navigation properties means that I can leverage projection to get back just the details I might need, which could be something as simple as a .Any() check or a .Count().

My advice when it comes to getting the most out of EF is to adapt to leverage its querying and projection to build efficient queries, then deal with aggregate roots solely when you actually need to work with a complete picture of ideally a single entity and it's related details.