0

I have the following entities:

> ConferenceSession : Entity
> - string Code 
> - VenueId
> 
> Venue : Entity
> - int MaxCapacity
> 
> Attendee : Entity

The business requirement is an attendee can register to one or more conference sessions, and vice versa a conference session can have one or more attendees.

the following Constraints must reject the attendee registration:

  • Attendee cannot register more than 5 conference session
  • Conference session cannot have total of attendees exceeding max capicity

Which one should be the aggregate root ? how do i perform the above domain constraints since both conferenceSession needs to link attendee, and attendee needs to link conferenceSession ?

I know the possible similar question was asked here (many to many relationship in ddd) but it does not have any constraints which makes it possible to have 1-to-many.

So far i have come up with the following:

Class Attendee : AggreateRoot
{
     Registration[] Registrations { get; }
     void Register(ConferenceSession session){
          if (this.Registrations.Count >= 5){ throw domainexception; }
          if (!session.CanRegister()){ throw domainexception; }

          // Do i do "Registrations.Add(new Registration)" here ? what about the Registrations in ConferenceSession ? 
     }
}

Class ConferenceSession : Entity
{
     Registration[] Registrations { get; }
     int Capacity { get; }

     bool CanRegister()
     {
          return this.Registrations.Count < this.Capacity;
     }
}

Class Registration : Entity
{
     Registration(ConferenceSession session, Attendee attendee)
     {
         this.Session = session;
         this.Attendee = attendee; 
     }

     ConferenceSession Session {get;}
     Attendee Attendee {get;}


}
bet
  • 962
  • 1
  • 9
  • 22

1 Answers1

0

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:

expandable
  • 2,180
  • 7
  • 15
  • thanks but your solution is doing one way validation which means only the session perform business validation to its maximum capacity. what about the attendee /or account not allowed to register more than 5 sessions ? i cannot see this validation in your solution. – bet Jun 13 '19 at 00:57
  • `JoinSessionRequestedEventHandler` enforces this constrain on this line `if(acceptedUserRequestsCount > MaxRequestsCount) { request.Reject() }` where `MaxRequestsCount `can be 5 in this case – expandable Jun 13 '19 at 05:43
  • if(acceptedUserRequestsCount > MaxRequestsCount) is to check if the session has reached its max capacity , right ? remember i have 2 constraints, the session cannot exceed its capacity AND an attendee cannot register more than 5 sessions – bet Jun 13 '19 at 22:02
  • I guess this part is confusing. This check is to enforce the constraint that an attendee cannot register for more than 5 sessions. The idea is that a `user` makes requests `JoinConferenceSessionRequest` for each session. If he/she has 5 requests that are accepted, that means that he has joined 5 sessions. This code `JoinConferenceSessionRequestRepository.GetAcceptedRequestsCountForUser` will count how many requests are accepted. We don't use `ConferenceSession` to count how many of those he has already joined to enforce this constrain. This allows us to enforce it based on his requests only. – expandable Jun 14 '19 at 12:52
  • I'll change the code and add comments to make things more explicit. Probably using `DomainEvents` as a mechanism to notify for error from aggregates can be confusing. I'll move checks in event/command hadnlers. – expandable Jun 15 '19 at 17:06