Late to the party, but I also went through it so here I'll share my finding.
In this case, it's pretty clear that the Product
is the root of your aggregate, and aggregate root are usually equipped with a repository (and a factory).
But also, a PurchasableProduct
is a Product
so that the PurchasableProduct
is a fully fledged aggregate root.
If you're introducing this subclassing of Product
is surely because some workflows apply to all products, but some other workflow exist only for PurchasableProduct
s, so you only load PurchasableProduct
s from your repository, for example:
public void purchaseProduct(PurchaseProductCommand cmd) {
Product p = productRepository.findById(cmd.productId())
.orElseThrow(()-> new AggregateNotFoundException());
if (product instanceof PurchasableProduct) {
((PurchasableProduct) product).purchase(cmd);
}
else {
throw new AggregateNotFoundException();
}
productRepository.save(product)
}
It's clear that we can do better than that. Let's see the two approaches that we can apply.
Approaches
Approach #1: Repository method specialization
In this case we can save a lot of lines by using the method:
PurchasableProduct product =
(PurchasableProduct)productRepository.findByIdAndType(cmd.productId(), PURCHASABLE);
but this is not the best way since you have to introduce this "type" thing and you won't get rid of casting. Let's delegate this discrimination to the repository
PurchasableProduct product = productRepository.findPurchasableById(cmd.productId());
This is the simplest solution, especially when you can count on ORM frameworks that can map class inheritance onto data storage capabilities for free. Nonetheless, there are two pitfalls:
The clients of ProductRepository
want to only work on products. They might not want to burden of understanding what a PurchasableProduct
is (that's why you are subclassing) and why they have methods in your repository that speak about Purchasable products. It's the single-responsibility principle (SRP) at the end of the day.
If you have found that a PurchasableProduct
has dedicated workflows, you also can infer that PurchasableProduct
s have a different lifecycle. They take care of purchasing, while Product
s are more well suited for catalogue etc. You may find out that you have a new bounded context, and eventually you want to split purchasing use cases implementation in a new module, then in a new microservice. As task, you may have to split out PurchasableProductRepository
, hoping that nobody had the bad idea of mixing things in other workflow.
Approach #2: Specializing Repository
You just have:
PurchasableProduct product = purchasableProductRepository.findById(cmd.productId());
There is one more file to add to your repository, maybe a bit of maintenance more (but again, here ORM save our time).
But this approach also scales, when it comes to find more states/roles for a product, because you will just add a new repository along with its subclass, using the basic CRUD repository methods (that you will define with or without ORM).
So I definitely would go for the second approach:
public void purchaseProduct(PurchaseProductCommand cmd) {
PurchasableProduct p = repository.findById(cmd.productId())
.orElseThrow(()-> new AggregateNotFoundException());
p.purchase(cmd);
repository.save(p);
}
Product
class when you get a new requirement that specifies a change the products. Would you also create aFooProduct
subclass if the next requirement is "all products must be extended with a Foo attribute"?