Skip to content

Protecting invariants

An invariant is something that holds true about your domain no matter what. Invariants ensure consistency within the domain.

As an example using our Post aggregate, an invariant we could probably protect is that on insert, a title should not be empty or null.

TDD

First, let’s write out a test to prove that a TitleWasNullException is thrown when we violate the invariant.

<?php

use App\Domain\Post\Post;
use App\Domain\Post\TitleWasNullException;
use App\Domain\Post\ValueObject\PostId;
use App\Domain\Post\ValueObject\Title;
use App\Domain\Post\ValueObject\Content;

it('should not allow a null title.', function () use ($event) {
    return Post::createPost(
        postId: new PostId(value: '760b7c16-b28e-4d31-9f93-7a2f0d3a1c51'),
        title: new Title(value: ''),
        content: new Content(value: 'A null title is not allowed'),
    );
})->throws(exception: TitleWasNullException::class, exceptionMessage: 'Title cannot be null.');

Method Updates

In the last section, we introduced the Post aggregate. We will now update the methods createPost and changeTitle to meet our invariant exception. As mentioned in the previous section when we created some value objects, the Title value object inherits a few methods from the base class StringLiteral. One of those methods is isEmpty(). We can use this method to protect our invariant.

<?php

/**
 * @throws TitleWasNullException
 */
public static function createPost(PostId $postId, Title $title, Content $content): Post
{
    if ($title->isEmpty()) {
        throw new TitleWasNullException(message: 'Title cannot be null.');
    }

    $post = self::root(aggregateId: $postId);

    $post->recordApplyAndPublishThat(
        event: PostWasCreated::withData($postId, $title, $content)
    );

    return $post;
}

/**
 * @throws TitleWasNullException
 */
public function changeTitle(Title $title): void
{
    if ($title->isEmpty()) {
        throw new TitleWasNullException(message: 'Title cannot be null.');
    }
    if ($title->__toString() === $this->title->__toString()) {
        return;
    }
    $this->recordApplyAndPublishThat(
        event: TitleWasChanged::withData(postId: $this->postId, title: $title)
    );
}

Now that our methods have been updated, our tests should now pass.