5

I understand that Aggregates should be small and they should protect invariants. I also know that keeping large collections in Aggregates impacts performance.

I have a usecase, that needs to protect its invariants, but also will lead to large collection.

Aggregate is Vendor, and it can have multiple active Promotion(s). Each Promotion has PromotionType, StartDate and EndDate. The invariants are:

  • at any point of time there can be max one promotion of each PromotionType
  • at any point of time there can be max 2 promotions
public Vendor : Aggregate {
    public Guid Id;
    public List<Promotion> Promotions;
    // some other Vendor props here

    public void AddPromotion(Promotion promo) {
        // protect invariants (business rules) here:
        // rule_1: if 2 promotions are already active during any time between promo.Start and promo.End then throw ex
        // rule_2: if during any time between promo.Start and promo.End there is promo with same Type then throw ex

        // if all is ok (invariants protected) then:
        Promotions.Add(promo);
    }
}

public Promotion : ValueObject {
    public PromotionType Type; // enum CheapestItemForFree, FreeDelivery, Off10PercentOfTotalBill
    public DateTime Start;
    public DateTime End;
}

As we can see, Promotions collection will grow while new promotions are added during time, and old promotions will get expired.

solution 1) One possibility is to make Promotion an aggregate on its own, containing VendorId, but in that case it would be difficult to protect mentioned invariants.

solution 2) Another possibility is to have a maintenance job that will move expired (EndDate passed) to some history table, but it's smelly solution IMO.

solution 3) Yet another possibility is to also make Promotion an aggregate on its own but protect the invariants in Domain Service, e.g.:

public class PromotionsDomainService {
    public Promotion CreateNewVendorPromotion(Guid vendorId, DateTime start, DateTime end, PromotionType type) {
        // protect invariants here:
        // invariants broken -> throw ex
        // invariants valid -> return new Promotion aggregate object
    }
}

... but protecting it in PromotionsDomainService (and returning Aggregates) we risk race condition and inconsistency (unless we apply pessimistic lock).

What is recommended DDD approach in such case?

2 Answers2

3

Your aggregate should contain only the data you need in order to fulfill its purpose. Reading the description of your problem, I don't see that Vendor needs the expired promotions for anything. Therefore you only need to keep the active promotions in the collection.

In your AddPromotion method, if there is an active promotion of that type, you will return an error. If there isn't any promotion of that type, you will add it and if there is an expired promotion of that type, you will replace it. Unless you have a huge number of promotion types (which doesn't seem to be the case), you will have a maximum of one promotion of each type. It seems that this will keep the collection to a very reasonable size. Let me know if this is not the case.

It is very possible that you need the expired promotions as historical data. But these should be on a read model designed for that purpose, not in the aggregate. For that, the aggregate could publish an event every type it accepts a new promotion and a listener would react to that event and insert a record in the historical promotions table.

Update:

After reading again the question, I realized that you don't even need to keep one promotion of each type. You will have a maximum of 2 promotions in the collection, so the size of the collection will be maximum 2, unless I'm misunderstanding it.

Francesc Castells
  • 1,833
  • 16
  • 25
  • So the key concept here is that during AddPromotion I can clear expired promotions, and the history of promotions (which indeed I need) should be in another table, in read model. However, read model is just a projection of source-of-truth write model, so IMO history should be also a part of write model. Thus, if another table references Promotion (either active or historic) it wouldn't be possible to have a proper relation in DB (can't have FK to two different tables). Solution I see here is to break "update one aggregate per transaction" rule, and during AddPromotion handler... – Maciej Pszczolinski Feb 14 '20 at 04:44
  • ... I will update both "Vendor" aggregate with its list of active promotions, as well as I'll create new entity of type "PromotionHistoryItem" (also AR). – Maciej Pszczolinski Feb 14 '20 at 04:50
  • Maybe I shouldn't have called it read model, but just promotions history. This table can be the source of truth of the promotions history and the Vendor is the authority that allows promotions to be created. Also, with the model as it is now, Promotion is a value object, so you can't reference it by definition and even if it was an entity with an Id you shouldn't have a FK to it directly as it would be against the rule of referencing only aggregate roots. I wouldn't worry about transactions as anything referencing this information could well be in a separate db/microservice – Francesc Castells Feb 14 '20 at 05:28
  • Ok, this approach makes sense. Thanks! – Maciej Pszczolinski Feb 14 '20 at 05:35
  • @FrancescCastells I was understanding that you also need the promotions that will be activated in the future to avoid adding a promotion (that also will be active in the future) with promotion type conflict or if there are 2 promotions already in this start/end date range (max promotion actives). – jlvaquero Feb 14 '20 at 10:02
  • @jlvaquero ah, that might be the case and then there'll be definitely more than 2 promotions in the aggregate. But without knowing the exact scenario it's hard to say if it will be problematic or not. If promotions durations are in the order of days you'd probably need many months worth of promotions for it to become a problem. If they are at the order of minutes, then maybe. My proposal makes the solution not "ever-growing" which would make it a problem sooner or later. – Francesc Castells Feb 14 '20 at 10:36
1

This is an interesting case since I have have always struggled with why an aggregate root would ever need an entity. I tend to favor value objects in aggregates that reference other aggregates by id but I think you may have an entity here.

A solution may be to only have the ability to register promotions within a Vendor, thereby enforcing the invariant. The VendorRepository would only load the active promotions and add them to the Vendor. In this way they can expire at any time but the repository would only load the relevant promotions.

For open-ended promotions (which you probably wouldn't necessarily have) you can even expire them outside of the Vendor and your invariants should still be satisfied.

Even if you go with a Promotion as a value object, which would work, you can still follow this approach.

Eben Roux
  • 12,009
  • 2
  • 23
  • 43
  • Thanks Eben. That's interesting approach. However - wouldn't it by anti-pattern having only partial collection loaded to Vendor.Promotions collection? The fact, that the collection is not complete is implicit (hidden) from the consumer of Vendor entity, and IMO best practice is to have things explicit. – Maciej Pszczolinski Feb 13 '20 at 09:09
  • The intent of the domain model is to ensure that your invariants are met so I do not see it as any form of anti-pattern. Another example may be `ActiveOrders` on a `Customer` where one certainly would not want, or ever need, the entire collection of historical orders. There are at times technical "*tricks*" of sorts we can apply to get the invariants satisfied. Having filtered collections or counts may be helpful every-so-often. As time goes by we may find better/improved ways of getting the same thing done but we have to be pragmatic about these things. Hope that helps :) – Eben Roux Feb 13 '20 at 14:05
  • Ok, I didn't thought that filtered collection are acceptable. Never saw such approach in neither articles, tutorials or podcasts. Thanks for that advice :) – Maciej Pszczolinski Feb 13 '20 at 14:18