3

I'm building a web application using Laravel. I use the repository pattern as my data layer.

Imagine there's some entity like Product and a product can be assigned to a ProductCategory. The Product has a field named category_id and that field either contains the id of a product category, or null if the product doesn't belong to any category.

Both Products and ProductCategorys can be created, updated, read, and deleted.

I use two repositories, one for Product and one for ProductCategory. When a product category is deleted, the category_id field of the products that belong to that category must be set to null. (The products are decoupled from the deleted category and don't belong to any category anymore).

This means we are affecting ProductCategory and Product data. Say for example I've got the following method in the ProductCategoryRepository.

public function delete(int $categoryId) 
{
    ProductCategory::where('id', '=', $categoryId)->delete();
    // Now all products that belong to the deleted category must have their category_id set to null.
    Product::where('category_id', '=', $categoryId)->update(['category_id' => null]);
}

This means the repository...

  • Actually contains kind of business logic.
  • Affects data of multiple entities. (Actually don't know how bad this is).

The alternative I can think of, is just moving the Product update (setting category_id to null) to the ProductRepository.

public function changeProductsCategoryId(?int $oldValue, ?int $newValue) 
{
    Product::where('category_id', '=', $oldValue)->update(['category_id' => $newValue]);
}

Then, in a service where we handle the deletion of a category, we should call both repository methods.

// class ProductCategoryService
public function deleteCategory(int $categoryId) 
{
    $this->_categoryRepo->delete($categoryId);
    $this->_productRepo->changeProductsCategoryId($categoryId, null);
}

One advantage of the second option is that when we have another business case like moving products to another category, we can use the changeProductsCategoryId method of the ProductRepository.

A third option would be nested repositories. Like the ProductCategoryRepository get a ProductRepository instance injected in its constructor and calls changeProductsCategoryId on it in its delete method. But I'm a bit hesitant about this, because this can lead to the problem of circular dependencies. For example when the ProductRepository also ever needs a reference to the ProductCategoryRepository.

I would like to know which option is best design and if there are possible problems with this approach? Or is there a better solution (based on experience and eventually literature).

2 Answers 2

2

As you said you are leaking business logic into repository class. I think second option you mentioned is probably the best option. You can create a service layer for category and coordinate product and product category deletions at that layer. How you exactly implement at that point depends on the specific use case. Couple options are:

  • Open a transaction at service layer and perform all deletions in one transaction. With this you guarantee consistent state always.
  • Delete the product category and publish a domain event. A consumer can handle the removal of ids from the products by consuming the event. This will guarantee consistent state eventually.
1

The usual practice is, not to have one repository per entity, but one repository per aggregate.

The aggregate is defiend as follows in DDD:

A cluster of associated objects that are treated as a unit for the purpose of data changes. External references are restricted to one member of the AGGREGATE, designated as the root. A set of consistency rules applies within the AGGREGATE’S boundaries.

But Product, and ProductCategory do not seem to belong to the same aggregate here since both can be used independently of each other.

It would not be a good idea to group in an aggregate independent entities, that can be accessed independently of each others: the repository would break the Single Responsibility Principle as there might be more than one reason to change it.

A much more robust approach would be to define domain events to maintain consistency across different aggregates of the same domain. The ProductCategory.delete() operation would then produce an event CategoryDeleted and the products would subscribe to such an event.

Your alternative of defining a DDD service seems acceptable (you should by the way first select the products and reset the category to be deleted, and then delete the category. The problem with this approach is that it needs a delete for ProductCategory to be available; and if it's there, you cannot be sure that it's never used outside of the service.

3
  • Can products and product categories here be seen as an aggregate? A product category can be changed independently, like changing its name. And a product can be changed independently too. An aggregate is more like a root object that wraps child objects (relations). If I think of an aggregate, I would say a ProductCategory is a root object that "contains" Products. But both products and product categories can still be non-encapsulated and be used/changed apart and directly. Commented Dec 4, 2022 at 19:49
  • @user2190492 I don't think so. If you would see a product as being owned by a category (i.e. deleting category would imply deleting the product) and if you would always access product via the category, you could start to think about an aggregate. But his is in general not the case for products and categories (some cataloguing tools even allow a product to have several categories). So I'd suggest to keep them appart, and as a consequence, keep 2 different repositories.
    – Christophe
    Commented Dec 4, 2022 at 20:06
  • 1
    "The usual practice is, not to have one repository per entity, but one repository per aggregate." I agree with the spirit but want to slightly redirect that this statement applies to the public interface of the infrastructure layer but not necessarily the private implementation. It's perfectly fine to have a repository per table in the private implementation, where the repositories for the subaggregates are not exposed publically and only accessed by the aggregate root's repository (which is publically available).
    – Flater
    Commented Dec 4, 2022 at 23:42

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

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