Architecture – Balance between aggregate boundaries and domain consistency in DDD

aggregateArchitecturedesign-patternsdomain-driven-designobject-oriented-design

Designing an Aggregate and choosing an Aggregate Root getting tricky for me all the time especially when it comes to ensuring right transactional contexts and consistency constraints so I'm wondering whether there are any practices that can make it easier.

By example

There is a student attendance tracking system that keeps records of student attendances of groups.

enter image description here

  1. Course is a top-level component, describes a generic info about the learning course (name, description)
  2. Every Course has Groups that describe groups a User must sign up to if he/she wants to attend Group Meeting.
  3. Every Group contains a list of Meetings that describe occurrings of the Group a User can attend. Is defined by (datetimeBegins, datetimeEnds and a set of Attendance objects).
  4. Attendance is a record holds info about a student attending. Contains a reference to the User and some statistical data.

Iteration #1

It seemed a good idea to design Aggregate boundaries to match the taxonomy above exactly, where the main pillar was the Course Aggregate Root:

enter image description here

It is nice because:

  1. Matches the domain taxonomy exactly
  2. Ensures domain constraints like "Groups cannot be created outside the Course" or "Meetings must be defined inside a Group".

But at the same time, it's very fat, hard to maintain and designed with false invariants in mind that caused transactional failures: nothing about creating a new Meeting item should logically interfere with editing/creating a course for example. It just doesn't scale. Moreover, usually, you may want to update Group by adding some Sessions having GroupId and it should not require fetching the whole Course.

Iteration #2

Then I came up with an idea to separate all main concepts into different ARs:
enter image description here

After all, I have not problems with transactions, but, unfortunately, it added more questions than solved problems:

  1. Since Group and Meeting are separate AR now, how to ensure nobody
    will create "deattached" from Course Group or Meeting outside the
    Group via their own repositories?
  2. Having Meeting#attend(UserId userId) method, how to ensure the user is eligible (signed up for the Group) for attending this meeting?

Ideas for the Iteration #3

enter image description here

I'm thinking about moving Meeting inside the Group AR, where I can put Group#attend(UserId userId) method. But there is still a problem of ensuring the Group is created inside the Course only.

I was thinking about hiding (package private'ing/protected'ing) Group constructor and adding Course#createGroup(GroupParams p): Group but I'm not sure if it's valid to mess two different concepts (Group and Course aggregate roots) in each other.

Moreover, it doesn't solve the problem users still can remove Group via its repository (a Spring's Repository#delete(Group) in my case). I possibly can solve this by:

  • having domain event sourcing i.e. Group repository will emit GroupRemovedEvent so the Course can subscribe to it and modify its List<GroupId> groupIds.
  • having a reverse reference from Group to Course, but it may become inconvenient because use case get all groups of the course by CourseId given is a day-to-day operation.

I would appreciate any ideas on this matter. Thank you.

Best Answer

As far as I know there are two things your should consider by choosing aggregates.

  1. Invariants: For example you have 2 properties x and y and the sum of them must be always 6. Obviously you cannot change them independently, because you would end up in invalid states. After identifying your invariants you will know you consistency boundaries better.
  2. How much data you want to load into memory: For example if your aggregate contains a massive list of entities, you need to load all of them you want to modify it, because otherwise it is hard to ensure that all of the invariants are fulfilled. In theory you can lazy load your related entities, so if there is no invariant trying to use them, then they won't be loaded, but still it might be better to add another aggregate for those entities and refer to them only with ids in your first aggregate.