DDD can event handler construct value object for aggregate

Can I construct a value object in the event handler or should I pass the parameters to the aggregate to construct the value object itself? Seller is the aggregate and offer is the value object. Will it be better for the aggregate to pass the value object in the event?

    public async Task HandleAsync(OfferCreatedEvent domainEvent)
    {
        var seller = await this.sellerRepository.GetByIdAsync(domainEvent.SellerId);
        var offer = new Offer(domainEvent.BuyerId, domainEvent.ProductId, seller.Id);
        seller.AddOffer(offer);
    }

Solution 1:

should I pass the parameters to the aggregate to construct the value object itself?

You should probably default to passing the assembled value object to the domain entity / root entity.

The supporting argument is that we want to avoid polluting our domain logic with plumbing concerns. Expressed another way, new is not a domain concept, so we'd like that expression to live "somewhere else".

Note: that by passing the value to the domain logic, you protect that logic from changes to the construction of the values; for instance, how much code has to change if you later discover that there should be a fourth constructor argument?

That said, I'd consider this to be a guideline - in cases where you discover that violating the guideline offers significant benefits, you should violate the guideline without guilt.


Will it be better for the aggregate to pass the value object in the event?

Maybe? Let's try a little bit of refactoring....

// WARNING: untested code ahead
public async Task HandleAsync(OfferCreatedEvent domainEvent)
{
    var seller = await this.sellerRepository.GetByIdAsync(domainEvent.SellerId);
    Handle(domainEvent, seller);
}

static Handle(OfferCreatedEvent domainEvent, Seller seller)
{
        var offer = new Offer(domainEvent.BuyerId, domainEvent.ProductId, seller.Id);
        seller.AddOffer(offer);
}

Note the shift - where HandleAsync needs to be aware of async/await constructs, Handle is just a single threaded procedure that manipulates two local memory references. What that procedure does is copy information from the OfferCreatedEvent to the Seller entity.

The fact that Handle here can be static, and has no dependencies on the async shell, suggests that it could be moved to another place; another hint being that the implementation of Handle requires a dependency (Offer) that is absent from HandleAsync.

Now, within Handle, what we are "really" doing is copying information from OfferCreatedEvent to Seller. We might reasonably choose:

seller.AddOffer(domainEvent);
seller.AddOffer(domainEvent.offer());
seller.AddOffer(new Offer(domainEvent));
seller.AddOffer(new Offer(domainEvent.BuyerId, domainEvent.ProductId, seller.Id));
seller.AddOffer(domainEvent.BuyerId, domainEvent.ProductId, seller.Id);

These are all "fine" in the sense that we can get the machine to do the right thing using any of them. The tradeoffs are largely related to where we want to work with the information in detail, and where we prefer to work with the information as an abstraction.

In the common case, I would expect that we'd use abstractions for our domain logic (therefore: Seller.AddOffer(Offer)) and keep the details of how the information is copied "somewhere else".

The OfferCreatedEvent -> Offer function can sensibly live in a number of different places, depending on which parts of the design we think are most stable, how much generality we can justify, and so on.

Sometimes, you have to do a bit of war gaming: which design is going to be easiest to adapt if the most likely requirements change happens?