Entity invariants

We have gone through using value objects to protect invalid values from being even used as parameters for entity constructors and methods. This technique allows us moving a lot of checks to value objects, provides nice encapsulation and enables type safety. Then, when we create a new entity or execute some behavior using entity methods, we need to do some more checks. Since we can be quite sure that all parameters already contain valid individual values, we need to ensure that a given combination of parameters, current entity state and execute behavior is not going to bring the entity to some invalid state.

Let's look at what complex rules we have for our classified ad entity. To find such rules, we can use some sticky notes from our detailed EventStorming session in Chapter 3, and put them on a chart like this:

Analysing constraints for a command

We put command to the left side, the event to the right side and try to find out what could prevent our command to be executed in a way that it produces the desired outcome (the event). In our case here, we need to ensure that before an ad can be put to the review queue, it must have a non-empty title, text, and price. We cannot put these checks combined with the value object since before the ad is sent to review, it can have an empty title and text, it can have no price. Only when a given command is being executed, we need to check if these constraints are satisfied. It is what we can call an invariant for this entity - an ad that is in pending review cannot have an empty title, an empty text or zero price.

There are at least two ways to ensure that our entity never gets to an invalid state. The first and most obvious way is to add checks to the operation code. We have no method to request the ad to be published, so let's add it and make some changes related to the fact of using value objects for entity state as well:

namespace Marketplace.Domain
{
public class ClassifiedAd
{
public ClassifiedAdId Id { get; }

public ClassifiedAd(ClassifiedAdId id, UserId ownerId)
{
Id = id;
OwnerId = ownerId;
State = ClassifiedAdState.Inactive;
}

public void SetTitle(ClassifiedAdTitle title) => Title = title;

public void UpdateText(ClassifiedAdText text) => Text = text;

public void UpdatePrice(Price price) => Price = price;

public void RequestToPublish()
{
if (Title == null)
throw new InvalidEntityStateException(this, "title cannot be empty");

if (Text == null)
throw new InvalidEntityStateException(this, "text cannot be empty");

if (Price?.Amount == 0)
throw new InvalidEntityStateException(this, "price cannot be zero");

State = ClassifiedAdState.PendingReview;
}

public UserId OwnerId { get; }
public ClassifiedAdTitle Title { get; private set; }
public ClassifiedAdText Text { get; private set; }
public Price Price1 { get; private set; }
public ClassifiedAdState State { get; private set; }
public UserId ApprovedBy { get; private set; }

public enum ClassifiedAdState
{
PendingReview,
Active,
Inactive,
MarkedAsSold
}
}
}

In the new entity code, we have all properties to be typed as value objects, and we got one more property for the classified ad current state. At the beginning, it is set to Inactive, and when the ad is requested to be published, we change the state to PendingReview. But we only do it when all checks are satisfied.

To let the caller know if our entity is not ready to be published when some of those checks fail, we use our custom exception, which is implemented like this:

using System;

namespace Marketplace.Domain
{
public class InvalidEntityStateException : Exception
{
public InvalidEntityStateException(object entity, string message)
: base($"Entity {entity.GetType().Name} state change rejected, {message}")
{
}
}
}

This method of checking constraints before executing the operation, in the operation method itself, has one disadvantage. If now we will change the price to zero, it will go through, because UpdatePrice method is not checking the price value. We could, of course, copy the price check to the UpdatePrice method too but there might be more methods that need the same tests and we will keep copying control blocks. It will lead to a situation when if we need to change any of those rules, we need to go to numerous of places to replace all of the checks. This is very error-prone.

To combine rules in one place, we can use techniques of contract programming. Part of contract programming can be seen in value objects since we evaluate pre-conditions for each parameter of the operation method. When we execute the operation without doing any additional checks, we will need to do a combined test (post-condition control). This check can be implemented in one place, for the whole entity and each operation will need to call it at the last line in the method.

For our classified ad entity it could look like this:

namespace Marketplace.Domain
{
public class ClassifiedAd
{
public ClassifiedAdId Id { get; }

public ClassifiedAd(ClassifiedAdId id, UserId ownerId)
{
Id = id;
OwnerId = ownerId;
State = ClassifiedAdState.Inactive;
EnsureValidState();
}

public void SetTitle(ClassifiedAdTitle title)
{
Title = title;
EnsureValidState();
}

public void UpdateText(ClassifiedAdText text)
{
Text = text;
EnsureValidState();
}

public void UpdatePrice(Price price)
{
Price = price;
EnsureValidState();
}

public void RequestToPublish()
{
State = ClassifiedAdState.PendingReview;
EnsureValidState();
}

private void EnsureValidState()
{
bool valid = Id != null && OwnerId != null;
switch (State)
{
case ClassifiedAdState.PendingReview:
valid = valid
&& Title != null
&& Text != null
&& Price?.Amount > 0;
break;
case ClassifiedAdState.Active:
valid = valid
&& Title != null
&& Text != null
&& Price?.Amount > 0
&& ApprovedBy != null;
break;
}

if (!valid)
throw new InvalidEntityStateException(this,
$"Post-checks failed in state {State}");
}

public UserId OwnerId { get; }
public ClassifiedAdTitle Title { get; private set; }
public ClassifiedAdText Text { get; private set; }
public Price Price1 { get; private set; }
public ClassifiedAdState State { get; private set; }
public UserId ApprovedBy { get; private set; }

public enum ClassifiedAdState
{
PendingReview,
Active,
Inactive,
MarkedAsSold
}
}
}

As you can see, we have added one method called EnsureValidState that checks that in any situation the entity state is valid and if it is not valid - an exception will be thrown. When we call this method from any operation method, we can be sure that no matter what we are trying to do, our entity will always be in a valid state or the caller will get an exception.

Also, we converted all private fields to public read-only properties. We need public properties to write tests although we don't necessarily need to expose the internal entity state. To prevent setting values of these properties outside of operation methods, all properties have private setters or no setters for properties that are set int he constructor.

Now, let's write some tests to ensure that our constraints work:

using System;
using Marketplace.Domain;
using Xunit;

namespace Marketplace.Tests
{
public class ClassifiedAd_Publish_Spec
{
private readonly ClassifiedAd _classifiedAd;

public ClassifiedAd_Publish_Spec()
{
_classifiedAd = new ClassifiedAd(
new ClassifiedAdId(Guid.NewGuid()),
new UserId(Guid.NewGuid()));
}

[Fact]
public void Can_publish_a_valid_ad()
{
_classifiedAd.SetTitle(ClassifiedAdTitle.FromString("Test ad"));
_classifiedAd.UpdateText(ClassifiedAdText.FromString("Please buy my stuff"));
_classifiedAd.UpdatePrice(
Price.FromDecimal(100.10m, "EUR", new FakeCurrencyLookup()));

_classifiedAd.RequestToPublish();

Assert.Equal(ClassifiedAd.ClassifiedAdState.PendingReview,
_classifiedAd.State);
}

[Fact]
public void Cannot_publish_without_title()
{
_classifiedAd.UpdateText(ClassifiedAdText.FromString("Please buy my stuff"));
_classifiedAd.UpdatePrice(
Price.FromDecimal(100.10m, "EUR", new FakeCurrencyLookup()));

Assert.Throws<InvalidEntityStateException>(() => _classifiedAd.RequestToPublish());
}

[Fact]
public void Cannot_publish_without_text()
{
_classifiedAd.SetTitle(ClassifiedAdTitle.FromString("Test ad"));
_classifiedAd.UpdatePrice(
Price.FromDecimal(100.10m, "EUR", new FakeCurrencyLookup()));

Assert.Throws<InvalidEntityStateException>(() => _classifiedAd.RequestToPublish());
}

[Fact]
public void Cannot_publish_without_price()
{
_classifiedAd.SetTitle(ClassifiedAdTitle.FromString("Test ad"));
_classifiedAd.UpdateText(ClassifiedAdText.FromString("Please buy my stuff"));

Assert.Throws<InvalidEntityStateException>(() => _classifiedAd.RequestToPublish());
}

[Fact]
public void Cannot_publish_with_zero_price()
{
_classifiedAd.SetTitle(ClassifiedAdTitle.FromString("Test ad"));
_classifiedAd.UpdateText(ClassifiedAdText.FromString("Please buy my stuff"));
_classifiedAd.UpdatePrice(
Price.FromDecimal(0.0m, "EUR", new FakeCurrencyLookup()));

Assert.Throws<InvalidEntityStateException>(() => _classifiedAd.RequestToPublish());
}
}
}

This spec contains several tests for one operation (publish, or submit for review) with different pre-conditions. Here we test a happy path when all necessary details are correctly set before the ad can be sent for review; we also test several negative cases when publishing is not allowed due to missing mandatory information. Perhaps, testing negative scenarios is even more essential, since it is straightforward to find out when the happy path does not work - your users will immediately complain. Testing negative scenarios prevents bugs in controlling entity invariants, which in turn prevent entities to become invalid.

But now you might be wondering why we spent so much time talking about domain events and have not seen a single one in code? We will be discussing this in the next section.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.218.157.34