Home > Backend Development > PHP Tutorial > Handling Collections of Aggregate Roots – the Repository Pattern

Handling Collections of Aggregate Roots – the Repository Pattern

Joseph Gordon-Levitt
Release: 2025-02-27 10:46:10
Original
321 people have browsed it

Handling Collections of Aggregate Roots – the Repository Pattern

Core points

  • The warehousing model in Domain Driven Design (DDD) acts as an intermediary between the domain model and the data mapping layer, enhancing data query management and minimizing duplication.
  • Warehousing abstracts the complexity of the data layer from the domain model, promoting clear separation of concerns and persistence ignorance, which is in line with the DDD principle.
  • Implementing warehousing involves encapsulating the logic of data access and operations behind a collection-like interface, which can simplify interaction with the domain model.
  • While warehousing provides significant benefits in management domain complexity and isolation domain logic and data persistence details, their implementation can be too complex for simple applications.
  • The practical use of warehousing can be observed in systems requiring complex queries and data operations, where they provide a more domain-centric language and reduce infrastructure leakage into domain models.

One of the most typical aspects of traditional domain-driven design (DDD) architecture is the mandatory persistence agnosticity demonstrated by the domain model. In more conservative designs, including some implementations based on active records or data table gateways (in pursuit of quite deceptive simplicity, often end up with infrastructure pollution logic), there is always a clear concept of underlying storage mechanisms, usually relational databases. On the other hand, the domain model is conceptually designed from the outset to a strictly “store agnostic” nature, thus shifting any of its persistence logic beyond its boundaries. Even though considering that DDD is somewhat elusive when referring to a “database” directly, in the real world, it is likely that at least one database is running behind the scenes, because the domain model must eventually be persisted in some form. Therefore, it is common to deploy a mapping layer between the model and the data access layer. This not only actively promotes maintaining a considerable degree of isolation between layers, but also protects every complex detail in client code that involves moving domain objects back and forth between gaps in problem layers. Mea culpa To be fair, it is fair to say that handling singularity in the data mapper layer is a considerable burden, and the "write once/permanent use" strategy is often adopted. Nevertheless, the above pattern performs well under rather simple conditions, where only a small number of domain classes are processed by a small number of mappers. However, as the model starts to swell and becomes more complex, the situation can become more awkward, as there will definitely be additional mappers added over time. This briefly suggests that opening the door to persistence neglect can be difficult to implement in practice when using a rich domain model composed of multiple complex aggregate roots, at least if you don't create expensive object graphs in multiple places or embark on the path of sin of repeated implementation. Worse, in large systems that need to extract expensive aggregated root sets from the database that match different conditions, the entire query process itself can become an active, prolific enabler of such flawed duplication if it is not properly concentrated through a single entry point.In this complex use case, implementing an additional layer of abstraction (often referred to as warehousing in DDD jargon) that arbitrates between the data mapper and the domain model effectively helps minimize query logic duplication while exposing the semantics of the real memory set to the model. However, unlike mappers (which are part of the infrastructure), the warehousing itself is characterized by the language of the model, as it is closely tied to the model. And because of its implicit dependence on mappers, it also retains persistence ignorance, thus providing a higher level of abstraction, closer to domain objects. Unfortunately, not every possible application can easily implement the benefits of warehousing, so it is only worth implementing if the situation requires it. In any case, it would be very beneficial to build a small warehouse from scratch so you can see how it works internally and reveal what exactly is under its fairly esoteric shell.

Conduct preliminary preparations

The process of implementing warehousing can be very complex because it actually hides all the details of injecting and processing data mappers after a simplified collection-like API that in turn injects some kind of persistent adapter, and so on. This continuous injection of dependencies, coupled with a large amount of logic, explains why warehousing is often considered a simple look, even if some perspectives currently differ from the concept. In either case, the first step we should take to get up and run functional warehousing is to create a basic domain model. The model I plan to use here will be responsible for modeling the general user, with the basic structure as follows:

<?php namespace Model;

interface UserInterface
{
    public function setId($id);
    public function getId();

    public function setName($name);
    public function getName();

    public function setEmail($email);
    public function getEmail();

    public function setRole($role);
    public function getRole();
}
Copy after login
Copy after login
Copy after login
<?php namespace Model;

class User implements UserInterface
{
    const ADMINISTRATOR_ROLE = "Administrator";
    const GUEST_ROLE         = "Guest";

    protected $id;
    protected $name;
    protected $email;
    protected $role;

    public function __construct($name, $email, $role = self::GUEST_ROLE) {
        $this->setName($name);
        $this->setEmail($email);
        $this->setRole($role);
    }

    public function setId($id) {
        if ($this->id !== null) {
            throw new BadMethodCallException(
                "The ID for this user has been set already.");
        }
        if (!is_int($id) || $id             throw new InvalidArgumentException(
                "The user ID is invalid.");
        }
        $this->id = $id;
        return $this;
    }

    public function getId() {
        return $this->id;
    }

    public function setName($name) {
        if (strlen($name)  30) {
            throw new InvalidArgumentException(
                "The user name is invalid.");
        }
        $this->name = htmlspecialchars(trim($name), ENT_QUOTES);
        return $this;
    }

    public function getName() {
        return $this->name;
    }

    public function setEmail($email) {
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException(
                "The user email is invalid.");
        }
        $this->email = $email;
        return $this;
    }

    public function getEmail() {
        return $this->email;
    }

    public function setRole($role) {
        if ($role !== self::ADMINISTRATOR_ROLE
            && $role !== self::GUEST_ROLE) {
            throw new InvalidArgumentException(
                "The user role is invalid.");
        }
        $this->role = $role;
        return $this;
    }

    public function getRole() {
        return $this->role;
    }
}
Copy after login
Copy after login

In this case, the domain model is a rather skeletal layer, barely higher than a simple data holder who can self-verify, which defines the data and behavior of some fictitious user only through isolated interfaces and simple implementers. To keep it simple and easy to understand, I will keep the model this streamlined. As the model is already running in easy isolation, let's make it richer by adding another class to it, which handles the collection of user objects. This "add-on" component is just a classic array wrapper that implements Countable, ArrayAccess, and IteratorAggregate SPL interfaces:

<?php namespace ModelCollection;
use MapperUserCollectionInterface,
    ModelUserInterface;

class UserCollection implements UserCollectionInterface
{
    protected $users = array();

    public function add(UserInterface $user) {
        $this->offsetSet($user);
    }

    public function remove(UserInterface $user) {
        $this->offsetUnset($user);
    }

    public function get($key) {
        return $this->offsetGet($key);
    }

    public function exists($key) {
        return $this->offsetExists($key);
    }

    public function clear() {
        $this->users = array();
    }

    public function toArray() {
        return $this->users;
    }

    public function count() {
        return count($this->users);
    }

    public function offsetSet($key, $value) {
        if (!$value instanceof UserInterface) {
            throw new InvalidArgumentException(
                "Could not add the user to the collection.");
        }
        if (!isset($key)) {
            $this->users[] = $value;
        }
        else {
            $this->users[$key] = $value;
        }
    }

    public function offsetUnset($key) {
        if ($key instanceof UserInterface) {
            $this->users = array_filter($this->users,
                function ($v) use ($key) {
                    return $v !== $key;
                });
        }
        else if (isset($this->users[$key])) {
            unset($this->users[$key]);
        }
    }

    public function offsetGet($key) {
        if (isset($this->users[$key])) {
            return $this->users[$key];
        }
    }

    public function offsetExists($key) {
        return ($key instanceof UserInterface)
            ? array_search($key, $this->users)
            : isset($this->users[$key]);
    }

    public function getIterator() {
        return new ArrayIterator($this->users);
    }
}
Copy after login
Copy after login

In fact, putting this set of arrays within the boundaries of the model is completely optional, because using a normal array can produce nearly the same results. However, in this case, by relying on independent collection classes, it is easier to access the set of user objects extracted from the database through an object-oriented API. Furthermore, considering that the domain model must completely ignore the underlying storage set up in the infrastructure, the next logical step we should take is to implement a mapping layer that separates it well from the database. The following are the elements that make up this layer:

<?php namespace Model;

interface UserInterface
{
    public function setId($id);
    public function getId();

    public function setName($name);
    public function getName();

    public function setEmail($email);
    public function getEmail();

    public function setRole($role);
    public function getRole();
}
Copy after login
Copy after login
Copy after login
<?php namespace Model;

class User implements UserInterface
{
    const ADMINISTRATOR_ROLE = "Administrator";
    const GUEST_ROLE         = "Guest";

    protected $id;
    protected $name;
    protected $email;
    protected $role;

    public function __construct($name, $email, $role = self::GUEST_ROLE) {
        $this->setName($name);
        $this->setEmail($email);
        $this->setRole($role);
    }

    public function setId($id) {
        if ($this->id !== null) {
            throw new BadMethodCallException(
                "The ID for this user has been set already.");
        }
        if (!is_int($id) || $id             throw new InvalidArgumentException(
                "The user ID is invalid.");
        }
        $this->id = $id;
        return $this;
    }

    public function getId() {
        return $this->id;
    }

    public function setName($name) {
        if (strlen($name)  30) {
            throw new InvalidArgumentException(
                "The user name is invalid.");
        }
        $this->name = htmlspecialchars(trim($name), ENT_QUOTES);
        return $this;
    }

    public function getName() {
        return $this->name;
    }

    public function setEmail($email) {
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException(
                "The user email is invalid.");
        }
        $this->email = $email;
        return $this;
    }

    public function getEmail() {
        return $this->email;
    }

    public function setRole($role) {
        if ($role !== self::ADMINISTRATOR_ROLE
            && $role !== self::GUEST_ROLE) {
            throw new InvalidArgumentException(
                "The user role is invalid.");
        }
        $this->role = $role;
        return $this;
    }

    public function getRole() {
        return $this->role;
    }
}
Copy after login
Copy after login

Unbox, the batch of tasks performed by UserMapper is quite simple, limited to exposing a few general-purpose finders that are responsible for extracting users from the database and rebuilding the corresponding entity through the createUser() method. Also, if you've delved into some mappers before, and even wrote your own mapping masterpiece, the above is certainly easy to understand. The only subtle detail worth highlighting is probably that the UserCollectionInterface has been placed in the mapping layer, not in the model. I do this on purpose because in this way, the abstraction (protocol) that the user collection depends on is explicitly declared and owned by a higher level UserMapper, which is consistent with the guide promoted by the principle of dependency inversion. With the mapper already set up, we can use it directly out of the box and extract some user objects from the storage to allow the model to hydrate immediately. While this does seem to be the right path at first glance, we are actually tainting application logic with infrastructure unnecessary because mappers are actually part of the infrastructure. What if in the future, it is necessary to query user entities based on more granular, domain-specific conditions (not just common conditions exposed by the mapper's finder)? In this case, it does need to place an additional layer above the mapping layer, which not only provides a higher level of data access, but also carries the query logic block through a single point. Ultimately, that's the huge amount of benefits we expect from warehousing.

Implement user warehousing

In a production environment, warehousing can implement almost everything one can think of on its surface in order to expose the illusion of the memory set of aggregate roots to the model. However, in this case, we cannot so naively expect to enjoy this expensive luxury for free, because the warehouse we are going to build will be a rather artificial structure responsible for extracting users from the database:

<?php namespace ModelCollection;
use MapperUserCollectionInterface,
    ModelUserInterface;

class UserCollection implements UserCollectionInterface
{
    protected $users = array();

    public function add(UserInterface $user) {
        $this->offsetSet($user);
    }

    public function remove(UserInterface $user) {
        $this->offsetUnset($user);
    }

    public function get($key) {
        return $this->offsetGet($key);
    }

    public function exists($key) {
        return $this->offsetExists($key);
    }

    public function clear() {
        $this->users = array();
    }

    public function toArray() {
        return $this->users;
    }

    public function count() {
        return count($this->users);
    }

    public function offsetSet($key, $value) {
        if (!$value instanceof UserInterface) {
            throw new InvalidArgumentException(
                "Could not add the user to the collection.");
        }
        if (!isset($key)) {
            $this->users[] = $value;
        }
        else {
            $this->users[$key] = $value;
        }
    }

    public function offsetUnset($key) {
        if ($key instanceof UserInterface) {
            $this->users = array_filter($this->users,
                function ($v) use ($key) {
                    return $v !== $key;
                });
        }
        else if (isset($this->users[$key])) {
            unset($this->users[$key]);
        }
    }

    public function offsetGet($key) {
        if (isset($this->users[$key])) {
            return $this->users[$key];
        }
    }

    public function offsetExists($key) {
        return ($key instanceof UserInterface)
            ? array_search($key, $this->users)
            : isset($this->users[$key]);
    }

    public function getIterator() {
        return new ArrayIterator($this->users);
    }
}
Copy after login
Copy after login
<?php namespace Mapper;
use ModelUserInterface;

interface UserCollectionInterface extends Countable, ArrayAccess, IteratorAggregate 
{
    public function add(UserInterface $user);
    public function remove(UserInterface $user);
    public function get($key);
    public function exists($key);
    public function clear();
    public function toArray();
}
Copy after login
<?php namespace Mapper;
use ModelRepositoryUserMapperInterface,  
    ModelUser;

class UserMapper implements UserMapperInterface
{    
    protected $entityTable = "users";
    protected $collection;

    public function __construct(DatabaseAdapterInterface $adapter, UserCollectionInterface $collection) {
        $this->adapter = $adapter;
        $this->collection = $collection;
    }

    public function fetchById($id) {
        $this->adapter->select($this->entityTable,
            array("id" => $id));
        if (!$row = $this->adapter->fetch()) {
            return null;
        }
        return $this->createUser($row);
    }

    public function fetchAll(array $conditions = array()) {
        $this->adapter->select($this->entityTable, $conditions);
        $rows = $this->adapter->fetchAll();
        return $this->createUserCollection($rows);

    }

    protected function createUser(array $row) {
        $user = new User($row["name"], $row["email"],
            $row["role"]);
        $user->setId($row["id"]);
        return $user;
    }

    protected function createUserCollection(array $rows) {
        $this->collection->clear();
        if ($rows) {
            foreach ($rows as $row) {
                $this->collection[] = $this->createUser($row);
            }
        }
        return $this->collection;
    }
}
Copy after login

Although on top of a rather lightweight structure, the implementation of UserRepository is very intuitive because its API allows it to extract a collection of user objects from a storage that conforms to fine predicates closely related to the model language. In addition, in its current state, the repository only exposes some simple finders to the client code, which in turn uses the functionality of the data mapper to access the storage. In a more realistic environment, warehousing should also be able to persist aggregate roots. If you want to add the insert() method or other similar method to UserRepository, feel free to do so. In either case, an effective way to capture the actual advantages of using warehousing through examples is:

<?php namespace Model;

interface UserInterface
{
    public function setId($id);
    public function getId();

    public function setName($name);
    public function getName();

    public function setEmail($email);
    public function getEmail();

    public function setRole($role);
    public function getRole();
}
Copy after login
Copy after login
Copy after login

As mentioned earlier, warehousing effectively interchanges business terms with client code (the so-called "universal language" created by Eric Evans in his book "Domain Driven Design") rather than lower-level technical terms. Unlike the ambiguity present in the data mapper finder, on the other hand, the method of warehousing describes itself with "name", "email", and "role", which are undoubtedly part of the properties of modeling user entities. This finer, higher-level data abstraction, and a complete set of features required when encapsulating query logic in complex systems are undoubtedly one of the most compelling reasons why warehousing is more attractive in multi-layer designs. Of course, most of the time, there is an implicit trade-off between the hassle of getting these benefits upfront and deploying additional layers of abstraction (which can be too bloated in simpler applications).

Conclusion

As one of the core concepts in domain-driven design, warehousing can be found in applications written in several other languages ​​such as Java and C#, to name just a few. However, in PHP, they are still relatively unknown, just taking the first step in the world. Still, there are some trusted frameworks like FLOW3 and of course Doctrine 2.x that can help you adopt the DDD paradigm. As with any existing development approach, you don't have to use repositories in your application, or even needlessly smash them with the concept heap behind DDD. Just apply common sense and choose them only if you think they are suitable for your needs. It's that simple. Pictures from Chance Agrella / Freerangestock.com

FAQs on Handling Aggregated Root Collections (FAQ)

What is the aggregate root in domain-driven design?

In Domain Driven Design (DDD), an aggregate root is a collection of associated objects that are considered as a unit. These objects are bound together by root entities (also known as aggregate roots). The aggregation root maintains consistency of changes being made to the aggregation by forbidding external objects to hold references to their members.

How is the difference between an aggregate root and a normal entity?

The main difference between aggregate roots and ordinary entities is their responsibilities. Normal entities encapsulate behavior and state, while the aggregation root also ensures the integrity of the entire aggregation by controlling access to its members. It is the only member in an aggregation that allows external objects to hold references to them.

How to identify aggregate roots in my domain model?

Identifying the aggregation root requires a deep understanding of the business area. It is usually a high-level entity with a global identity and encapsulates other entities and value objects. For example, in the e-commerce world, an order can be an aggregate root that encapsulates line items and delivery information.

How to deal with a collection of aggregated roots?

Processing a collection of aggregated roots can be challenging. It is important to remember that each aggregate root is a consistency boundary, so changes to one aggregate root should not affect other aggregate roots. Therefore, when processing a collection, it is usually better to load and persist each aggregate root separately for consistency.

Can an aggregate root refer to another aggregate root?

Yes, an aggregate root can refer to another aggregate root, but it should be referenced only by identification. This means it should not hold a direct reference to another aggregate root object, but its ID. This helps maintain the consistency boundaries for each aggregate root.

How does an aggregate root relate to warehousing in DDD?

In DDD, warehousing provides methods for retrieving and storing aggregate roots. It abstracts the underlying storage mechanism, allowing the domain model to ignore the details of data persistence. Each aggregate root usually has its own storage.

What is the role of aggregation roots in executing business rules?

Aggregation roots play a crucial role in executing business rules. It ensures that all changes to the aggregation put it in a valid state. This means that any business rules spanning multiple entities or value objects should be enforced by the aggregate root.

How does aggregation root help reduce complexity in domain models?

Aggregation roots help reduce complexity in domain models by acting as consistency boundaries and controlling access to their members. It simplifies the model by providing a single point of interaction for each aggregation, making it easier to understand the system.

Can the aggregate root be part of multiple aggregates?

No, the aggregate root should not be part of multiple aggregates. This will violate the consistency boundaries of aggregates and may lead to inconsistencies in the domain model.

How to deal with the concurrency problem of aggregate roots?

Various strategies can be used to deal with concurrency problems at aggregate roots, such as optimistic locks or pessimistic locks. The choice of a policy depends on the specific requirements of the application and the nature of the concurrency problems you are facing.

This revised output maintains the original image formatting and location, paraphrases the text to avoid plagiarism, and keeps the core meaning intact. Remember to always cite your sources appropriately.

The above is the detailed content of Handling Collections of Aggregate Roots – the Repository Pattern. For more information, please follow other related articles on the PHP Chinese website!

Statement of this Website
The content of this article is voluntarily contributed by netizens, and the copyright belongs to the original author. This site does not assume corresponding legal responsibility. If you find any content suspected of plagiarism or infringement, please contact admin@php.cn
Latest Articles by Author
Popular Tutorials
More>
Latest Downloads
More>
Web Effects
Website Source Code
Website Materials
Front End Template