Java DDD – Updating Nested Objects in Aggregate: Delegation vs Direct Access

aggregateArchitecturedomain-driven-designjavaobject-oriented-design

The example domain problem

ddd-domain-problem

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

  • Course is a top-level component, AR, describes a generic info about the learning course (name, description)
  • Every Course has ExerciseGroups that describe groups where students can practice. ExerciseGroup holds the name of the group and list of signed-up students and tutors. ExerciseGroup has no sense outside the Course (from the domain), therefore it's under the Course AR.
  • Every ExerciseGroup contains a list of Sessions that describe occurrings of the ExerciseGroup a User can attend. Is defined by (datetimeBegins, datetimeEnds and a set of Attendance objects).
  • Attendance is a VO record that holds info about student attendings. Contains a reference to the User (via Id) and some statistical data.

What approaches I've used so far:

1. Reference by identity outside the AR.

class Group {
    // ...
    Set<UserId> studentIds;
    Set<UserId> tutorIds;
}

2. Direct references inside the AR.

class Course {
    // ....
    List<Group> groups;
}

3. ExerciseGroup/Session mutable operations done only by Course AR.

Following the idea that:

aggregate roots exist to protect the data within them… [you must not]
tear the aggregate roots internal structures and do operations on the
internals directly without incorporating the aggregate root, [since] the
aggregate root can no longer validate the operation is valid.

@david-packer (https://softwareengineering.stackexchange.com/a/354667/169341)

I have the following mutable operations in AR:

class Course {
    ExerciseGroup addExerciseGroup(String exGroupName...);
    void removeExerciseGroup(ExerciseGroupId exGroupId);
    void attendExerciseGroupSession(ExerciseGroupId exGroupId, SessionId sessionId, UserId userId...);
    // etc
}

While still having read-only access to the underling entities:

class Course {
    List<ExerciseGroup> exerciseGroups() {
        // save because all mutable operators in ExerciseGroup 
        // are `protected` and available nobody but Course AR.
        return unmodifiableList(this.exerciseGroups); 
    }
    // etc
}

The problems and questions

  1. What is your overall personal feeling about the chosen domain model? Does it make sense for you? Isn't it too fat?
  2. Should I delegate underlying entities of the AR to mutate its children or put the whole logic in the AR?
    For example, I can delegate ExerciseGroup to ask Session to create Attendance:

    class Course {
        void attendExerciseGroupSession(ExerciseGroupId gId, SessionId sId, UserId uId) {
            this.eGroups.getById(gId).attendSession(sId, uId);
        }
    }
    
    class ExerciseGroup {
        void attendSession(SessionId sessionId, UserId userId) {
            this.sessions.getById(sessionId).attend(userId);
        }
    }
    
    class Session {
        void attend(UserId userId) {
            this.attendances.add(new Attendance(userId, LocalDateTime.now()));
        }
    }
    

    Or I can create Attendance directly in Course AR.

    class Course {
        void attendExerciseGroupSession(ExerciseGroupId exGroupId, SessionId sessionId, UserId userId) {
            this.exerciseGroups.getById(exGroupId)
                .sessions().getById(sessionId)
                    .attendances().add(new Attendance(userId, LocalDateTime.now()));
        }
    }
    

While the first approach does make more sense for me, it became extremely complicated to not forget to "propagate" some methods to the top AR. One change in the Session (e.g. change void in attend(.) to Attendance) requires changes in all top-level classes.

  1. How to cope with deep nested objects in terms of AR method complexity? Let's say there will appear one more object btw Session and AttendanceMeeting, then method in Course may become addExerciseGroupSessionMeetingAttendance(ExerciseGroup gId, SessionId sId, MeetingId mId, AttendanceParams.. o) that is extremely long to me and unclear in terms of (2)
  2. Should addAREntity(EntityParam1,EntityParam1N) return EntityId or it's valid to return the whole Entity (if it can be assured it is unmodifiable outside of the domain e.g. all mutable methods are protected)?
    The problem with returning just EntityId is that usually you'll require the while object right after creating the object.

For example, an endpoint POST /courses/{courseId}/groups/{groupId}/sessions/ is very likely should return a complete Session object (e.g. to get all autogenerated data [id?] not included in DTO given to the endpoint). Receiving a SessionId from Course#addGroupSession(dto.param1(),..) means I need course.group(groupId).session(sessionId) fulfill this requirement.

  1. Will it be ok to return AR's users nested entities if we are sure they are immutable from outside (e.g. all mutable methods are protected).

In other words, is it okay to do course.getGroup(groupId).getSession()(sessionId) from the example above (assuming .group(groupId) and .session(sessionId) return unmodifiable objects), or it's better to have course.getGroupSession(groupId, sessionId)?

Thank you.

A day after (update)

I revisited my model and found it very fat, hard to maintain and designed with false invariants in mind that caused transactional failures. Nothing about creating a new session item should logically interfere with editing a course for example.

What I came up with after deeper transactional analysis is the following model (clickable):
enter image description here

The question I was using to separate boundary context is: can/should this and that in the same transaction? When you Session#attend, should it be done in the same transaction with Course [blocked]? Definitely it's not etc.

To fulfil the condition that session should not be outside the Group, I just put GroupId to the Session's controller and hope for eventual consistency 🙂

Questions left

  1. Will it be ok to return nested entities from an AR if we are sure they are immutable from outside directly (e.g. all mutable methods are protected) [not by AR, the owner of the entity].
    In other words, is ok to do this:

    class Course {
       List<Group> groups;
       List<Group> getGroups() {return Collections.unmodifiableList(groups);}
       public setGroupName(GroupId gi, String name) {
          this.findGroup(gi).setName(name);
       }
    }
    
    class Group {
       String name;
       // Protected so Course#getGroups users won't be able to mutate Group
       protected setName(String name) {
          this.name = name;
       }
    }
    
  2. Should I delegate underlying entities of the AR to mutate its children or put the whole logic in the AR? (see details above, #2 in The problems and questions)

Best Answer

I would turn the model upside down to make the most interesting / behavioral bits your AR.

Based on the info you provided, this is what I would do:

  • Course is a reference entity (not an AR)
  • User is an reference entity
  • Exercise Group is a reference entity (can also record a link between Course and User)
  • Session and/or Attendance are the only concepts with potentially interesting behavior
    • All other entities are only interesting because of their interaction within a Session or Attendance

Attendance would contain references (ids) of Session and User, and maybe other stats. You can discover which course the users attended by following the session -> exercise group -> course relationship.

Domain question: Is it necessary that Users only attend sessions for which they were in the exercise group? Would the people in the session turn away a person that showed up, even though they were not in the exercise group?

You could say that certain configuration changes such as joining and leaving groups could be interesting behavior. It depends on what the goals are.

Also keep in mind that your domain (write-side) model does not have to be the same as your read model. Trying to mix query and domain concerns can have you running in circles. As much as possible, let the domain just be concerned with modeling the business problem. If necessary copy the domain data into other forms that are easier to query against. E.g. database triggers, ETL jobs, code that runs on save, etc.

Many of the questions you posted would not really apply with what I said above, so I didn't answer them directly. Feel free to ask any other questions you have about my answer in comments.