0

I am pretty new to DDD, so any help/ideas will be appreciated. I will explain my initial design and problem below.

The user can ask the system to generate products proposal, proposal is basically something, which has some ownership and set of products, which can be renewed if system evolves, new products should be automatically added to proposal and returned to the user. Proposal is designed as separate AR as following class.

public class Proposal : AggregateRoot
{
    public Ownership OwnedBy { get; internal set; }

    public IProposalState State { get; internal set; } =
        ProposalStateCollection.ProposalInitializedState;

    internal Proposal(Ownership ownedBy)
        => SetIdentity(ownedBy.DeviceId, this);


    public Result AttachTo(string meteringPoint)
    {
        if (string.IsNullOrWhiteSpace(meteringPoint))
            return Result.Failure($"{nameof(meteringPoint)} cannot be null or empty.");

        return State.AttachMeteringPoint(this, meteringPoint);
    }

    public Result AttachTo(OwnerAddress ownerAddress)
    {
        if (ReferenceEquals(null, ownerAddress))
            return Result.Failure($"{nameof(ownerAddress)} cannot be null.");

        return State.AttachOwnerAddress(this, ownerAddress);
    }

    internal Result<Product> PopulateWith(ProductType productType)
    {
        if (ReferenceEquals(null, productType))
            return Result.Failure<Product>($"{nameof(productType)} cannot be null.");

        return ProductSpecification
            .Instance
            .OwnedByProposal(Id)
            .ForProductType(productType)
            .InState(ProductStateCollection.ProductInitializedState)
            .AndNoExistingIdentity()
            .Build();
    }

    public Result ScheduleRecalculation()
        => State.ScheduleRecalculation(this);

That's pretty straight forward, user sends request to the system, then the Application service serves the request (checking existing of proposal, if it was generated before or creating one if not) and then calling ScheduleRecalculation method, which simply generate domain event based on current state of proposal.

When handler received event that proposal is recalculated itself (ProposalRecalculationCompletedEvent) it asks domain service to provide him all latest products + existing one and then start recalculation of each product. Something like this. Product is modeled as separate AR.

    public Task Handle(
        DomainEventNotification<ProposalRecalculationCompletedEvent> notification,
        CancellationToken cancellationToken)
    {
        var domainEvent = notification.DomainEvent;

        return _proposalRepository.ProposalOfIdAsync(domainEvent.ProposalId, cancellationToken)
            .Bind(proposalOrNone => proposalOrNone.ToResult(
                $"Proposal [{domainEvent.ProposalId}] cannot be found. Won't be populated with latest products.'"))
            .Bind(proposal => _productRepository.ProductsOfProposalAsync(proposal.Id)
                .Tap(existing =>
                {
                    _productService.LatestProductsOfProposal(proposal, existing)
                        .Tap(latest =>
                        {
                            var products = latest.ToList();
                            _productService.RecalculatePriceOfProducts(products);
                            _productRepository.PersistProductsAsync(products);
                        });
                }))
            .OnFailure(error =>
                _logger.LogError(
                    "Error occured while populating proposal [{proposalId}] with the latest products. [{error}]",
                    domainEvent.ProposalId,
                    error));
    }

RecalculatePriceOfProducts is a simple wrapper around internal SchedulePriceRecalculation on Product AR, which send domain event for each product to start recalculation. Product AR itself.

public class Product : AggregateRoot, IEnumerable<PriceOffer>
{
    public string ProposalId { get; internal set; }

    public IProductState State { get; internal set; } =
        ProductStateCollection.ProductInitializedState;

    public ProductType Type { get; internal set; }

    public Maybe<PriceOffer> CurrentPriceOffer { get; internal set; }
        = Maybe<PriceOffer>.None;

    internal IList<PriceOffer> PriceOffers { get; set; }

    private IDictionary<PriceOffer, PriceOffer> _priceOffersMap =>
        PriceOffers.ToDictionary(p => p, p => p);

    internal Product()
        => SetIdentity(Guid.NewGuid().ToString("D"), this);

    internal Result SchedulePriceRecalculation(HourlyPrecisedDatePeriod forPeriod)
    {
        if (ReferenceEquals(null, forPeriod))
            return Result.Failure($"{nameof(forPeriod)} cannot be null.");

        if (HasValidPrice(forPeriod))
            return Result.Success();

        return State.SchedulePriceRecalculation(() =>
            AddDomainEvent(this.ToProductPriceRecalculationScheduled(forPeriod)));
    }

    public Result EnrollPriceFor(
        HourlyPrecisedDatePeriod validForPeriod,
        IPriceOfferCalculation priceOfferCalculation)
    {
        if (ReferenceEquals(null, validForPeriod))
            return Result.Failure($"{nameof(priceOfferCalculation)} cannot be null.");

        if (ReferenceEquals(null, priceOfferCalculation))
            return Result.Failure(
                $"{nameof(priceOfferCalculation)} cannot be null.");

        return State.EnrollPriceFor(() =>
                priceOfferCalculation.Calculate(validForPeriod)
                    .Ensure(
                        priceOffer => !_priceOffersMap.ContainsKey(priceOffer),
                        "Price offer already enrolled within the product.")
                    .Tap(priceOffer =>
                    {
                        PriceOffers.Add(priceOffer);
                        CurrentPriceOffer = priceOffer;
                    }))
            .Tap(state => State = state);
    }

    private bool HasValidPrice(HourlyPrecisedDatePeriod forPeriod)
        => CurrentPriceOffer.HasValue && CurrentPriceOffer.Value.IsValidForPeriod(forPeriod);

    public IEnumerator<PriceOffer> GetEnumerator()
        => PriceOffers.GetEnumerator();

    IEnumerator IEnumerable.GetEnumerator()
        => GetEnumerator();
}

Here is the question, as soon as I iterate products and schedule price recalculation for each (by domain event and eventually map it to command and sending to service bus), the products processed in parallel, after processing is done for ALL products (no matter if it was succeed or not) I need to sent another event to service bus. Any suggestion how I can do that? As soon as Products are separate ARs I need someway to know that for that particular recalculation all products were calculated with Success or Failed result.

Another question, if you look at EnrollPriceFor method, which accepts IPriceOfferCalculation. So basically, when price calculation is scheduled and command sent to service bus, the receiver builds IPriceOfferCalculation implementation and pass it to the Product AR, the requirements tell that if calculation failed, it should schedule another calculation with fallback calculation. So who should do that? The receiver? The Product AR itself, then what would be the best design for that?

Hope it is possible to understand...

  • Your Service Bus implementation... Does it offer something like .ContinueWith()? – Robert Harvey Apr 28 at 15:47
  • @RobertHarvey well I think so, I can chain Tasks, but the think is, that knowledge about fallback calculations and when to perform it (at least how I understood it), should be belonged to my domain knowledge and not exposed to domain event handlers. – Jevgenij Nekrasov Apr 28 at 15:55
  • What's preventing you from keeping the fallback calculations in your domain knowledge? – Robert Harvey Apr 28 at 16:28
  • You might get better results from your question by posting it to codereview.stackexchange.com. This post seems like a good fit there. – Robert Harvey Apr 28 at 16:59
  • @RobertHarvey good, thanks, will try – Jevgenij Nekrasov Apr 28 at 17:18
0

I believe you can solve this with the Saga pattern. See here and here for example. NServiceBus has a nice implementation and documentation for Sagas that you can look at.

Sagas (sometimes known as process managers, or activities) are useful for doing distributed, long running, persistent transactions. They are also useful for coordinating the message passing between aggregates or bounded contexts. A general rule of thumb is that sagas only subscribe to events, and issue commands. As apposed to aggregate roots, which handle commands, and publish events. Sagas are only invoked through facts (events) that have happened in the business domain. Sagas are really powerful at solving business problems that span transaction boundaries.

Basically, a Saga listens to events and has state where, in your case, it can store if events for all products have arrived or not and if they were successful or not. Once the event for the last product arrives, it can send a command to trigger the continuation of the process.

One tricky thing about Sagas is that they are not meant to call other dependencies, like repositories. Therefore, you need to think carefully about how you are going to initialize their sate. For example, in your case, the saga needs to know all the product Ids. I can think of two ways to solve it (but there could be more):

  1. My initial thought is that instead of the Proposal aggregate, you could have a Proposal Saga. This Saga can listen to commands like CreateProposal (which creates the saga), AddProduct and ScheduleRecalculation. ScheduleRecalculation might just send a command to another handler to do the actual calculation (especially if it needs external dependencies) and listen to a RecalculationCompletedEvent. Then can coordinate the recalculation for all products, listen to their completion events and when they have all completed, trigger the continuation task.

  2. Another approach, using what you already have, is leave the Proposal aggregate and add a Saga that tracks its work. So, when a proposal is created, publish ProposalCreatedEvent and the Saga is created as a reaction to this event. When you add products to the proposal, publish an event and listen to it from the saga, so it can keep track of all the products, and so on.

I hope it makes sense. You'll need to work out exactly how it would work because I'm probably missing something in my proposed solution.

| improve this answer | |

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.