Jan 05, 2015
Table of contents:
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.
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.
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();
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.
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);
}
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.