Having many to many relationships in your model is normal. Having constraints between concepts in our models is also normal.
This doesn't mean that the implementation of your model has to be done by using
object references* from one entity to another. Also the assisiation doesn't have to be bidirectional.
In the cases where you have an operation spanning multiple aggregates, and this operation is more complext, you have a Process. One way to implement a Processes is to use a Saga
Here are couple of questions that need to be answered:
How do you identify an Atendee? Do you use a name, email of some kind of code? Do people who want to attend sessions make accounts before registering for sessions?
If all conference sessions are full, should the person requesting to become an Atendee have his Request rejected?
I think that you may be missing some concepts in your model or you haven't fully exlained it . I'll try to give an example of a domain model based on some assumptions that I'll make, taking into account the above questions.
Let's say that a person has to make an Account with his email so he/she can be identified.
After the Account is created, this person can try to register fo ConferenceSessions. If there are no available seats for any sessions, then this person will have an account without been able to become an Atendee at all.
This will simplify are logic as we won't have to do verification for the first time when a person tries to attend a ConferenceSession. It will also give us a way to identify a person (with his/her Account). It has the overhead of having accounts who don't attend anything but if this is a big system that people can attend other Conferences later on they will be able to use their account again. Also our system will be able to keep track of this person. How many Conferences has been attended and so on. Also a credit card or some other payment method can be assosiated with this Account.
OK, so when a person registers he creates an Account. Accounts only have unique e-mail constrain that doesn't involve other entities, so i'll skip this one and concentrate
on the other many-to-many relationships.
Note: the Venue has the maxCapacity, but you talk about your ConferenceSessions having a capacity, so I'll just assume that the sessions have the capacity and ignore the unclear Venue. This doesn't change the validity of the provided solution.
Then a person can use his Account to try to register for a ConferenceSession. His RegistrationRequest can be either approved or rejected based on the capacity of the ConferenceSession and the current Attendees count and how many sessions he is attending.
Also we have couple of events that occur in our system:
JoinSessionRequested
JoinSessionRequestRejected
JoinSessionsRequestWaitingForApproval
JoinSessionRequestApproved
ConferenceSessionJoined
JoinConferenceSessionRejected
We will create a reactive system that will use these events to trigger actions.
Here's one possible implementation.
public class Account : ConcurrentEntity {
public Guid ID { get; }
public Email Email { get; }
}
public class Attendee : ConcurrentEntity {
public Guid AccountID { get; }
public Guid ConferenceSessionID { get; }
}
public class ConferenceSession : ConcurrentEntity {
public Guid ID { get; }
public Guid ConferenceID { get; }
public Attendee[] Attendees { get; }
public Capacity MaxCapacity { get; }
public bool HasReachedMaxCapacity {
get { return Attendees.Length == MaxCapacity; }
}
public void RegisterAttendee(Guid accountID) {
if(HasReachedMaxCapacity) {
throw new Exception("Session has reached max capacity");
}
Attendees.Add(new Attendee(this.ID, accountID));
}
}
public enum RequestStatus { Pendind, WaitingApproval, Approved, Rejected }
public class JoinConferenceSessionRequest {
public Guid ID { get; }
public RequestStatus Status { get; ]
public Guid AccountID{ get; }
public Guid SessionID { get; }
public void Accept() {
Status = RequestStatus.Accepted;
AddEvent(new JoinSessionsRequestAccepted(
this.ID, this.UserID, this.SeesionID));
}
public void Reject() {
Status = RequestStatus.Rejected;
AddEvent(new JoinSessionsRequestRejected(
this.ID, this.UserID, this.SeesionID));
}
public void TransitionToWaitingForApprovalState() {
Status = RequestStatus.WaitingForApproval;
AddEvent(new JoinSessionsRequestWaitingForApproval(
this.ID, this.UserID, this.SeesionID));
}
}
public class RequestToJoinConferenceSessionCommand {
public void Execute(Guid accountID, Guid conferenceSeesionID) {
var request = new JoinConferenceSessionRequest(
accountID, conferenceSessionID);
JoinConferenceSessionRequestRepository.Save(request);
}
}
public class JoinSessionRequestedEventHandler {
public void Handle(JoinSessionRequestedEvent event) {
var request = JoinConferenceSessionRequestRepository
.GetByID(event.RequestID);
bool hasAlreadyRequestedToJoinThisSession =
JoinConferenceSessionRequestRepository
.ExistsForConfernce(accountID, conferenceSessionID);
// CONSTRANT: THE USER CANNOT REQUEST TO JOIN THE
// SAME SESSIONS TWO TIMES, tHIS CAN HAPPEN BECAUSE NO ONE STOPS THE
// USER FROM OPENING TWO BROWSERS/MOBILE APPS OR BROWSER AND MOBILE
if(hasAlreadyRequestedToJoinThisSession) {
request.Reject();
}
else {
var acceptedUserRequestsCount =
JoinConferenceSessionRequestRepository
.GetAcceptedRequestsCountForUser(event.UserID);
// CONSTRAIN: A USER CANNOT JOIN MORE THAN 5 SESSION.
// BECAUSE REQUESTS ARE MADE EXPLICITLY WE CAN COUNT HOW MANY
// ACCEPTED REQUESTS HE HAS AT THIS POINT IN TIME. iF HE HAS
// MORE THAN 5, WE REJECT THE REQUEST
if(acceptedUserRequestsCount > 5) {
request.Reject();
}
else {
request.TransitionToWaitingForApprovalState();
}
}
JoinConferenceSessionRequestRepository.Save(request);
}
}
public class JoinSessionsRequestWaitingForApprovalEventHandler {
public void Handle(JoinSessionsRequestWaitingForApproval event) {
var session = ConferenceSessionRepository.GetByID(event.SessionID);
var account = AccountRepository.GetByID(event.AccountID);
// CONSTRAINT: THE USER CANNOT REGISTER FOR THE SESSION IF IT HAS
// REACHED IT'S CAPACITY. IF IT HAS WE NEED TO PUBLISH AN EVENT TO
// NOTIFY THE REST OF THE SYSTEM FOR THAT
// SO THE REQUEST CAN BE REJECTED
if (session.HasReachedMaxCapacity) {
MessageBus.PublishEvent(
new JoinConferenceSessionRejected(session.ID, account.ID);
}
else {
session.RegisterAttendee(account);
ConferenceSessionRepository.Save(session);
}
}
}
public class JoinConferenceSessionRejectedEventHandler {
public void Handle(JoinConferenceSessionRejectedEvent event) {
var request = ConferenceSessionRequestRepository
.FindForUserAndSession(event.UserID, event.SessionID);
request.Reject();
ConferenceSessionRequestRepository.Save(request);
}
}
public class ConferenceSessionJoinedEventHandler {
public void Handle(ConferenceSessionJoinedEventHandler event) {
var request = ConferenceSessionRequestRepository
.FindForUserAndSession(event.UserID, event.SessionID);
request.Accept();
ConferenceSessionRequestRepository.Save(request);
}
}
Notice in this solution that we use only events. Events are used also for the notifications when a validation fails. We don't throw and handle exceptions. We captured all the things that can happen in a protocol of events that flow in our system.
All entities except Attendee are aggregate roots. Attendee is an entity that is contained in the ConferenceSession aggregate and it's easier to enforce the rule. We also use Optimistic Offline lock implemented in ConcurrentEntity base class. We also use Reference by ID instead of object references.
We record all requests for an account to join session so we can enforce the constraint or having only 5 sessions attended by a person.
Here are some resources that you can check: