I am having a problem modeling and implementing a event attendance system using CQRS. My issue is that a child entity can raise an event, but I am not sure how and when to process it.
Basically, an event can have attendees, which start in a TBD state, and can either accept or reject attending the event. However, they can change their attendance, and when that happens, I would like a event to be raised so that an event handler can process (notify a event organizer for example).
I have used the state pattern to manage an attendee's state, and it depend on the current state whether a event should be raised. At the moment, this event does not change the state of the Event. However it seems to me that this event should be part of the event stream.
My issue is that I don't know if the event will be raised until I apply one of the AttendeeResponded events, which calls the method on the current state.If I raise an event during a Apply, then I would end up with problem rehydrating the AR. I could add this information to the event during the apply, having the state return information, but then the event become mutable.
My thought is that maybe the state pattern does not work well as a place where events could be generated, or that maybe that the state pattern is not a good fit here. I could extend the state to have a method that determines if a certain state change will throw an event, but that seems clunky.
Finally, my AR's don't have any references to eventBus's, so I can't just throw an event onto the bus, and not have it as part of the AR's event stream. I had though AR's having a reference to the event bus was starting to violate SRP, but maybe I'm wrong on that.
I've included simplified code to help my description. Anyone with some helpful tips? Thanks,Phil
public class Event : EventSourcedAggregateRoot<Guid>
{
#region Fields
private readonly HashSet<Attendee> _attendance = new HashSet<Attendee>();
private Guid _eventID;
private string _title;
#endregion
#region Constructors
[Obsolete]
private Event()
{
}
public Event(LocalDate date, string title)
{
HandleEvent(new EventCreated(date, title, new GuidCombGenerator().GenerateNewId()));
}
public Event(IEnumerable<IAggregateEvent<Guid>> @events)
{
LoadsFromHistory(@events);
}
#endregion
#region Properties and Indexers
public IReadOnlyCollection<Attendee> Attendance
{
get { return _attendance.ToArray(); }
}
public Guid EventID
{
get { return _eventID; }
private set
{
if (_eventID == new Guid()) _eventID = value;
else throw new FieldAccessException("Cannot change the ID of an entity.");
}
}
public LocalDate Date { get; private set; }
public override Guid ID
{
get { return EventID; }
set { EventID = value; }
}
public string Title
{
get { return _title; }
private set
{
Guard.That(() => value).IsNotNullOrWhiteSpace();
_title = value;
}
}
#endregion
#region Methods
public override void Delete()
{
if (!Deleted)
HandleEvent(new EventDeleted(EventID));
}
public void UpdateEvent(LocalDate date, string title)
{
HandleEvent(new EventUpdated(date, title, EventID));
}
public void AddAttendee(Guid memberID)
{
Guard.That(() => _attendance).IsTrue(set => set.All(attendee => attendee.MemberID != memberID), "Attendee already exists");
HandleEvent(new AttendeeAdded(memberID, EventID));
}
public void DeleteAttendee(Guid memberID)
{
Guard.That(() => memberID).IsTrue(x => Attendance.Any(a => attendee.MemberID == memberID), "MemberID does not exist");
HandleEvent(new AttendeeDeleted(memberID, EventID));
}
internal void RespondIsComing(Guid memberID)
{
Guard.That(() => memberID).IsTrue(x => Attendance.Any(a => attendee.MemberID == memberID), "MemberID does not exist");
HandleEvent(new AttendeeRespondedAsComing(memberID, EventID));
}
internal void RespondNotComing(Guid memberID)
{
Guard.That(() => memberID).IsTrue(x => Attendance.Any(a => attendee.MemberID == memberID), "MemberID does not exist");
HandleEvent(new AttendeeRespondedAsNotComing(memberID, EventID));
}
#endregion
#region Event Handlers
private void Apply(EventCreated @event)
{
Date = @event.Date;
Title = @event.Title;
EventID = @event.EventID;
}
private void Apply(EventDeleted @event)
{
Deleted = true;
}
private void Apply(AttendeeAdded @event)
{
_attendance.Add(new Attendee(@event.MemberID, @event.EventID));
}
private void Apply(EventUpdated @event)
{
Title = @event.Title;
Date = @event.Date;
}
private void Apply(AttendeeRespondedAsComing @event)
{
var attendee = GetAttendee(@event.AttendeeID);
attendee.Accept();
}
private void Apply(AttendeeRespondedAsNotComing @event)
{
var attendee = GetAttendee(@event.AttendeeID);
attendee.Reject();
}
private void Apply(AttendeeDeleted @event)
{
_attendance.RemoveWhere(x => x.AttendeeID == @event.AttendeeID);
}
protected override void ApplyEvent(IAggregateEvent @event)
{
Apply((dynamic) @event);
}
#endregion
}
public class Attendee
{
#region AttendenceResponse enum
public enum AttendenceResponse
{
TBD,
Coming,
NotComing
}
#endregion
#region Fields
private IAttendenceResponseState _attendState;
private readonly Guid _eventID;
private readonly Guid _memberID;
#endregion
#region Constructors
public Attendee(Guid memberID, Guid EventID)
{
_memberID = memberID;
_eventID = EventID;
_attendState = new TBD(this);
}
#endregion
#region Properties and Indexers
public IAttendenceResponseState AttendingState
{
get { return _attendState; }
private set { _attendState = value; }
}
public Guid EventID
{
get { return _eventID; }
}
public Guid MemberID
{
get { return _memberID; }
}
#endregion
#region Methods
public void Accept()
{
_attendState.Accept();
}
public void Reject()
{
_attendState.Reject();
}
#endregion
#region Nested type: IAttendenceResponseState
public interface IAttendenceResponseState
{
#region Properties and Indexers
AttendenceResponse AttendenceResponse { get; }
#endregion
#region Methods
void Accept();
void Reject();
#endregion
}
#endregion
#region Nested type: Coming
private class Coming : IAttendenceResponseState
{
#region Fields
private readonly Attendee _attendee;
#endregion
#region Constructors
public Coming(Attendee attendee)
{
_attendee = attendee;
}
#endregion
#region IAttendenceResponseState Members
public void Accept()
{
}
public AttendenceResponse AttendenceResponse
{
get { return AttendenceResponse.Coming; }
}
public void Reject()
{
_attendee.AttendingState = (new NotComing(_attendee));
//Here is where I would like to 'raise' an event
}
#endregion
}
#endregion
#region Nested type: NotComing
private class NotComing : IAttendenceResponseState
{
#region Fields
private readonly Attendee _attendee;
#endregion
#region Constructors
public NotComing(Attendee attendee)
{
_attendee = attendee;
}
#endregion
#region IAttendenceResponseState Members
public void Accept()
{
_attendee.AttendingState = (new Coming(_attendee));
//Here is where I would like to 'raise' an event
}
public AttendenceResponse AttendenceResponse
{
get { return AttendenceResponse.NotComing; }
}
public void Reject()
{
}
#endregion
}
#endregion
#region Nested type: TBD
private class TBD : IAttendenceResponseState
{
#region Fields
private readonly Attendee _attendee;
#endregion
#region Constructors
public TBD(Attendee attendee)
{
_attendee = attendee;
}
#endregion
#region IAttendenceResponseState Members
public void Accept()
{
_attendee.AttendingState = (new Coming(_attendee));
}
public AttendenceResponse AttendenceResponse
{
get { return AttendenceResponse.TBD; }
}
public void Reject()
{
_attendee.AttendingState = (new NotComing(_attendee));
}
#endregion
}
#endregion
}
Reply to mynkow's response:
I expose some of the state (read-only mind you) so that I might create projections of the current state of an aggregate. How would you normally do this? Do you create projection directly from events (this seems more complicated then reading the current state from the aggregate), or do you have your aggregate create DTOs?
I had public void AddAttendee(Guid memberID) before, but I switch it to Member to try force that a valid member would have to exist. I think I was wrong in doing this, and have since created an Attendance manager that does this validation and calls this method. (code updated to reflect this)
I used nested classes to try to signify that it was parent child relationship, but I agree, I don't much like how large it makes the Event class. The AttendenceResponseState is nested however so that it can modify the Attendee's private state. Do you think this use is valid? (code updated to move Attendee outside of Event class)
Just to be clear, AttendenceResponseState is a implementation of the State Pattern, not the Attendee's full state (conflicting words :))
And I agree that Attendee doesn't really need to be an entity, but the ID is from another system that I have to work with, so I thought I would use it here. Some stuff is lost in the preparation of the code for SO.
I personally don't like separating the aggregates state from the aggregate, but just as a matter of personal taste. I might review that choice if I have to implement momento's, or as I gain more experience :). Also are Ports the same as Sagas?
Can you talk more to how an aggregate would produce more then one event? I think this is one of the things I am trying to do. Is it ok to call an ApplyEvent, then perform more logic and possibly call ApplyEvent a second time?
Thanks for your input, and if you have any other notes I'll be glad to hear them.