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...