2

Say we have an imaginary system managing purchases made by customers, and there are the following business rules:

  1. You can only buy a product if you have enough money on your pre-paid account
  2. You can deposit any value to your pre-paid account
  3. If you buy a particular product for the first time, you get 50% discount

First thing that comes to my mind is to model it like so (C#-like pseudo-code):

class Purchase : Entity
{
  private Guid PurchaseId;
  private Guid GlobalProductId;
  private string ProductName;
  private decimal Price;
}

class Customer : AggregateRoot
{
  private Guid CustomerId;
  private List<Purchase> Purchases;
  private decimal Balance;

  public void Deposit(value){
    Balance += value;
  }

  public Result Purchase(globalProductId, productName, price){
    if(NeverBoughtProduct(globalProductId)){
      price = price * 0.5;
    }

    if(price > Balance){
      return Result.Fail('Insufficient funds');
    }

    Balance -= price;
    Purchases.Add(new Purchase(globalProductId, productName, price))
    return Result.Ok();
  }

  private bool NeverBoughtProduct(globalProductId){
    return Purchases.Any(purchase => purchase.GlobalProductId == globalProductId)
  }
}

The problem is that the number of purchases can grow rapidly. In order to make a purchase, you have to fetch all previous purchases for the customer form database.

In no-DDD scenario the solution would be trivial - a simple query fetching only a single boolean value whether the product has already been purchased.

I cannot make the Purchase an aggregate root, because then I wouldn't be able to encapsulate the business rules in domain objects.

Lazy loading isn't going to work either, since NeverBoughtProduct will fetch potentially every Purchase anyway.

This is a very common scenario, but I couldn't find a good solution on the web. How do you tackle such problems? Do you use some ORM tricks? Or would you change the model (if so, how would you do it)?

3

This is precisely the kind of situation that will often trip up inexperienced modelers!

Let us preface the solution with a quick refresher on the general "flow" an application will follow when handling commands:

1. Retrieve all state (domain model) necessary to coordinate use-case

2. Coordinate domain model to fulfill use-case ("tell don't ask")

3. Persist domain model

In your case, either we are pulling way too much state in step 1, or we are retrieving more state in step 2. Neither of these are ideal. So where is the solution?

A little more "knowledge crunching" can reveal that we are missing a concept that can be used to encapsulate the critical piece of state (NeverBoughtProduct). What we need here is a PurchaseRequest (VO). So now instead of:

// 1
customer = customers.Find( cmd.CustomerId );

// 2
customer.Purchase( cmd.ProductId, cmd.Price);

// 3
customers.Save( customer );

we have:

// 1
customer = customers.Find( cmd.CustomerId );

purchaseRequest = purchaseRequests.Find( cmd.CustomerId, cmd.ProductId );

// 2
customer.Purchase( purchaseRequest, cmd.Price ); 

// 3
customers.Save( customer );

In the end, you actually knew the solution to your problem! (take a look at your non-DDD passage - that's your PurchaseRequest).

  • Thanks, this is very educational. Just one more thing I don't understand. What is purchaseRequest exactly? Is it some kind of non-persistent projection of system state deduced both from clients and purchases? How do we classify it? Entity? DTO? Value object? Would it simply have a single property like CanGiveDiscount? – Andrzej Gis Dec 27 '18 at 18:36
  • Also, what would customer class look like now? Precisely, how can we add a purchase to a customer? Customer should clearly have some kind of purchases collection, but the collection would hold only purchases made since customer object creation. In that case, how should we name the collection? 'Purchases' would be misleading since it only contains a tiny subset to recent purchases. 'PendingPurchases' doesn't work either I guess. Form customer class perspective, the purchase isn't pending. – Andrzej Gis Dec 27 '18 at 18:57
  • @AndrzejGis In my example above, a PurchaseRequest (or whatever name is most appropriate in your UL) represents the pending intent for the purchase of a Product by a Customer. As you astutely point out, in it's current for it needn't be persisted and would be best-understood as a Value Object. It allows you to encapsulate a snapshot of persistent state to help mediate a purchase. It is not strictly necessary to put any logic on the PR. Because it is a VO, it can be consumed as part of another aggregate to propagate state (hint Purchase). – king-side-slide Dec 27 '18 at 19:39
  • @AndrzejGis I would disagree that it's clear a Customer needs a collection of Purchases. Let us allow the behavior of our system inform its design. Currently, I see no reason for a Customer to have any reference to their purchases. That is, I see no rules necessitating this relationship. That said, I already know what you are thinking next, "how is a Purchase created then?". Well, object creation patterns can be weird. Because a Customer is not changed by making a purchase, we actually needn't save the Customer. With that in mind... – king-side-slide Dec 27 '18 at 19:44
  • @AndrzejGis ... we might find ourselves in an even more simple design (which is a good thing): purchase = customer.Purchase( purchaseRequest, cmd.Price ). Followed by purchases.Save( purchase ). In this way, the Customer.Purchase method is really a factory method for another domain entity and not a mutation. Does that make sense? – king-side-slide Dec 27 '18 at 19:47
1

I think the problem here is that you operate at Purchase level. With it, you need to fetch every record.

I would introduce a separate PurchaseHistory interface. It would be able to do all kinds of analytics with Purchases and timelines (which you are going to need anyway).

In particular, it would have something like std::optional<Purchase> FindLatestPurchase(string ProductId). It would be able to use "ORM tricks" to only query the last purchase record from the database, for a particular user and product, and indicate the absence of such record.

(Other benefits, like an ability to retire old purchase records while preserving summaries stats computed from them, you will discover later.)

  • 1
    I don't get what your domain model would look like. Could you please elaborate? – Andrzej Gis Dec 26 '18 at 17:54
  • My idea was to follow the standard DDD idea of an aggregate root as illustrated by "order vs line items" example. That is, purchase history is an aggreagate root, and individual purchases are non-root entities. Purchase history at best loads an entire list of references to purchases, not entire purchases. I don't see why logically transparent lazy-loading can't be implemented here, as long as we access old and create new Purchases only through PurchaseHistory. OTOH maybe PurchaseHistory a Domain Service could be a better approach. – 9000 Dec 26 '18 at 18:58
  • Issuing a query from your domain is generally a bad idea as it not only creates another dependency, it creates one in the wrong direction! Think about it like this: is it really the case that the piece of state we are after (NeverBoughtProduct) cannot be known before the flow of control reaches our domain model? That is, is the CustomerId and ProductId contained in the request/command not enough information to resolve NeverBoughtProduct before Customer.Purchase is invoked? Of course we know that it is. The question, then, is what do we do about it? See my answer. – king-side-slide Dec 27 '18 at 17:26

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.