I wonder how to build correct hierarchy and where to cover invariants. There are following entities: Faculty, Course, Student, Teacher. Faculty consists of Courses. Course cannot exist without Faculty. Also, Course cannot exist without Teacher. One Teacher can have many Courses. One Student can have many Courses, and one Course can have many Students. Invariants:
1 Course can have a maximum of 30 Students. 1 Student can have a maximum of 10 Courses. 1 Faculty can have a maximum of 20 Courses. 1 Teacher can have a maximum of 100 Students who study on his Courses. (There is no direct connection between Teacher and Students, since students study on different Courses taught by Teacher)
I tried following solution.
public class Faculty : Entity
{
public Guid Id { get; private set; }
public string Name { get; private set; }
private readonly List<Course> _courses = new();
public IReadOnlyCollection<Course> Courses => _courses.AsReadOnly();
private const int MaxCourses = 20;
public Faculty(string name)
{
Id = Guid.NewGuid();
Name = name;
}
public Course AddCourse(string courseName, Teacher teacher)
{
if (_courses.Count >= MaxCourses)
throw new InvalidOperationException("A Faculty can have a maximum of 20 courses.");
var course = new Course(courseName, this, teacher);
_courses.Add(course);
teacher.AssignCourse(course);
return course;
}
}
public class Course : Entity
{
public Guid Id { get; private set; }
public string Name { get; private set; }
public Faculty Faculty { get; private set; }
public Teacher Teacher { get; private set; }
private readonly List<Student> _students = new();
public IReadOnlyCollection<Student> Students => _students.AsReadOnly();
private const int MaxStudents = 30;
public Course(string name, Faculty faculty, Teacher teacher)
{
Id = Guid.NewGuid();
Name = name;
Faculty = faculty ?? throw new ArgumentNullException(nameof(faculty));
Teacher = teacher ?? throw new ArgumentNullException(nameof(teacher));
}
public void EnrollStudent(Student student)
{
if (_students.Count >= MaxStudents)
throw new InvalidOperationException("Course cannot have more than 30 students.");
_students.Add(student);
Teacher.EnsureStudentLimit();
}
}
public class Teacher : Entity
{
public Guid Id { get; private set; }
public string Name { get; private set; }
private readonly List<Course> _courses = new();
public IReadOnlyCollection<Course> Courses => _courses.AsReadOnly();
private const int MaxStudentsAcrossCourses = 100;
public Teacher(string name)
{
Id = Guid.NewGuid();
Name = name;
}
public void AssignCourse(Course course)
{
_courses.Add(course);
}
public void EnsureStudentLimit()
{
var totalStudents = _courses.SelectMany(c => c.Students).Distinct().Count();
if (totalStudents > MaxStudentsAcrossCourses)
throw new InvalidOperationException("A teacher cannot have more than 100 students across their courses.");
}
}
public class Student : Entity
{
public Guid Id { get; private set; }
public string Name { get; private set; }
private readonly List<CourseEnrollment> _enrollments = new();
public IReadOnlyCollection<CourseEnrollment> Enrollments => _enrollments.AsReadOnly();
private const int MaxCourses = 10;
public Student(string name)
{
Id = Guid.NewGuid();
Name = name;
}
public void EnrollInCourse(Course course)
{
if (_enrollments.Count >= MaxCourses)
throw new InvalidOperationException("A student cannot enroll in more than 10 courses.");
var enrollment = new CourseEnrollment(this, course);
_enrollments.Add(enrollment);
course.EnrollStudent(this);
}
}
public class CourseEnrollment : Entity
{
public Guid Id { get; private set; }
public Student Student { get; private set; }
public Course Course { get; private set; }
public CourseEnrollment(Student student, Course course)
{
Id = Guid.NewGuid();
Student = student ?? throw new ArgumentNullException(nameof(student));
Course = course ?? throw new ArgumentNullException(nameof(course));
}
}
The problem here is that it breaks DDD rules about aggregates. For example: Teacher shouldn't have links to Courses as they are part of Faculty aggregate. Course shouldn't have collection of Students, as interaction with other aggregation roots should be performed via Ids, not direct links. What exactly should I do with this to cover all the invariants? OK I could move logic to some domain event handler or service. How would I cover Teacher invariants in that service? DDD says that we can have repositories only for aggregates. Teacher can have a limit amount of students, that study in different courses with the same teacher. But courses are part of Faculty aggregate. So which repository should I use to get this info.
I tried following solution.
Yeah. It turns out that trying to model domains using nouns isn't all that effective, because it has a tendency to clump a bunch of unrelated concerns/information together.
Often a more useful approach is to start from thinking about processes and information. What we are really doing is storing and manipulating information; getting the domain model "right" begins with understanding the information and choosing the correct organization of that information to meet your needs.
Here, your "invariants" are really relying on two histories
Which in turn implies that your information wants to be organized into two data structures:
And the job of your domain model is ensuring that edits to these two collections satisfy all of your rules.
class EnrollmentProcessing {
// For various reasons, you will almost certainly
// want these to be collections of _values_, rather
// than collections of _entities_.
List<TeachingAssignments> _teachingAssignments;
List<StudentEnrollments> _studentEnrollments;
void assignInstructor(TeacherId teacher, CourseId course) {
// ...
}
void enrollStudent(StudentId student, CourseId course) {
// ...
}
// there are probably other methods you need....
}
Depending on the scale of the problem you are working with, it might make sense to have a single "enrollment processing" instance for everything; or it might be that you partition the work and have an EnrollmentProcessing instance for each enrollment period at each schoool -- lots of possibilities depending on how much scaling you need.
You could write the API in terms of entities....
class EnrollmentProcessing {
void assignInstructor(Teacher teacher, Course course) {
// ...
}
void enrollStudent(Student student, Course course) {
// ...
}
}
... but that does increase the coupling between enrollment processing and the other parts of your domain model, so you will want to make sure that the benefits that accrue outweigh the costs.
In a perfect world, "EnrollmentProcessing" would probably be named for the document/ledger/registry that keeps track of this information.