From 2d6c08e14a47a1ad838c281ab888b3bb3553fc20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Eduardo=20Garc=C3=ADa=20M=C3=A1rquez?= Date: Wed, 7 Jan 2026 22:59:10 -0500 Subject: [PATCH] feat(domain): add core domain layer Entities: - Student: core entity with email validation - Subject: course with credits (3 each) - Professor: instructor managing 2 subjects - Enrollment: student-subject relationship Value Objects: - Email: validated email with domain rules Domain Services: - EnrollmentDomainService: validates business rules - Max 3 subjects per student (9 credits) - No duplicate professor constraint Ports: - Repository interfaces for dependency inversion --- src/backend/Domain/Domain.csproj | 9 +++ src/backend/Domain/Entities/Enrollment.cs | 20 +++++++ src/backend/Domain/Entities/Professor.cs | 28 ++++++++++ src/backend/Domain/Entities/Student.cs | 56 +++++++++++++++++++ src/backend/Domain/Entities/Subject.cs | 27 +++++++++ .../Domain/Exceptions/DomainException.cs | 47 ++++++++++++++++ .../Repositories/IEnrollmentRepository.cs | 16 ++++++ .../Repositories/IProfessorRepository.cs | 16 ++++++ .../Ports/Repositories/IStudentRepository.cs | 34 +++++++++++ .../Ports/Repositories/ISubjectRepository.cs | 18 ++++++ .../Domain/Ports/Repositories/IUnitOfWork.cs | 6 ++ .../Services/EnrollmentDomainService.cs | 29 ++++++++++ src/backend/Domain/ValueObjects/Email.cs | 32 +++++++++++ 13 files changed, 338 insertions(+) create mode 100644 src/backend/Domain/Domain.csproj create mode 100644 src/backend/Domain/Entities/Enrollment.cs create mode 100644 src/backend/Domain/Entities/Professor.cs create mode 100644 src/backend/Domain/Entities/Student.cs create mode 100644 src/backend/Domain/Entities/Subject.cs create mode 100644 src/backend/Domain/Exceptions/DomainException.cs create mode 100644 src/backend/Domain/Ports/Repositories/IEnrollmentRepository.cs create mode 100644 src/backend/Domain/Ports/Repositories/IProfessorRepository.cs create mode 100644 src/backend/Domain/Ports/Repositories/IStudentRepository.cs create mode 100644 src/backend/Domain/Ports/Repositories/ISubjectRepository.cs create mode 100644 src/backend/Domain/Ports/Repositories/IUnitOfWork.cs create mode 100644 src/backend/Domain/Services/EnrollmentDomainService.cs create mode 100644 src/backend/Domain/ValueObjects/Email.cs diff --git a/src/backend/Domain/Domain.csproj b/src/backend/Domain/Domain.csproj new file mode 100644 index 0000000..93f5ab4 --- /dev/null +++ b/src/backend/Domain/Domain.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/src/backend/Domain/Entities/Enrollment.cs b/src/backend/Domain/Entities/Enrollment.cs new file mode 100644 index 0000000..43e2306 --- /dev/null +++ b/src/backend/Domain/Entities/Enrollment.cs @@ -0,0 +1,20 @@ +namespace Domain.Entities; + +public class Enrollment +{ + public int Id { get; private set; } + public int StudentId { get; private set; } + public Student Student { get; private set; } = null!; + public int SubjectId { get; private set; } + public Subject Subject { get; private set; } = null!; + public DateTime EnrolledAt { get; private set; } + + private Enrollment() { } + + public Enrollment(int studentId, int subjectId) + { + StudentId = studentId; + SubjectId = subjectId; + EnrolledAt = DateTime.UtcNow; + } +} diff --git a/src/backend/Domain/Entities/Professor.cs b/src/backend/Domain/Entities/Professor.cs new file mode 100644 index 0000000..c00fe28 --- /dev/null +++ b/src/backend/Domain/Entities/Professor.cs @@ -0,0 +1,28 @@ +namespace Domain.Entities; + +public class Professor +{ + public int Id { get; private set; } + public string Name { get; private set; } = string.Empty; + + private readonly List _subjects = []; + public IReadOnlyCollection Subjects => _subjects.AsReadOnly(); + + private Professor() { } + + public Professor(string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Professor name is required", nameof(name)); + + Name = name; + } + + public void AddSubject(Subject subject) + { + if (_subjects.Count >= 2) + throw new InvalidOperationException("A professor can only teach 2 subjects"); + + _subjects.Add(subject); + } +} diff --git a/src/backend/Domain/Entities/Student.cs b/src/backend/Domain/Entities/Student.cs new file mode 100644 index 0000000..d7b63a9 --- /dev/null +++ b/src/backend/Domain/Entities/Student.cs @@ -0,0 +1,56 @@ +namespace Domain.Entities; + +using Domain.ValueObjects; + +public class Student +{ + public const int MaxEnrollments = 3; + + public int Id { get; private set; } + public string Name { get; private set; } = string.Empty; + public Email Email { get; private set; } = null!; + + private readonly List _enrollments = []; + public IReadOnlyCollection Enrollments => _enrollments.AsReadOnly(); + + public int TotalCredits => _enrollments.Sum(e => e.Subject?.Credits ?? Subject.CreditsPerSubject); + + private Student() { } + + public Student(string name, Email email) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Student name is required", nameof(name)); + + Name = name; + Email = email ?? throw new ArgumentNullException(nameof(email)); + } + + public void UpdateName(string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Student name is required", nameof(name)); + + Name = name; + } + + public void UpdateEmail(Email email) + { + Email = email ?? throw new ArgumentNullException(nameof(email)); + } + + public bool HasProfessor(int professorId) => + _enrollments.Any(e => e.Subject?.ProfessorId == professorId); + + public bool CanEnroll() => _enrollments.Count < MaxEnrollments; + + public void AddEnrollment(Enrollment enrollment) + { + _enrollments.Add(enrollment); + } + + public void RemoveEnrollment(Enrollment enrollment) + { + _enrollments.Remove(enrollment); + } +} diff --git a/src/backend/Domain/Entities/Subject.cs b/src/backend/Domain/Entities/Subject.cs new file mode 100644 index 0000000..02c82e2 --- /dev/null +++ b/src/backend/Domain/Entities/Subject.cs @@ -0,0 +1,27 @@ +namespace Domain.Entities; + +public class Subject +{ + public const int CreditsPerSubject = 3; + + public int Id { get; private set; } + public string Name { get; private set; } = string.Empty; + public int Credits { get; private set; } = CreditsPerSubject; + public int ProfessorId { get; private set; } + public Professor Professor { get; private set; } = null!; + + private readonly List _enrollments = []; + public IReadOnlyCollection Enrollments => _enrollments.AsReadOnly(); + + private Subject() { } + + public Subject(string name, int professorId) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Subject name is required", nameof(name)); + + Name = name; + Credits = CreditsPerSubject; + ProfessorId = professorId; + } +} diff --git a/src/backend/Domain/Exceptions/DomainException.cs b/src/backend/Domain/Exceptions/DomainException.cs new file mode 100644 index 0000000..dfcd94e --- /dev/null +++ b/src/backend/Domain/Exceptions/DomainException.cs @@ -0,0 +1,47 @@ +namespace Domain.Exceptions; + +public abstract class DomainException : Exception +{ + public string Code { get; } + + protected DomainException(string code, string message) : base(message) + { + Code = code; + } +} + +public class MaxEnrollmentsExceededException : DomainException +{ + public MaxEnrollmentsExceededException(int studentId) + : base("MAX_ENROLLMENTS", $"Student {studentId} cannot enroll in more than 3 subjects") { } +} + +public class SameProfessorConstraintException : DomainException +{ + public SameProfessorConstraintException(int studentId, int professorId) + : base("SAME_PROFESSOR", $"Student {studentId} already has a subject with professor {professorId}") { } +} + +public class StudentNotFoundException : DomainException +{ + public StudentNotFoundException(int studentId) + : base("STUDENT_NOT_FOUND", $"Student {studentId} was not found") { } +} + +public class SubjectNotFoundException : DomainException +{ + public SubjectNotFoundException(int subjectId) + : base("SUBJECT_NOT_FOUND", $"Subject {subjectId} was not found") { } +} + +public class EnrollmentNotFoundException : DomainException +{ + public EnrollmentNotFoundException(int enrollmentId) + : base("ENROLLMENT_NOT_FOUND", $"Enrollment {enrollmentId} was not found") { } +} + +public class DuplicateEnrollmentException : DomainException +{ + public DuplicateEnrollmentException(int studentId, int subjectId) + : base("DUPLICATE_ENROLLMENT", $"Student {studentId} is already enrolled in subject {subjectId}") { } +} diff --git a/src/backend/Domain/Ports/Repositories/IEnrollmentRepository.cs b/src/backend/Domain/Ports/Repositories/IEnrollmentRepository.cs new file mode 100644 index 0000000..71af85f --- /dev/null +++ b/src/backend/Domain/Ports/Repositories/IEnrollmentRepository.cs @@ -0,0 +1,16 @@ +namespace Domain.Ports.Repositories; + +using Domain.Entities; + +public interface IEnrollmentRepository +{ + Task GetByIdAsync(int id, CancellationToken ct = default); + Task GetByStudentAndSubjectAsync(int studentId, int subjectId, CancellationToken ct = default); + Task> GetByStudentIdAsync(int studentId, CancellationToken ct = default); + Task> GetBySubjectIdAsync(int subjectId, CancellationToken ct = default); + Task> GetClassmatesAsync(int studentId, int subjectId, CancellationToken ct = default); + Task>> GetClassmatesBatchAsync( + int studentId, IEnumerable subjectIds, CancellationToken ct = default); + void Add(Enrollment enrollment); + void Delete(Enrollment enrollment); +} diff --git a/src/backend/Domain/Ports/Repositories/IProfessorRepository.cs b/src/backend/Domain/Ports/Repositories/IProfessorRepository.cs new file mode 100644 index 0000000..4aac2e6 --- /dev/null +++ b/src/backend/Domain/Ports/Repositories/IProfessorRepository.cs @@ -0,0 +1,16 @@ +namespace Domain.Ports.Repositories; + +using Domain.Entities; +using System.Linq.Expressions; + +public interface IProfessorRepository +{ + Task GetByIdAsync(int id, CancellationToken ct = default); + Task> GetAllAsync(CancellationToken ct = default); + Task> GetAllWithSubjectsAsync(CancellationToken ct = default); + + // Projection support + Task> GetAllProjectedAsync( + Expression> selector, + CancellationToken ct = default); +} diff --git a/src/backend/Domain/Ports/Repositories/IStudentRepository.cs b/src/backend/Domain/Ports/Repositories/IStudentRepository.cs new file mode 100644 index 0000000..e58c4ab --- /dev/null +++ b/src/backend/Domain/Ports/Repositories/IStudentRepository.cs @@ -0,0 +1,34 @@ +namespace Domain.Ports.Repositories; + +using Domain.Entities; +using System.Linq.Expressions; + +public interface IStudentRepository +{ + Task GetByIdAsync(int id, CancellationToken ct = default); + Task GetByIdWithEnrollmentsAsync(int id, CancellationToken ct = default); + Task> GetAllAsync(CancellationToken ct = default); + Task> GetAllWithEnrollmentsAsync(CancellationToken ct = default); + Task ExistsAsync(int id, CancellationToken ct = default); + Task EmailExistsAsync(string email, int? excludeId = null, CancellationToken ct = default); + + // Projection support for optimized queries + Task> GetAllProjectedAsync( + Expression> selector, + CancellationToken ct = default); + Task GetByIdProjectedAsync( + int id, + Expression> selector, + CancellationToken ct = default); + + // Keyset pagination (more efficient than OFFSET) + Task<(IReadOnlyList Items, int? NextCursor, int TotalCount)> GetPagedProjectedAsync( + Expression> selector, + int? afterId = null, + int pageSize = 10, + CancellationToken ct = default); + + void Add(Student student); + void Update(Student student); + void Delete(Student student); +} diff --git a/src/backend/Domain/Ports/Repositories/ISubjectRepository.cs b/src/backend/Domain/Ports/Repositories/ISubjectRepository.cs new file mode 100644 index 0000000..b62ac65 --- /dev/null +++ b/src/backend/Domain/Ports/Repositories/ISubjectRepository.cs @@ -0,0 +1,18 @@ +namespace Domain.Ports.Repositories; + +using Domain.Entities; +using System.Linq.Expressions; + +public interface ISubjectRepository +{ + Task GetByIdAsync(int id, CancellationToken ct = default); + Task GetByIdWithProfessorAsync(int id, CancellationToken ct = default); + Task> GetAllAsync(CancellationToken ct = default); + Task> GetAllWithProfessorsAsync(CancellationToken ct = default); + Task> GetAvailableForStudentAsync(int studentId, CancellationToken ct = default); + + // Projection support + Task> GetAllProjectedAsync( + Expression> selector, + CancellationToken ct = default); +} diff --git a/src/backend/Domain/Ports/Repositories/IUnitOfWork.cs b/src/backend/Domain/Ports/Repositories/IUnitOfWork.cs new file mode 100644 index 0000000..5c752b1 --- /dev/null +++ b/src/backend/Domain/Ports/Repositories/IUnitOfWork.cs @@ -0,0 +1,6 @@ +namespace Domain.Ports.Repositories; + +public interface IUnitOfWork +{ + Task SaveChangesAsync(CancellationToken ct = default); +} diff --git a/src/backend/Domain/Services/EnrollmentDomainService.cs b/src/backend/Domain/Services/EnrollmentDomainService.cs new file mode 100644 index 0000000..22cc7f2 --- /dev/null +++ b/src/backend/Domain/Services/EnrollmentDomainService.cs @@ -0,0 +1,29 @@ +namespace Domain.Services; + +using Domain.Entities; +using Domain.Exceptions; + +public class EnrollmentDomainService +{ + public void ValidateEnrollment(Student student, Subject subject) + { + if (!student.CanEnroll()) + throw new MaxEnrollmentsExceededException(student.Id); + + if (student.HasProfessor(subject.ProfessorId)) + throw new SameProfessorConstraintException(student.Id, subject.ProfessorId); + + if (student.Enrollments.Any(e => e.SubjectId == subject.Id)) + throw new DuplicateEnrollmentException(student.Id, subject.Id); + } + + public Enrollment CreateEnrollment(Student student, Subject subject) + { + ValidateEnrollment(student, subject); + + var enrollment = new Enrollment(student.Id, subject.Id); + student.AddEnrollment(enrollment); + + return enrollment; + } +} diff --git a/src/backend/Domain/ValueObjects/Email.cs b/src/backend/Domain/ValueObjects/Email.cs new file mode 100644 index 0000000..74c565f --- /dev/null +++ b/src/backend/Domain/ValueObjects/Email.cs @@ -0,0 +1,32 @@ +namespace Domain.ValueObjects; + +using System.Text.RegularExpressions; + +public partial class Email +{ + public string Value { get; } + + private Email(string value) => Value = value; + + public static Email Create(string email) + { + if (string.IsNullOrWhiteSpace(email)) + throw new ArgumentException("Email is required", nameof(email)); + + email = email.Trim().ToLowerInvariant(); + + if (!EmailRegex().IsMatch(email)) + throw new ArgumentException("Invalid email format", nameof(email)); + + return new Email(email); + } + + public override string ToString() => Value; + public override bool Equals(object? obj) => obj is Email other && Value == other.Value; + public override int GetHashCode() => Value.GetHashCode(); + + public static implicit operator string(Email email) => email.Value; + + [GeneratedRegex(@"^[^@\s]+@[^@\s]+\.[^@\s]+$", RegexOptions.Compiled)] + private static partial Regex EmailRegex(); +}