cult3

Using Aggregates as a Gateway to Functionality

Jan 05, 2015

Table of contents:

  1. The responsibility of the Thread Aggregate
  2. The Post Entity
  3. Checking that a User is a member of a Group
  4. Creating a new Post
  5. Conclusion

As we’ve seen over the last couple of weeks, Aggregates are an integral building block of Domain Driven Design.

Aggregates constrain access to certain Entities of an application by grouping them as a unit behind an Entity root. This limits exposure of the public API and protects the internal implementation from the outside world (What are Aggregates in Domain Driven Design?).

Last week we looked at Enforcing Business Rules through Aggregate Instantiation and how we can use one Aggregate to create another to effectively assign responsibility to the correct object. This allows us to easily encapsulate the business rules of the application without leaking responsibility to a service or over-complicating the single responsibility of an object.

This week we’re going to be looking at Aggregates in more depth to see how they can act as a Gateway to the functionality of your application.

The responsibility of the Thread Aggregate

Last week we wrote the Thread Entity that will form part of the Discussion Bounded Context.

Once the Thread has been instantiated from the Group Aggregate, it is up to the Thread to enforce the business rules of the application.

The Thread Aggregate should control access to all users and posts of a discussion.

It should also ensure that only members of the group can create a new post in the thread.

A post must belong to a thread, so the Thread entity must also have responsibility for creating new Post Entities.

In today’s tutorial we will be looking at implementing all of this functionality.

The Post Entity

So the first thing we need to do is create the Post Entity. As we’ve seen many times in this series, the very first thing we need to create is the PostId:

<?php namespace Cribbb\Domain\Model\Discussion;

use Rhumsaa\Uuid\Uuid;
use Cribbb\Domain\Identifier;
use Cribbb\Domain\UuidIdentifier;

class PostId extends UuidIdentifier implements Identifier
{
    /**
     * @var Uuid
     */
    protected $value;

    /**
     * Create a new Identifier
     *
     * @return void
     */
    public function __construct(Uuid $value)
    {
        $this->value = $value;
    }
}

I’ll not include the tests for this class as I’m basically just repeating myself each time we need a new identifier!

Next we can create the Post Entity. Again this is a fairly standard example of a Doctrine Entity:

<?php namespace Cribbb\Domain\Model\Discussion;

use Assert\Assertion;
use Cribbb\Domain\RecordsEvents;
use Doctrine\ORM\Mapping as ORM;
use Cribbb\Domain\AggregateRoot;
use Cribbb\Domain\Model\Identity\User;
use Doctrine\Common\Collections\ArrayCollection;

/**
 * @ORM\Entity
 * @ORM\Table(name="posts")
 */
class Post implements AggregateRoot
{
    use RecordsEvents;

    /**
     * @ORM\Id
     * @ORM\Column(type="string")
     */
    private $id;

    /**
     * @ORM\Column(type="text")
     */
    private $body;

    /**
     * @ORM\ManyToOne(targetEntity="Cribbb\Domain\Model\Identity\User", inversedBy="posts")
     **/
    private $user;

    /**
     * @ORM\ManyToOne(targetEntity="Cribbb\Domain\Model\Discussion\Thread", inversedBy="posts")
     **/
    private $thread;

    /**
     * Create a new Post
     *
     * @param PostId $PostId
     * @param User $user
     * @param Thread $thread
     * @param string $body
     * @return void
     */
    public function __construct(
        PostId $postId,
        User $user,
        Thread $thread,
        $body
    ) {
        Assertion::string($body);

        $this->setId($postId);
        $this->setUser($user);
        $this->setThread($thread);
        $this->setBody($body);
    }

    /**
     * Get the id
     *
     * @return PostId
     */
    public function id()
    {
        return PostId::fromString($this->id);
    }

    /**
     * Set the id
     *
     * @param PostId $id
     * @return void
     */
    private function setId(PostId $id)
    {
        $this->id = $id->toString();
    }

    /**
     * Set the User
     *
     * @return void
     */
    private function setUser(User $user)
    {
        $this->user = $user;
    }

    /**
     * Get the User
     *
     * @return User
     */
    public function user()
    {
        return $this->user;
    }

    /**
     * Set the Thread
     *
     * @param Thread $thread
     * @return void
     */
    private function setThread(Thread $thread)
    {
        $this->thread = $thread;
    }

    /**
     * Get the Thread
     *
     * @return Thread
     */
    public function thread()
    {
        return $this->thread;
    }

    /**
     * Get the body
     *
     * @return string
     */
    public function body()
    {
        return $this->body;
    }

    /**
     * Set the body
     *
     * @param string $body
     * @return void
     */
    private function setBody($body)
    {
        $this->body = $body;
    }
}

The most interesting thing to note in this Entity is that it requires two Many-to-One relationships to the User and Thread Entities respectively.

Once again, the tests for the Post Entity are fairly standard at this stage:

<?php namespace Cribbb\Tests\Domain\Model\Discussion;

use Cribbb\Domain\Model\Groups\Group;
use Cribbb\Domain\Model\Identity\User;
use Cribbb\Domain\Model\Identity\Email;
use Cribbb\Domain\Model\Groups\GroupId;
use Cribbb\Domain\Model\Discussion\Post;
use Cribbb\Domain\Model\Identity\UserId;
use Cribbb\Domain\Model\Discussion\PostId;
use Cribbb\Domain\Model\Discussion\Thread;
use Cribbb\Domain\Model\Identity\Username;
use Cribbb\Domain\Model\Discussion\ThreadId;
use Cribbb\Domain\Model\Identity\HashedPassword;

class PostTest extends \PHPUnit_Framework_TestCase
{
    /** @var User */
    private $user;

    /** @var Thread */
    private $thread;

    public function setUp()
    {
        $this->user = User::register(
            UserId::generate(),
            new Email("name@domain.com"),
            new Username("username"),
            new HashedPassword("password")
        );

        $this->thread = new Thread(
            ThreadId::generate(),
            new Group(GroupId::generate(), "Cribbb"),
            "Hello World"
        );
    }

    /** @test */
    public function should_require_id()
    {
        $this->setExpectedException("Exception");

        $post = new Post(null, $this->user, $this->thread, "Once upon a time...");
    }

    /** @test */
    public function should_require_user()
    {
        $this->setExpectedException("Exception");

        $post = new Post(
            PostId::generate(),
            null,
            $this->thread,
            "Once upon a time..."
        );
    }

    /** @test */
    public function should_require_thread()
    {
        $this->setExpectedException("Exception");

        $post = new Post(
            PostId::generate(),
            $this->user,
            null,
            "Once upon a time..."
        );
    }

    /** @test */
    public function should_require_body()
    {
        $this->setExpectedException("Exception");

        $post = new Post(PostId::generate(), $this->user, $this->thread);
    }

    /** @test */
    public function should_create_post()
    {
        $post = new Post(
            PostId::generate(),
            $this->user,
            $this->thread,
            "Once upon a time..."
        );

        $this->assertInstanceOf("Cribbb\Domain\Model\Discussion\Post", $post);
        $this->assertInstanceOf(
            "Cribbb\Domain\Model\Discussion\PostId",
            $post->id()
        );
        $this->assertInstanceOf(
            "Cribbb\Domain\Model\Identity\User",
            $post->user()
        );
        $this->assertEquals("Once upon a time...", $post->body());
    }
}

Finally we need to add the reverse side of the relationship in the User and Thread Entities.

Firstly, the User Entity requires the following class property and annotation:

/**
 * @ORM\OneToMany(targetEntity="Cribbb\Domain\Model\Discussion\Post", mappedBy="user")
 **/
private $posts;

And the Thread Entity requires this class property and annotation:

/**
 * @ORM\OneToMany(targetEntity="Cribbb\Domain\Model\Discussion\Post", mappedBy="thread")
 **/
private $posts;

Both Entities will also require a new instance of ArrayCollection to be created in the __construct() method:

$this->posts = new ArrayCollection();

Checking that a User is a member of a Group

Only users who are members of a Group should be able to create a new Post in an existing Thread.

The Group Aggregate is responsible for controlling who can post in any particular thread and so we need a way of checking to see if a user is a member of a group.

We can do that by adding the following two methods to the Group Entity:

/**
 * Check to see if the User is a Member
 *
 * @param User $user
 * @return bool
 */
public function isMember(User $user)
{
    return $this->members->contains($user);
}

/**
 * Check to see if the User is an Admin
 *
 * @param User $user
 * @return bool
 */
public function isAdmin(User $user)
{
    return $this->admins->contains($user);
}

In both of these methods we are using the contains() method of the internal ArrayCollection.

We can test this functionality in the GroupTest file like this:

/** @test */
public function should_check_to_see_if_user_is_member()
{
    $group = new Group(GroupId::generate(), 'Cribbb');

    $this->assertFalse($group->isMember($this->user));

    $group->addMember($this->user);

    $this->assertTrue($group->isMember($this->user));
}

/** @test */
public function should_check_to_see_if_user_is_admin()
{
    $group = new Group(GroupId::generate(), 'Cribbb');

    $this->assertFalse($group->isAdmin($this->user));

    $group->addAdmin($this->user);

    $this->assertTrue($group->isAdmin($this->user));
}

In both of these tests we first assert that the user is not a member or admin. Next we add the user to the group. And finally we assert that the user is now a member or an admin.

Creating a new Post

Last week we saw how we can add a method to the Group Entity to assume the responsibility of creating new Thread objects.

We now face a similar situation where a Thread should have the responsibility for creating new Post objects.

A Post should require a User and a Thread on instantiation, and the User should be a member of the Group that the Thread belongs to.

We can encapsulate this business rule by adding a method to the Thread Entity:

/**
 * Create new Post
 *
 * @param User $user
 * @param string $body
 * @return Post
 */
public function createNewPost(User $user, $body)
{
    if ($this->group->isMember($user)) {
        $post = new Post(PostId::generate(), $user, $this, $body);

        $this->posts[] = $post;

        return $post;
    }

    throw new Exception('This user is not a member of the Group!');
}

First we check to ensure that the User is a member of the group. If the user is not a member we can abort the process by throwing a new Exception.

Next we create a new Post by generating a new PostId and passing in the $user, an instance of the current Thread as well as the $body string.

Finally we add the $post to the thread’s collection and then return it from the method.

We can also provide a public posts() method that returns the ArrayCollection.

Next we can add two tests to the ThreadTest file to ensure that this functionality is working as expected.

First we can assert that a new Post is created correctly:

/** @test */
public function should_create_post()
{
    $this->group->addMember($this->user);

    $thread = new Thread(ThreadId::generate(), $this->group, 'Hello World');

    $post = $thread->createNewPost($this->user, 'Once upon a time...');

    $this->assertInstanceOf('Cribbb\Domain\Model\Discussion\Post', $post);
}

Next we can assert that an Exception is thrown when a user who is not a member of the group attempts to create a new post in a thread:

/** @test */
public function should_fail_to_create_post_when_user_is_not_a_member()
{
    $this->setExpectedException('Exception');

    $thread = new Thread(ThreadId::generate(), $this->group, 'Hello World');

    $post = $thread->createNewPost($this->user, 'Once upon a time...');

    $this->assertInstanceOf('Cribbb\Domain\Model\Discussion\Post', $post);
}

Conclusion

Over the last two weeks we’ve looked at how we can enforce business rules through the instantiation of new objects. In today’s tutorial, the Thread Aggregate is responsible for creating new Post Entities.

By writing an explicit method on the Thread Aggregate to create new Post Entities, we can ensure that the business rules is enforced without the outside world knowing anything of the rules.

One thing I have neglected to implement is the rule about having at least one post in a thread. I could enforce this rule by having the Thread instantiated with a Post.

However, I’ve decided to not enforce this rule using object instantiation. Instead I will likely enforce this rule through a Service class. This isn’t ideal, but we would be getting into a Russian Doll situation which would make the code more difficult to work with. I think it’s fine in certain situations to let logic leak into the Service class.

This is a series of posts on building an entire Open Source application called Cribbb. All of the tutorials will be free to web, and all of the code is available on GitHub.

Philip Brown

@philipbrown

© Yellow Flag Ltd 2024.