diff --git a/docs/how-to-guides/120-aggregates.md b/docs/how-to-guides/120-aggregates.md index 80d40ce3..095e7f8a 100644 --- a/docs/how-to-guides/120-aggregates.md +++ b/docs/how-to-guides/120-aggregates.md @@ -110,9 +110,9 @@ The above example is the simplest example of a use case, where: * A simple value object is passed in, * There are no rules applied to either who is performing this operation or what state the aggregate is in, -* We raise a single event, and forward the same data to it. +* We raise a single event and forward the same data to it. -In many cases, things are a little more complex than this, in many dimensions. +In many cases, things are a little more complex than this in many dimensions. For example, `AssignRoles()` in the `OrganizationsRoot`: @@ -248,13 +248,11 @@ The main job here is to convert the data in the domain event back into value obj #### Invariant rules -The second part of raising an event in the aggregate is the call to the `EnsureInvariants()` method. - -This method is called immediately after the event has been raised, and after it is handled by the `OnStateChanged()` method. +The second part of raising an event in the aggregate is the call to the `EnsureInvariants()` method, performed automatically by the `AggregateRootBase` class immediately after it is handled by the `OnStateChanged()` method. The purpose of the method is to ensure that, at all times, the aggregate is in a valid state. -> If you remember, one of the rules of aggregates is that they can NOT be invalid at any point in time. This moment is one of those points in time where that is enforced and verified. +> If you remember, one of the rules of aggregates is that (as a whole) they can NOT be invalid at any point in time. This moment is one of those points in time where that is enforced and verified. Thus, we say that the rules in this method are the "invariant" rules of the aggregate since they vary very little (if at all) over time. diff --git a/docs/how-to-guides/130-child-entities.md b/docs/how-to-guides/130-child-entities.md index af430e3a..c02d47f8 100644 --- a/docs/how-to-guides/130-child-entities.md +++ b/docs/how-to-guides/130-child-entities.md @@ -4,7 +4,7 @@ You want to represent a relationship from an aggregate to either a single entity or to a collection of entities. -> Remember, an entity (and aggregate root) differ "by ID" and "not by value" +> Remember, an entity (and aggregate root) differ from each other "by ID" and "not by value" > > If the concept you are modeling does NOT warrant a unique identifier (as opposed to differing "by value"), it probably is not a good candidate for an entity, and you should model your concept with a value object instead. > @@ -103,6 +103,158 @@ public sealed class Unavailability : EntityBase Lastly, notice that a child entity also has an `EnsureInvariants()` method, which works exactly the same way as it does for the aggregate root. +### Changing State + +For some entities, is it necessary to change their state in a use case. + +The use case is always implemented in the aggregate root, but the aggregate would be delegating the change in the entity to a method in the entity class, to comply with encapsulation and the design principles of [TellDontAsk](https://martinfowler.com/bliki/TellDontAsk.html) and the [Law of Demeter](https://en.wikipedia.org/wiki/Law_of_Demeter). + +#### Raising the event + +Changing an entity's state is done similarly to how you change the state of an aggregate. You raise a domain event to do that. + +> All domain events are defined on the aggregate. + +In the case of raising a domain event to change the state of an entity: + +1. From an aggregate root method, locate the instance of the specific entity to update. (usually from a collection of entities already stored on the on the aggregate). +2. Provide a method on the entity class, and have the aggregate call that method. +3. In the entity method, perform the usual validation and rules, and raise the domain event to the entity itself (using the `RaiseChangeEvent()` method). +4. The underlying `EntityBase` class will replay the domain event onto the entity, and then it will call `EnsureInvariants()` on the entity. +5. The entity will handle the domain event in the `OnStateChanged()` method and update its internal state from the state in the domain event. +6. This domain event is then passed up to the parent aggregate to handle (via the `RootEventHandler` defined on the constructor of the entity). +7. The aggregate root handles the domain event in its `OnStateChanged()` method, and finally the aggregate's `EnsureInvariants()` method is called, which often calls the `EnsureVariants() ` method on all the entities in the collection (again). + +> This process may seem on first-look to be the opposite of raising an event from the aggregate, where the aggregate receives the event first and then delegates it to the entity. But in actuality, the process is not any different at all. As in both cases, the entity's internal state is updated before the aggregate's state is updated. + +For example, for the `BookingRoot` entity in the `carsDomain`, a trip begins in the aggregate: + +```c# + public Result StartTrip(Location from) + { + if (!CarId.HasValue) + { + return Error.RuleViolation(Resources.BookingRoot_ReservationRequiresCar); + } + + var added = RaiseChangeEvent(BookingsDomain.Events.TripAdded(Id, OrganizationId)); + if (added.IsFailure) + { + return added.Error; + } + + var trip = Trips.Latest()!; + return trip.Begin(from); + } +``` + +Notice that the aggregate delegates the call to the specific `trip` entity, and the trip does this: + +```c# + public Result Begin(Location from) + { + if (BeganAt.HasValue) + { + return Error.RuleViolation(Resources.TripEntity_AlreadyBegan); + } + + var starts = DateTime.UtcNow; + return RaiseChangeEvent(Events.TripBegan(RootId.Value, OrganizationId.Value, Id, starts, from)); + } +``` + +Which raises the event to the entity first and then the aggregate last. + +#### Handling the raised event + +When any event is raised (using the `RaiseChangeEvent()`), the entity will always play the event back onto itself through the `OnStateChanged()` method. + +The first thing to do is to handle the event in the `OnStateChanged()` method. + +For example, + +```c# +protected override Result OnStateChanged(IDomainEvent @event) + { + switch (@event) + { + ... other event handlers + + case TripBegan changed: + { + var from = Location.Create(changed.BeganFrom); + if (from.IsFailure) + { + return from.Error; + } + + BeganAt = changed.BeganAt; + From = from.Value; + return Result.Ok; + } + + ... other event handlers + + default: + return HandleUnKnownStateChangedEvent(@event); + } + } +``` + +The main job here is to convert the data in the domain event back into value objects and then set properties on the entity. + +> It is important to note that you only need to set properties on the entity if you need to use them in either the rules of other use cases or for mapping in the application class. +> The other thing worth saying (to avoid over-engineering at this stage) is that even if you decide not to represent the data in a property on the entity now (which is optional), you can always add it later; there is no negative impact. YAGNI, don't add it now if you don't need it now. Then, when you need it, you add it. + +#### Invariant rules + +The second part of raising an event in the entity is the call to the `EnsureInvariants()` method, performed automatically by the `EntityBase` class immediately after it is handled by the `OnStateChanged()` method. + +The purpose of the method is to ensure that, at all times, the entity is in a valid state. + +> If you remember, one of the rules of aggregates is that (as a whole) they can NOT be invalid at any point in time. This moment is one of those points in time where that is enforced and verified. + +Thus, we say that the rules in this method are the "invariant" rules of the entity since they vary very little (if at all) over time. + +These rules, can cascade down to another collection of entities and value objects if needed. + +For example, in the `Trip` + +```c# + public override Result EnsureInvariants() + { + var ensureInvariants = base.EnsureInvariants(); + if (ensureInvariants.IsFailure) + { + return ensureInvariants.Error; + } + + if (BeganAt.HasValue && !From.HasValue) + { + return Error.RuleViolation(Resources.TripEntity_NoStartingLocation); + } + + if (EndedAt.HasValue && !BeganAt.HasValue) + { + return Error.RuleViolation(Resources.TripEntity_NotBegun); + } + + if (EndedAt.HasValue && !To.HasValue) + { + return Error.RuleViolation(Resources.TripEntity_NoEndingLocation); + } + + return Result.Ok; + } +``` + +Some key notes here: + +1. Not every state (after an event is raised) requires an invariant rule to be put in place. Focus on those that must be true at all times, or in specific known states. +2. You may want to cascade the rules in child entities or value object collections. +3. In general, use the `RuleViolation` error with a specific description. +4. These rules (and their contexts) should be unit-tested. + ### Dealing with collections Sometimes, you will need to create a collection of entities for your aggregate. diff --git a/src/CarsDomain.UnitTests/UnavailabilitySpec.cs b/src/CarsDomain.UnitTests/UnavailabilitySpec.cs index 04215d3a..6d9cb031 100644 --- a/src/CarsDomain.UnitTests/UnavailabilitySpec.cs +++ b/src/CarsDomain.UnitTests/UnavailabilitySpec.cs @@ -55,33 +55,33 @@ public void WhenOverlapsAndNotAssigned_ThenReturnsError() result.Should().BeError(ErrorCode.RuleViolation, Resources.UnavailabilityEntity_NotAssigned); } -#if TESTINGONLY [Fact] public void WhenOverlapsAndNotOverlapping_ThenReturnsFalse() { +#if TESTINGONLY _unavailability.TestingOnly_Assign("acarid".ToId(), "anorganizationid".ToId(), TimeSlot.Create(_start, _end).Value, CausedBy.Create(UnavailabilityCausedBy.Other, null).Value); +#endif var slot = TimeSlot.Create(_end, _end.AddHours(1)).Value; var result = _unavailability.Overlaps(slot).Value; result.Should().BeFalse(); } -#endif -#if TESTINGONLY [Fact] public void WhenOverlapsAndOverlapping_ThenReturnsTrue() { +#if TESTINGONLY _unavailability.TestingOnly_Assign("acarid".ToId(), "anorganizationid".ToId(), TimeSlot.Create(_start, _end).Value, CausedBy.Create(UnavailabilityCausedBy.Other, null).Value); +#endif var slot = TimeSlot.Create(_start.SubtractHours(1), _end.AddHours(1)).Value; var result = _unavailability.Overlaps(slot).Value; result.Should().BeTrue(); } -#endif [Fact] public void WhenIsDifferentCauseAndHasNoCausedByInEither_ThenReturnsFalse() @@ -93,115 +93,123 @@ public void WhenIsDifferentCauseAndHasNoCausedByInEither_ThenReturnsFalse() result.Should().BeFalse(); } -#if TESTINGONLY [Fact] public void WhenIsDifferentCauseAndHasNoCausedInSource_ThenReturnsTrue() { var other = Unavailability.Create(_recorder.Object, _idFactory.Object, _ => Result.Ok); +#if TESTINGONLY other.Value.TestingOnly_Assign("acarid".ToId(), "anorganizationid".ToId(), TimeSlot.Create(_start, _end).Value, CausedBy.Create(UnavailabilityCausedBy.Other, null).Value); +#endif var result = _unavailability.IsDifferentCause(other.Value); result.Should().BeTrue(); } -#endif -#if TESTINGONLY [Fact] public void WhenIsDifferentCauseAndHasNoCausedInOther_ThenReturnsTrue() { +#if TESTINGONLY _unavailability.TestingOnly_Assign("acarid".ToId(), "anorganizationid".ToId(), TimeSlot.Create(_start, _end).Value, CausedBy.Create(UnavailabilityCausedBy.Other, null).Value); +#endif var other = Unavailability.Create(_recorder.Object, _idFactory.Object, _ => Result.Ok); var result = _unavailability.IsDifferentCause(other.Value); result.Should().BeTrue(); } -#endif -#if TESTINGONLY [Fact] public void WhenIsDifferentCauseAndHaveSameCausesAndNoReferences_ThenReturnsFalse() { +#if TESTINGONLY _unavailability.TestingOnly_Assign("acarid".ToId(), "anorganizationid".ToId(), TimeSlot.Create(_start, _end).Value, CausedBy.Create(UnavailabilityCausedBy.Other, null).Value); +#endif var other = Unavailability.Create(_recorder.Object, _idFactory.Object, _ => Result.Ok); +#if TESTINGONLY other.Value.TestingOnly_Assign("acarid".ToId(), "anorganizationid".ToId(), TimeSlot.Create(_start, _end).Value, CausedBy.Create(UnavailabilityCausedBy.Other, null).Value); +#endif var result = _unavailability.IsDifferentCause(other.Value); result.Should().BeFalse(); } -#endif -#if TESTINGONLY [Fact] public void WhenIsDifferentCauseAndHaveSameCausesAndDifferentReferences_ThenReturnsTrue() { +#if TESTINGONLY _unavailability.TestingOnly_Assign("acarid".ToId(), "anorganizationid".ToId(), TimeSlot.Create(_start, _end).Value, CausedBy.Create(UnavailabilityCausedBy.Other, "areference1").Value); +#endif var other = Unavailability.Create(_recorder.Object, _idFactory.Object, _ => Result.Ok); +#if TESTINGONLY other.Value.TestingOnly_Assign("acarid".ToId(), "anorganizationid".ToId(), TimeSlot.Create(_start, _end).Value, CausedBy.Create(UnavailabilityCausedBy.Other, "areference2").Value); +#endif var result = _unavailability.IsDifferentCause(other.Value); result.Should().BeTrue(); } -#endif -#if TESTINGONLY [Fact] public void WhenIsDifferentCauseAndHaveDifferentCausesAndNullReference_ThenReturnsTrue() { + var other = Unavailability.Create(_recorder.Object, _idFactory.Object, _ => Result.Ok); +#if TESTINGONLY _unavailability.TestingOnly_Assign("acarid".ToId(), "anorganizationid".ToId(), TimeSlot.Create(_start, _end).Value, CausedBy.Create(UnavailabilityCausedBy.Other, null).Value); - var other = Unavailability.Create(_recorder.Object, _idFactory.Object, _ => Result.Ok); other.Value.TestingOnly_Assign("acarid".ToId(), "anorganizationid".ToId(), TimeSlot.Create(_start, _end).Value, CausedBy.Create(UnavailabilityCausedBy.Maintenance, null).Value); +#endif var result = _unavailability.IsDifferentCause(other.Value); result.Should().BeTrue(); } -#endif -#if TESTINGONLY [Fact] public void WhenIsDifferentCauseAndHaveDifferentCausesAndSameReference_ThenReturnsTrue() { +#if TESTINGONLY _unavailability.TestingOnly_Assign("acarid".ToId(), "anorganizationid".ToId(), TimeSlot.Create(_start, _end).Value, CausedBy.Create(UnavailabilityCausedBy.Other, "areference").Value); +#endif var other = Unavailability.Create(_recorder.Object, _idFactory.Object, _ => Result.Ok); +#if TESTINGONLY other.Value.TestingOnly_Assign("acarid".ToId(), "anorganizationid".ToId(), TimeSlot.Create(_start, _end).Value, CausedBy.Create(UnavailabilityCausedBy.Maintenance, "areference").Value); +#endif var result = _unavailability.IsDifferentCause(other.Value); result.Should().BeTrue(); } -#endif -#if TESTINGONLY [Fact] public void WhenIsDifferentCauseAndHaveDifferentCausesAndDifferentReference_ThenReturnsTrue() { +#if TESTINGONLY _unavailability.TestingOnly_Assign("acarid".ToId(), "anorganizationid".ToId(), TimeSlot.Create(_start, _end).Value, CausedBy.Create(UnavailabilityCausedBy.Other, "areference1").Value); +#endif var other = Unavailability.Create(_recorder.Object, _idFactory.Object, _ => Result.Ok); +#if TESTINGONLY other.Value.TestingOnly_Assign("acarid".ToId(), "anorganizationid".ToId(), TimeSlot.Create(_start, _end).Value, CausedBy.Create(UnavailabilityCausedBy.Maintenance, "areference2").Value); +#endif var result = _unavailability.IsDifferentCause(other.Value); result.Should().BeTrue(); } -#endif [Fact] public void WhenEnsureInvariantsAndNoDetails_ThenReturnsError() @@ -211,16 +219,16 @@ public void WhenEnsureInvariantsAndNoDetails_ThenReturnsError() result.Should().BeError(ErrorCode.RuleViolation, Resources.UnavailabilityEntity_NotAssigned); } -#if TESTINGONLY [Fact] public void WhenEnsureInvariantsAndAssigned_ThenReturnsTrue() { +#if TESTINGONLY _unavailability.TestingOnly_Assign("acarid".ToId(), "anorganizationid".ToId(), TimeSlot.Create(_start, _end).Value, CausedBy.Create(UnavailabilityCausedBy.Other, "areference1").Value); +#endif var result = _unavailability.EnsureInvariants(); result.Should().BeSuccess(); } -#endif } \ No newline at end of file diff --git a/src/CarsDomain/Unavailability.cs b/src/CarsDomain/Unavailability.cs index 82e3895f..f8ce1fa9 100644 --- a/src/CarsDomain/Unavailability.cs +++ b/src/CarsDomain/Unavailability.cs @@ -110,7 +110,10 @@ public Result Overlaps(TimeSlot slot) public void TestingOnly_Assign(Identifier carId, Identifier organizationId, TimeSlot timeSlot, CausedBy causedBy) { - RaiseChangeEvent(Events.UnavailabilitySlotAdded(carId, organizationId, timeSlot, causedBy)); + CarId = carId; + OrganizationId = organizationId; + Slot = timeSlot; + CausedBy = causedBy; } #endif } \ No newline at end of file