AggregateRoot.Emit() does not check for duplicate SourceId
When emitting an AggregateEvent with a Metadata.SourceId, AggregateRoot.Emit() does not inject the emitted event's Metadata.SourceId into PreviousSourceIds, and so issuing the same command with the same SourceId on the same aggregate multiple times doesn't throw a DuplicateOperationException. This check for duplicate SourceId seems to only be performed when using AggregateStore.Update(), which makes it more difficult to write tests for duplicate SourceId detection.
In other words, you can't simply write a test that instantiates an aggregate, issues two commands with the same SourceId and expect it to reliably detect the duplicate condition - instead you have to instantiate (or mock) the whole AggregateStore and use that in your tests. While this can be done, it makes testing more difficult and imposes a steeper learning curve on the users.
Consider the following piece of code:
using System;
using System.Collections.Generic;
using EventFlow.Aggregates;
using EventFlow.Core;
using EventFlow.Exceptions;
using FluentAssertions;
using Xunit;
namespace MyTests
{
public class DuplicateSourceIdTests
{
private class MyAggregateId : Identity<MyAggregateId>
{
public MyAggregateId(string value) : base(value) { }
}
private class MyEvent : AggregateEvent<MyAggregate, Identity<MyAggregateId>> { }
private class MyAggregate : AggregateRoot<MyAggregate, Identity<MyAggregateId>>
{
public MyAggregate(Identity<MyAggregateId> id) : base(id) { }
public void DoSomething(SourceId sourceId)
{
Emit(new MyEvent(), new Metadata(KeyValuePair.Create(MetadataKeys.SourceId, sourceId.Value)));
}
protected void Apply(MyEvent _) { }
}
[Fact(DisplayName = "Issuing multiple commands with duplicate SourceId should throw DuplicateOperationException")]
public void Issuing_command_with_duplicate_SourceId__should_throw_DuplicateOperationException()
{
var sourceId = new SourceId("A8C53FFD-1E85-407B-B176-24A16F591254");
var aggregate = new MyAggregate(Identity<MyAggregateId>.New);
aggregate.DoSomething(sourceId); // invoking the command the first time
aggregate
.Invoking(a => a.DoSomething(sourceId)) // invoking the same command the second time
.Should().Throw<DuplicateOperationException>(); // does not actually throw!
}
}
}
The test above should detect that MyAgregate.DoSomething() was called twice with the same SourceId and throw an DuplicateOperationException, but it does not.
Is this a bug, or is it an intentional decision driven by some architectural reasons? If the latter is the case, what are those reasons?
Should be checked
@Yaevh the check for source ID is done in the AggregateStore. Here's the reference.
https://github.com/eventflow/EventFlow/blob/develop-v1/Source/EventFlow/Aggregates/AggregateStore.cs#L132
I've looked through the source code and it seems that this behaviour (not checking for duplicate SourceIds inside the aggregate) is actually desirable, because a single command may actually result in emitting multiple events from the aggregate - and these events would have the same SourceId.
Consider a CulinaryRecipeAggregate: a recipe consists of one or more steps ("preheat a frying pan", "break three eggs into a bowl", "pour the eggs from the bowl into the pan" etc.), and performing each step leads to the aggregate emitting an appropriate StepCompletedEvent. Additionally, when the last step of the recipe is performed ("transfer the omelette onto a plate"), the aggregate should emit not only a StepCompletedEvent, but also a RecipeFinishedEvent and a DishReadyToBeServedEvent. These three events originate from a single RecordStepCompletedCommand, and thus would have the same SourceId.
In 99.9% of the cases the aggregate would not be manipulated directly, but indirectly via IAggregateStore.Update(), and IAggregateStore.Update() checks whether the new events' SouceId matches any of the previously loaded SourceIds. So this method guards against issuing a command with the same SourceId multiple times, but allows for a single command to result in the aggregate emitting multiple events with the same SourceId.