diff --git a/src/backend/Adapters/Driven/Persistence/Repositories/EnrollmentRepository.cs b/src/backend/Adapters/Driven/Persistence/Repositories/EnrollmentRepository.cs
index f0d5c36..6901d62 100644
--- a/src/backend/Adapters/Driven/Persistence/Repositories/EnrollmentRepository.cs
+++ b/src/backend/Adapters/Driven/Persistence/Repositories/EnrollmentRepository.cs
@@ -3,8 +3,12 @@ namespace Adapters.Driven.Persistence.Repositories;
using Adapters.Driven.Persistence.Context;
using Domain.Entities;
using Domain.Ports.Repositories;
+using Domain.ReadModels;
using Microsoft.EntityFrameworkCore;
+///
+/// EF Core implementation of .
+///
public class EnrollmentRepository(AppDbContext context) : IEnrollmentRepository
{
public async Task GetByIdAsync(int id, CancellationToken ct = default) =>
@@ -29,45 +33,32 @@ public class EnrollmentRepository(AppDbContext context) : IEnrollmentRepository
.AsNoTracking()
.ToListAsync(ct);
- public async Task> GetClassmatesAsync(int studentId, int subjectId, CancellationToken ct = default) =>
+ public async Task> GetClassmatesAsync(int studentId, int subjectId, CancellationToken ct = default) =>
await context.Enrollments
.Where(e => e.SubjectId == subjectId && e.StudentId != studentId)
- .Select(e => e.Student)
- .AsNoTracking()
+ .Select(e => new ClassmateInfo(e.Student.Id, e.Student.Name))
.ToListAsync(ct);
- public async Task>> GetClassmatesBatchAsync(
+ public async Task>> GetClassmatesBatchAsync(
int studentId, IEnumerable subjectIds, CancellationToken ct = default)
{
var subjectIdList = subjectIds.ToList();
if (subjectIdList.Count == 0)
- return new Dictionary>();
+ return new Dictionary>();
- // Single query to get all classmates for all subjects
+ // Single query to get all classmates for all subjects - projects directly to ClassmateInfo
+ // Note: AsNoTracking is not needed for projections (Select) as they're not tracked by default
var enrollments = await context.Enrollments
.Where(e => subjectIdList.Contains(e.SubjectId) && e.StudentId != studentId)
- .Select(e => new { e.SubjectId, e.Student.Id, e.Student.Name })
- .AsNoTracking()
+ .Select(e => new { e.SubjectId, Classmate = new ClassmateInfo(e.Student.Id, e.Student.Name) })
.ToListAsync(ct);
- // Group by SubjectId and project to Student (minimal data needed)
+ // Group by SubjectId
return enrollments
.GroupBy(e => e.SubjectId)
.ToDictionary(
g => g.Key,
- g => (IReadOnlyList)g
- .Select(e => CreateMinimalStudent(e.Id, e.Name))
- .ToList());
- }
-
- private static Student CreateMinimalStudent(int id, string name)
- {
- // Use reflection to set Id since it's private set
- var student = (Student)System.Runtime.CompilerServices.RuntimeHelpers
- .GetUninitializedObject(typeof(Student));
- typeof(Student).GetProperty("Id")!.SetValue(student, id);
- typeof(Student).GetProperty("Name")!.SetValue(student, name);
- return student;
+ g => (IReadOnlyList)g.Select(e => e.Classmate).ToList());
}
public void Add(Enrollment enrollment) => context.Enrollments.Add(enrollment);
diff --git a/src/backend/Application/Common/ValidationPatterns.cs b/src/backend/Application/Common/ValidationPatterns.cs
new file mode 100644
index 0000000..7ca41b8
--- /dev/null
+++ b/src/backend/Application/Common/ValidationPatterns.cs
@@ -0,0 +1,69 @@
+namespace Application.Common;
+
+using System.Text.RegularExpressions;
+
+///
+/// Shared validation patterns and constants for input validation.
+/// Used across validators to ensure consistency and DRY principle.
+///
+public static partial class ValidationPatterns
+{
+ #region Name Validation
+
+ ///
+ /// Minimum length for student/person names.
+ ///
+ public const int MinNameLength = 3;
+
+ ///
+ /// Maximum length for student/person names.
+ ///
+ public const int MaxNameLength = 100;
+
+ ///
+ /// Pattern for valid name characters.
+ /// Allows: Unicode letters, spaces, hyphens, apostrophes, and periods.
+ /// Examples: "Juan Pérez", "María O'Brien", "Jean-Pierre"
+ ///
+ [GeneratedRegex(@"^[\p{L}\s\-'\.]+$", RegexOptions.Compiled)]
+ public static partial Regex SafeNamePattern();
+
+ #endregion
+
+ #region Email Validation
+
+ ///
+ /// Maximum length for email addresses (RFC 5321).
+ ///
+ public const int MaxEmailLength = 254;
+
+ #endregion
+
+ #region Security Patterns
+
+ ///
+ /// Pattern to detect potentially dangerous content (XSS, script injection).
+ /// Matches: HTML tags, javascript: protocol, event handlers (onclick, onload, etc.)
+ ///
+ [GeneratedRegex(@"<[^>]*>|javascript:|on\w+=", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
+ public static partial Regex DangerousContentPattern();
+
+ ///
+ /// Checks if a string contains potentially dangerous content.
+ ///
+ /// The string to check.
+ /// True if the content is safe (no dangerous patterns found).
+ public static bool IsSafeContent(string? value) =>
+ string.IsNullOrEmpty(value) || !DangerousContentPattern().IsMatch(value);
+
+ #endregion
+
+ #region ID Validation
+
+ ///
+ /// Error message for invalid entity ID.
+ ///
+ public const string InvalidIdMessage = "ID must be greater than 0";
+
+ #endregion
+}
diff --git a/src/backend/Application/Enrollments/Commands/EnrollStudentValidator.cs b/src/backend/Application/Enrollments/Commands/EnrollStudentValidator.cs
index 3fac178..9aa1312 100644
--- a/src/backend/Application/Enrollments/Commands/EnrollStudentValidator.cs
+++ b/src/backend/Application/Enrollments/Commands/EnrollStudentValidator.cs
@@ -1,24 +1,33 @@
namespace Application.Enrollments.Commands;
+using Application.Common;
using FluentValidation;
+///
+/// Validator for .
+/// Validates that student and subject IDs are valid.
+///
public class EnrollStudentValidator : AbstractValidator
{
public EnrollStudentValidator()
{
RuleFor(x => x.StudentId)
- .GreaterThan(0).WithMessage("Invalid student ID");
+ .GreaterThan(0).WithMessage(ValidationPatterns.InvalidIdMessage);
RuleFor(x => x.SubjectId)
- .GreaterThan(0).WithMessage("Invalid subject ID");
+ .GreaterThan(0).WithMessage(ValidationPatterns.InvalidIdMessage);
}
}
+///
+/// Validator for .
+/// Validates that enrollment ID is valid.
+///
public class UnenrollStudentValidator : AbstractValidator
{
public UnenrollStudentValidator()
{
RuleFor(x => x.EnrollmentId)
- .GreaterThan(0).WithMessage("Invalid enrollment ID");
+ .GreaterThan(0).WithMessage(ValidationPatterns.InvalidIdMessage);
}
}
diff --git a/src/backend/Application/Enrollments/Queries/GetClassmatesQuery.cs b/src/backend/Application/Enrollments/Queries/GetClassmatesQuery.cs
index 1b7ae3f..910f2d4 100644
--- a/src/backend/Application/Enrollments/Queries/GetClassmatesQuery.cs
+++ b/src/backend/Application/Enrollments/Queries/GetClassmatesQuery.cs
@@ -5,8 +5,16 @@ using Domain.Exceptions;
using Domain.Ports.Repositories;
using MediatR;
+///
+/// Query to retrieve classmates for all subjects a student is enrolled in.
+///
+/// The student's unique identifier.
public record GetClassmatesQuery(int StudentId) : IRequest>;
+///
+/// Handler for .
+/// Uses batch query to avoid N+1 problem.
+///
public class GetClassmatesHandler(
IStudentRepository studentRepository,
IEnrollmentRepository enrollmentRepository)
@@ -31,7 +39,7 @@ public class GetClassmatesHandler(
enrollment.SubjectId,
enrollment.Subject?.Name ?? "",
classmatesBySubject.TryGetValue(enrollment.SubjectId, out var classmates)
- ? classmates.Select(c => new ClassmateDto(c.Id, c.Name)).ToList()
+ ? classmates.Select(c => new ClassmateDto(c.StudentId, c.Name)).ToList()
: []
)).ToList();
}
diff --git a/src/backend/Application/Enrollments/Queries/GetClassmatesValidator.cs b/src/backend/Application/Enrollments/Queries/GetClassmatesValidator.cs
new file mode 100644
index 0000000..24dfce6
--- /dev/null
+++ b/src/backend/Application/Enrollments/Queries/GetClassmatesValidator.cs
@@ -0,0 +1,17 @@
+namespace Application.Enrollments.Queries;
+
+using Application.Common;
+using FluentValidation;
+
+///
+/// Validator for .
+/// Ensures the student ID is valid before querying classmates.
+///
+public class GetClassmatesValidator : AbstractValidator
+{
+ public GetClassmatesValidator()
+ {
+ RuleFor(x => x.StudentId)
+ .GreaterThan(0).WithMessage(ValidationPatterns.InvalidIdMessage);
+ }
+}
diff --git a/src/backend/Application/Students/Commands/CreateStudentValidator.cs b/src/backend/Application/Students/Commands/CreateStudentValidator.cs
index e371de4..0bc8f4d 100644
--- a/src/backend/Application/Students/Commands/CreateStudentValidator.cs
+++ b/src/backend/Application/Students/Commands/CreateStudentValidator.cs
@@ -1,70 +1,72 @@
namespace Application.Students.Commands;
-using System.Text.RegularExpressions;
+using Application.Common;
using Domain.Ports.Repositories;
using FluentValidation;
-public partial class CreateStudentValidator : AbstractValidator
+///
+/// Validator for .
+/// Validates name format, email uniqueness, and prevents XSS attacks.
+///
+public class CreateStudentValidator : AbstractValidator
{
- [GeneratedRegex(@"^[\p{L}\s\-'\.]+$", RegexOptions.Compiled)]
- private static partial Regex SafeNamePattern();
-
- [GeneratedRegex(@"<[^>]*>|javascript:|on\w+=", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
- private static partial Regex DangerousPattern();
-
public CreateStudentValidator(IStudentRepository studentRepository)
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Name is required")
- .MinimumLength(3).WithMessage("Name must be at least 3 characters")
- .MaximumLength(100).WithMessage("Name must not exceed 100 characters")
- .Matches(SafeNamePattern()).WithMessage("Name contains invalid characters")
- .Must(NotContainDangerousContent).WithMessage("Name contains prohibited content");
+ .MinimumLength(ValidationPatterns.MinNameLength)
+ .WithMessage($"Name must be at least {ValidationPatterns.MinNameLength} characters")
+ .MaximumLength(ValidationPatterns.MaxNameLength)
+ .WithMessage($"Name must not exceed {ValidationPatterns.MaxNameLength} characters")
+ .Matches(ValidationPatterns.SafeNamePattern())
+ .WithMessage("Name contains invalid characters")
+ .Must(ValidationPatterns.IsSafeContent)
+ .WithMessage("Name contains prohibited content");
RuleFor(x => x.Email)
.NotEmpty().WithMessage("Email is required")
- .MaximumLength(254).WithMessage("Email must not exceed 254 characters")
+ .MaximumLength(ValidationPatterns.MaxEmailLength)
+ .WithMessage($"Email must not exceed {ValidationPatterns.MaxEmailLength} characters")
.EmailAddress().WithMessage("Invalid email format")
- .Must(NotContainDangerousContent).WithMessage("Email contains prohibited content")
+ .Must(ValidationPatterns.IsSafeContent)
+ .WithMessage("Email contains prohibited content")
.MustAsync(async (email, ct) =>
!await studentRepository.EmailExistsAsync(email, null, ct))
.WithMessage("Email already exists");
}
-
- private static bool NotContainDangerousContent(string? value) =>
- string.IsNullOrEmpty(value) || !DangerousPattern().IsMatch(value);
}
-public partial class UpdateStudentValidator : AbstractValidator
+///
+/// Validator for .
+/// Validates name format, email uniqueness (excluding current student), and prevents XSS attacks.
+///
+public class UpdateStudentValidator : AbstractValidator
{
- [GeneratedRegex(@"^[\p{L}\s\-'\.]+$", RegexOptions.Compiled)]
- private static partial Regex SafeNamePattern();
-
- [GeneratedRegex(@"<[^>]*>|javascript:|on\w+=", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
- private static partial Regex DangerousPattern();
-
public UpdateStudentValidator(IStudentRepository studentRepository)
{
RuleFor(x => x.Id)
- .GreaterThan(0).WithMessage("Invalid student ID");
+ .GreaterThan(0).WithMessage(ValidationPatterns.InvalidIdMessage);
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Name is required")
- .MinimumLength(3).WithMessage("Name must be at least 3 characters")
- .MaximumLength(100).WithMessage("Name must not exceed 100 characters")
- .Matches(SafeNamePattern()).WithMessage("Name contains invalid characters")
- .Must(NotContainDangerousContent).WithMessage("Name contains prohibited content");
+ .MinimumLength(ValidationPatterns.MinNameLength)
+ .WithMessage($"Name must be at least {ValidationPatterns.MinNameLength} characters")
+ .MaximumLength(ValidationPatterns.MaxNameLength)
+ .WithMessage($"Name must not exceed {ValidationPatterns.MaxNameLength} characters")
+ .Matches(ValidationPatterns.SafeNamePattern())
+ .WithMessage("Name contains invalid characters")
+ .Must(ValidationPatterns.IsSafeContent)
+ .WithMessage("Name contains prohibited content");
RuleFor(x => x.Email)
.NotEmpty().WithMessage("Email is required")
- .MaximumLength(254).WithMessage("Email must not exceed 254 characters")
+ .MaximumLength(ValidationPatterns.MaxEmailLength)
+ .WithMessage($"Email must not exceed {ValidationPatterns.MaxEmailLength} characters")
.EmailAddress().WithMessage("Invalid email format")
- .Must(NotContainDangerousContent).WithMessage("Email contains prohibited content")
+ .Must(ValidationPatterns.IsSafeContent)
+ .WithMessage("Email contains prohibited content")
.MustAsync(async (command, email, ct) =>
!await studentRepository.EmailExistsAsync(email, command.Id, ct))
.WithMessage("Email already exists");
}
-
- private static bool NotContainDangerousContent(string? value) =>
- string.IsNullOrEmpty(value) || !DangerousPattern().IsMatch(value);
}
diff --git a/src/backend/Application/Students/Queries/GetStudentByIdValidator.cs b/src/backend/Application/Students/Queries/GetStudentByIdValidator.cs
new file mode 100644
index 0000000..90dfccb
--- /dev/null
+++ b/src/backend/Application/Students/Queries/GetStudentByIdValidator.cs
@@ -0,0 +1,17 @@
+namespace Application.Students.Queries;
+
+using Application.Common;
+using FluentValidation;
+
+///
+/// Validator for .
+/// Ensures the student ID is valid before querying.
+///
+public class GetStudentByIdValidator : AbstractValidator
+{
+ public GetStudentByIdValidator()
+ {
+ RuleFor(x => x.Id)
+ .GreaterThan(0).WithMessage(ValidationPatterns.InvalidIdMessage);
+ }
+}
diff --git a/src/backend/Application/Students/Queries/GetStudentsPagedValidator.cs b/src/backend/Application/Students/Queries/GetStudentsPagedValidator.cs
new file mode 100644
index 0000000..4e9eb1f
--- /dev/null
+++ b/src/backend/Application/Students/Queries/GetStudentsPagedValidator.cs
@@ -0,0 +1,22 @@
+namespace Application.Students.Queries;
+
+using Application.Common;
+using FluentValidation;
+
+///
+/// Validator for .
+/// Validates pagination parameters.
+///
+public class GetStudentsPagedValidator : AbstractValidator
+{
+ public GetStudentsPagedValidator()
+ {
+ RuleFor(x => x.PageSize)
+ .GreaterThan(0).WithMessage("Page size must be greater than 0")
+ .LessThanOrEqualTo(50).WithMessage("Page size must not exceed 50");
+
+ RuleFor(x => x.AfterCursor)
+ .GreaterThan(0).WithMessage(ValidationPatterns.InvalidIdMessage)
+ .When(x => x.AfterCursor.HasValue);
+ }
+}
diff --git a/src/backend/Application/Subjects/Queries/GetAvailableSubjectsQuery.cs b/src/backend/Application/Subjects/Queries/GetAvailableSubjectsQuery.cs
index 19fa1dc..ac48639 100644
--- a/src/backend/Application/Subjects/Queries/GetAvailableSubjectsQuery.cs
+++ b/src/backend/Application/Subjects/Queries/GetAvailableSubjectsQuery.cs
@@ -1,16 +1,24 @@
namespace Application.Subjects.Queries;
using Application.Subjects.DTOs;
-using Domain.Entities;
using Domain.Exceptions;
using Domain.Ports.Repositories;
+using Domain.Services;
using MediatR;
+///
+/// Query to get all subjects with availability status for a specific student.
+///
public record GetAvailableSubjectsQuery(int StudentId) : IRequest>;
+///
+/// Handler for .
+/// Uses domain service for availability logic.
+///
public class GetAvailableSubjectsHandler(
IStudentRepository studentRepository,
- ISubjectRepository subjectRepository)
+ ISubjectRepository subjectRepository,
+ EnrollmentDomainService enrollmentDomainService)
: IRequestHandler>
{
public async Task> Handle(
@@ -22,41 +30,17 @@ public class GetAvailableSubjectsHandler(
var allSubjects = await subjectRepository.GetAllWithProfessorsAsync(ct);
- var enrolledSubjectIds = student.Enrollments.Select(e => e.SubjectId).ToHashSet();
- var enrolledProfessorIds = student.Enrollments
- .Select(e => e.Subject?.ProfessorId ?? 0)
- .Where(id => id > 0)
- .ToHashSet();
-
- return allSubjects.Select(s =>
+ return allSubjects.Select(subject =>
{
- var (isAvailable, reason) = GetAvailability(s, student, enrolledSubjectIds, enrolledProfessorIds);
+ var (isAvailable, reason) = enrollmentDomainService.CheckSubjectAvailability(student, subject);
return new AvailableSubjectDto(
- s.Id,
- s.Name,
- s.Credits,
- s.ProfessorId,
- s.Professor?.Name ?? "",
+ subject.Id,
+ subject.Name,
+ subject.Credits,
+ subject.ProfessorId,
+ subject.Professor?.Name ?? "",
isAvailable,
reason);
}).ToList();
}
-
- private static (bool IsAvailable, string? Reason) GetAvailability(
- Subject subject,
- Student student,
- HashSet enrolledSubjectIds,
- HashSet enrolledProfessorIds)
- {
- if (enrolledSubjectIds.Contains(subject.Id))
- return (false, "Already enrolled");
-
- if (!student.CanEnroll())
- return (false, "Maximum 3 subjects reached");
-
- if (enrolledProfessorIds.Contains(subject.ProfessorId))
- return (false, "Already have a subject with this professor");
-
- return (true, null);
- }
}
diff --git a/src/backend/Application/Subjects/Queries/GetAvailableSubjectsValidator.cs b/src/backend/Application/Subjects/Queries/GetAvailableSubjectsValidator.cs
new file mode 100644
index 0000000..66302e9
--- /dev/null
+++ b/src/backend/Application/Subjects/Queries/GetAvailableSubjectsValidator.cs
@@ -0,0 +1,17 @@
+namespace Application.Subjects.Queries;
+
+using Application.Common;
+using FluentValidation;
+
+///
+/// Validator for .
+/// Ensures the student ID is valid before querying available subjects.
+///
+public class GetAvailableSubjectsValidator : AbstractValidator
+{
+ public GetAvailableSubjectsValidator()
+ {
+ RuleFor(x => x.StudentId)
+ .GreaterThan(0).WithMessage(ValidationPatterns.InvalidIdMessage);
+ }
+}
diff --git a/src/backend/Domain/Exceptions/DomainException.cs b/src/backend/Domain/Exceptions/DomainException.cs
index dfcd94e..7c30d5e 100644
--- a/src/backend/Domain/Exceptions/DomainException.cs
+++ b/src/backend/Domain/Exceptions/DomainException.cs
@@ -1,7 +1,14 @@
namespace Domain.Exceptions;
+///
+/// Base class for all domain exceptions.
+/// Provides a machine-readable code for client error handling.
+///
public abstract class DomainException : Exception
{
+ ///
+ /// Machine-readable error code for client handling.
+ ///
public string Code { get; }
protected DomainException(string code, string message) : base(message)
@@ -10,38 +17,75 @@ public abstract class DomainException : Exception
}
}
+///
+/// Thrown when a student attempts to enroll in more than 3 subjects.
+///
public class MaxEnrollmentsExceededException : DomainException
{
public MaxEnrollmentsExceededException(int studentId)
: base("MAX_ENROLLMENTS", $"Student {studentId} cannot enroll in more than 3 subjects") { }
}
+///
+/// Thrown when a student attempts to enroll in a subject with a professor they already have.
+///
public class SameProfessorConstraintException : DomainException
{
public SameProfessorConstraintException(int studentId, int professorId)
: base("SAME_PROFESSOR", $"Student {studentId} already has a subject with professor {professorId}") { }
}
+///
+/// Thrown when a requested student is not found.
+///
public class StudentNotFoundException : DomainException
{
public StudentNotFoundException(int studentId)
: base("STUDENT_NOT_FOUND", $"Student {studentId} was not found") { }
}
+///
+/// Thrown when a requested subject is not found.
+///
public class SubjectNotFoundException : DomainException
{
public SubjectNotFoundException(int subjectId)
: base("SUBJECT_NOT_FOUND", $"Subject {subjectId} was not found") { }
}
+///
+/// Thrown when a requested enrollment is not found.
+///
public class EnrollmentNotFoundException : DomainException
{
public EnrollmentNotFoundException(int enrollmentId)
: base("ENROLLMENT_NOT_FOUND", $"Enrollment {enrollmentId} was not found") { }
}
+///
+/// Thrown when a student attempts to enroll in a subject they're already enrolled in.
+///
public class DuplicateEnrollmentException : DomainException
{
public DuplicateEnrollmentException(int studentId, int subjectId)
: base("DUPLICATE_ENROLLMENT", $"Student {studentId} is already enrolled in subject {subjectId}") { }
}
+
+///
+/// Thrown when attempting to assign more than 2 subjects to a professor.
+/// Business rule: Each professor teaches exactly 2 subjects.
+///
+public class ProfessorMaxSubjectsExceededException : DomainException
+{
+ public ProfessorMaxSubjectsExceededException(int professorId)
+ : base("PROFESSOR_MAX_SUBJECTS", $"Professor {professorId} already has the maximum of 2 subjects") { }
+}
+
+///
+/// Thrown when a requested professor is not found.
+///
+public class ProfessorNotFoundException : DomainException
+{
+ public ProfessorNotFoundException(int professorId)
+ : base("PROFESSOR_NOT_FOUND", $"Professor {professorId} was not found") { }
+}
diff --git a/src/backend/Domain/Ports/Repositories/IEnrollmentRepository.cs b/src/backend/Domain/Ports/Repositories/IEnrollmentRepository.cs
index 71af85f..bd21c72 100644
--- a/src/backend/Domain/Ports/Repositories/IEnrollmentRepository.cs
+++ b/src/backend/Domain/Ports/Repositories/IEnrollmentRepository.cs
@@ -1,16 +1,52 @@
namespace Domain.Ports.Repositories;
using Domain.Entities;
+using Domain.ReadModels;
+///
+/// Repository interface for enrollment persistence operations.
+///
public interface IEnrollmentRepository
{
+ ///
+ /// Gets an enrollment by its unique identifier.
+ ///
Task GetByIdAsync(int id, CancellationToken ct = default);
+
+ ///
+ /// Gets an enrollment by student and subject combination.
+ ///
Task GetByStudentAndSubjectAsync(int studentId, int subjectId, CancellationToken ct = default);
+
+ ///
+ /// Gets all enrollments for a specific student with related entities.
+ ///
Task> GetByStudentIdAsync(int studentId, CancellationToken ct = default);
+
+ ///
+ /// Gets all enrollments for a specific subject with related entities.
+ ///
Task> GetBySubjectIdAsync(int subjectId, CancellationToken ct = default);
- Task> GetClassmatesAsync(int studentId, int subjectId, CancellationToken ct = default);
- Task>> GetClassmatesBatchAsync(
+
+ ///
+ /// Gets classmates (other students) enrolled in the same subject.
+ ///
+ Task> GetClassmatesAsync(int studentId, int subjectId, CancellationToken ct = default);
+
+ ///
+ /// Batch query to get classmates for multiple subjects in a single database call.
+ /// Eliminates N+1 query problem when loading classmates for all enrolled subjects.
+ ///
+ Task>> GetClassmatesBatchAsync(
int studentId, IEnumerable subjectIds, CancellationToken ct = default);
+
+ ///
+ /// Adds a new enrollment to the context.
+ ///
void Add(Enrollment enrollment);
+
+ ///
+ /// Removes an enrollment from the context.
+ ///
void Delete(Enrollment enrollment);
}
diff --git a/src/backend/Domain/Ports/Repositories/IProfessorRepository.cs b/src/backend/Domain/Ports/Repositories/IProfessorRepository.cs
index 4aac2e6..412166a 100644
--- a/src/backend/Domain/Ports/Repositories/IProfessorRepository.cs
+++ b/src/backend/Domain/Ports/Repositories/IProfessorRepository.cs
@@ -3,13 +3,29 @@ namespace Domain.Ports.Repositories;
using Domain.Entities;
using System.Linq.Expressions;
+///
+/// Repository interface for professor persistence operations.
+///
public interface IProfessorRepository
{
+ ///
+ /// Gets a professor by ID without related entities.
+ ///
Task GetByIdAsync(int id, CancellationToken ct = default);
+
+ ///
+ /// Gets all professors without related entities.
+ ///
Task> GetAllAsync(CancellationToken ct = default);
+
+ ///
+ /// Gets all professors with their subjects.
+ ///
Task> GetAllWithSubjectsAsync(CancellationToken ct = default);
- // Projection support
+ ///
+ /// Gets all professors with custom projection to avoid over-fetching.
+ ///
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
index e58c4ab..29b9243 100644
--- a/src/backend/Domain/Ports/Repositories/IStudentRepository.cs
+++ b/src/backend/Domain/Ports/Repositories/IStudentRepository.cs
@@ -3,32 +3,85 @@ namespace Domain.Ports.Repositories;
using Domain.Entities;
using System.Linq.Expressions;
+///
+/// Repository interface for student persistence operations.
+///
public interface IStudentRepository
{
+ ///
+ /// Gets a student by ID without related entities.
+ ///
Task GetByIdAsync(int id, CancellationToken ct = default);
+
+ ///
+ /// Gets a student by ID with enrollments and related subjects.
+ ///
Task GetByIdWithEnrollmentsAsync(int id, CancellationToken ct = default);
+
+ ///
+ /// Gets all students without related entities.
+ ///
Task> GetAllAsync(CancellationToken ct = default);
+
+ ///
+ /// Gets all students with enrollments and related subjects.
+ ///
Task> GetAllWithEnrollmentsAsync(CancellationToken ct = default);
+
+ ///
+ /// Checks if a student exists by ID.
+ ///
Task ExistsAsync(int id, CancellationToken ct = default);
+
+ ///
+ /// Checks if an email is already in use, optionally excluding a student.
+ ///
+ /// The email to check.
+ /// Optional student ID to exclude from the check.
+ /// Cancellation token.
Task EmailExistsAsync(string email, int? excludeId = null, CancellationToken ct = default);
- // Projection support for optimized queries
+ ///
+ /// Gets all students with custom projection to avoid over-fetching.
+ ///
Task> GetAllProjectedAsync(
Expression> selector,
CancellationToken ct = default);
+
+ ///
+ /// Gets a student by ID with custom projection.
+ ///
Task GetByIdProjectedAsync(
int id,
Expression> selector,
CancellationToken ct = default);
- // Keyset pagination (more efficient than OFFSET)
+ ///
+ /// Gets students with keyset pagination (more efficient than OFFSET).
+ ///
+ /// Projection selector.
+ /// Cursor: ID after which to start.
+ /// Number of items per page.
+ /// Cancellation token.
+ /// Items, next cursor (if any), and total count.
Task<(IReadOnlyList Items, int? NextCursor, int TotalCount)> GetPagedProjectedAsync(
Expression> selector,
int? afterId = null,
int pageSize = 10,
CancellationToken ct = default);
+ ///
+ /// Adds a new student to the context.
+ ///
void Add(Student student);
+
+ ///
+ /// Marks a student as modified in the context.
+ ///
void Update(Student student);
+
+ ///
+ /// Removes a student from the context.
+ ///
void Delete(Student student);
}
diff --git a/src/backend/Domain/Ports/Repositories/ISubjectRepository.cs b/src/backend/Domain/Ports/Repositories/ISubjectRepository.cs
index b62ac65..e5e23b6 100644
--- a/src/backend/Domain/Ports/Repositories/ISubjectRepository.cs
+++ b/src/backend/Domain/Ports/Repositories/ISubjectRepository.cs
@@ -3,15 +3,39 @@ namespace Domain.Ports.Repositories;
using Domain.Entities;
using System.Linq.Expressions;
+///
+/// Repository interface for subject persistence operations.
+///
public interface ISubjectRepository
{
+ ///
+ /// Gets a subject by ID without related entities.
+ ///
Task GetByIdAsync(int id, CancellationToken ct = default);
+
+ ///
+ /// Gets a subject by ID with its professor.
+ ///
Task GetByIdWithProfessorAsync(int id, CancellationToken ct = default);
+
+ ///
+ /// Gets all subjects without related entities.
+ ///
Task> GetAllAsync(CancellationToken ct = default);
+
+ ///
+ /// Gets all subjects with their professors.
+ ///
Task> GetAllWithProfessorsAsync(CancellationToken ct = default);
+
+ ///
+ /// Gets subjects available for a student (not enrolled, no professor conflict).
+ ///
Task> GetAvailableForStudentAsync(int studentId, CancellationToken ct = default);
- // Projection support
+ ///
+ /// Gets all subjects with custom projection to avoid over-fetching.
+ ///
Task> GetAllProjectedAsync(
Expression> selector,
CancellationToken ct = default);
diff --git a/src/backend/Domain/ReadModels/ClassmateInfo.cs b/src/backend/Domain/ReadModels/ClassmateInfo.cs
new file mode 100644
index 0000000..78bd34c
--- /dev/null
+++ b/src/backend/Domain/ReadModels/ClassmateInfo.cs
@@ -0,0 +1,9 @@
+namespace Domain.ReadModels;
+
+///
+/// Minimal read-only projection of a student for classmate queries.
+/// Used to avoid loading full Student entities when only Id and Name are needed.
+///
+/// The student's unique identifier.
+/// The student's full name.
+public readonly record struct ClassmateInfo(int StudentId, string Name);
diff --git a/src/backend/Domain/Services/EnrollmentDomainService.cs b/src/backend/Domain/Services/EnrollmentDomainService.cs
index 22cc7f2..62a575c 100644
--- a/src/backend/Domain/Services/EnrollmentDomainService.cs
+++ b/src/backend/Domain/Services/EnrollmentDomainService.cs
@@ -3,8 +3,20 @@ namespace Domain.Services;
using Domain.Entities;
using Domain.Exceptions;
+///
+/// Domain service for enrollment business rules.
+/// Contains validation logic that spans multiple aggregates.
+///
public class EnrollmentDomainService
{
+ ///
+ /// Validates that a student can enroll in a subject.
+ ///
+ /// The student attempting to enroll.
+ /// The subject to enroll in.
+ /// Student has reached max enrollments.
+ /// Student already has a subject with this professor.
+ /// Student is already enrolled in this subject.
public void ValidateEnrollment(Student student, Subject subject)
{
if (!student.CanEnroll())
@@ -17,6 +29,12 @@ public class EnrollmentDomainService
throw new DuplicateEnrollmentException(student.Id, subject.Id);
}
+ ///
+ /// Creates an enrollment after validating business rules.
+ ///
+ /// The student to enroll.
+ /// The subject to enroll in.
+ /// The created enrollment.
public Enrollment CreateEnrollment(Student student, Subject subject)
{
ValidateEnrollment(student, subject);
@@ -26,4 +44,30 @@ public class EnrollmentDomainService
return enrollment;
}
+
+ ///
+ /// Checks if a subject is available for enrollment by a student.
+ /// Returns availability status and reason without throwing exceptions.
+ ///
+ /// The student checking availability.
+ /// The subject to check.
+ /// A tuple with availability status and optional reason if unavailable.
+ public (bool IsAvailable, string? Reason) CheckSubjectAvailability(
+ Student student,
+ Subject subject)
+ {
+ // Already enrolled in this subject
+ if (student.Enrollments.Any(e => e.SubjectId == subject.Id))
+ return (false, "Already enrolled");
+
+ // Maximum enrollments reached
+ if (!student.CanEnroll())
+ return (false, "Maximum 3 subjects reached");
+
+ // Already has a subject with this professor
+ if (student.HasProfessor(subject.ProfessorId))
+ return (false, "Already have a subject with this professor");
+
+ return (true, null);
+ }
}
diff --git a/tests/Application.Tests/Application.Tests.csproj b/tests/Application.Tests/Application.Tests.csproj
index 6ee6157..3bf834b 100644
--- a/tests/Application.Tests/Application.Tests.csproj
+++ b/tests/Application.Tests/Application.Tests.csproj
@@ -25,6 +25,7 @@
+
diff --git a/tests/Application.Tests/Enrollments/GetClassmatesQueryTests.cs b/tests/Application.Tests/Enrollments/GetClassmatesQueryTests.cs
index 3d22e9a..ea5acb2 100644
--- a/tests/Application.Tests/Enrollments/GetClassmatesQueryTests.cs
+++ b/tests/Application.Tests/Enrollments/GetClassmatesQueryTests.cs
@@ -4,6 +4,7 @@ using Application.Enrollments.Queries;
using Domain.Entities;
using Domain.Exceptions;
using Domain.Ports.Repositories;
+using Domain.ReadModels;
using Domain.ValueObjects;
using FluentAssertions;
using NSubstitute;
@@ -84,12 +85,10 @@ public class GetClassmatesQueryTests
_studentRepository.GetByIdWithEnrollmentsAsync(1, Arg.Any())
.Returns(student);
- var classmate1 = new Student("Jane Doe", Email.Create("jane@test.com"));
- var classmate2 = new Student("Bob Smith", Email.Create("bob@test.com"));
- SetEntityId(classmate1, 2);
- SetEntityId(classmate2, 3);
+ var classmate1 = new ClassmateInfo(2, "Jane Doe");
+ var classmate2 = new ClassmateInfo(3, "Bob Smith");
- var batchResult = new Dictionary>
+ var batchResult = new Dictionary>
{
[1] = [classmate1],
[2] = [classmate2]
@@ -129,7 +128,7 @@ public class GetClassmatesQueryTests
_studentRepository.GetByIdWithEnrollmentsAsync(1, Arg.Any())
.Returns(student);
- var emptyBatchResult = new Dictionary>();
+ var emptyBatchResult = new Dictionary>();
_enrollmentRepository.GetClassmatesBatchAsync(1, Arg.Any>(), Arg.Any())
.Returns(emptyBatchResult);
diff --git a/tests/Application.Tests/Subjects/SubjectQueriesTests.cs b/tests/Application.Tests/Subjects/SubjectQueriesTests.cs
index 1eb8445..653aa6b 100644
--- a/tests/Application.Tests/Subjects/SubjectQueriesTests.cs
+++ b/tests/Application.Tests/Subjects/SubjectQueriesTests.cs
@@ -92,13 +92,15 @@ public class GetAvailableSubjectsQueryTests
{
private readonly IStudentRepository _studentRepository;
private readonly ISubjectRepository _subjectRepository;
+ private readonly Domain.Services.EnrollmentDomainService _enrollmentDomainService;
private readonly GetAvailableSubjectsHandler _handler;
public GetAvailableSubjectsQueryTests()
{
_studentRepository = Substitute.For();
_subjectRepository = Substitute.For();
- _handler = new GetAvailableSubjectsHandler(_studentRepository, _subjectRepository);
+ _enrollmentDomainService = new Domain.Services.EnrollmentDomainService();
+ _handler = new GetAvailableSubjectsHandler(_studentRepository, _subjectRepository, _enrollmentDomainService);
}
[Fact]
diff --git a/tests/Common/Builders/EnrollmentBuilder.cs b/tests/Common/Builders/EnrollmentBuilder.cs
new file mode 100644
index 0000000..d55d59a
--- /dev/null
+++ b/tests/Common/Builders/EnrollmentBuilder.cs
@@ -0,0 +1,74 @@
+namespace Common.Builders;
+
+using Domain.Entities;
+
+///
+/// Builder pattern for creating Enrollment test objects.
+///
+public class EnrollmentBuilder
+{
+ private int _id = 1;
+ private int _studentId = 1;
+ private int _subjectId = 1;
+ private Student? _student;
+ private Subject? _subject;
+ private DateTime? _enrolledAt;
+
+ public EnrollmentBuilder WithId(int id)
+ {
+ _id = id;
+ return this;
+ }
+
+ public EnrollmentBuilder WithStudentId(int studentId)
+ {
+ _studentId = studentId;
+ return this;
+ }
+
+ public EnrollmentBuilder WithSubjectId(int subjectId)
+ {
+ _subjectId = subjectId;
+ return this;
+ }
+
+ public EnrollmentBuilder WithStudent(Student student)
+ {
+ _student = student;
+ _studentId = student.Id;
+ return this;
+ }
+
+ public EnrollmentBuilder WithSubject(Subject subject)
+ {
+ _subject = subject;
+ _subjectId = subject.Id;
+ return this;
+ }
+
+ public EnrollmentBuilder WithEnrolledAt(DateTime enrolledAt)
+ {
+ _enrolledAt = enrolledAt;
+ return this;
+ }
+
+ public Enrollment Build()
+ {
+ var enrollment = new Enrollment(_studentId, _subjectId);
+ SetProperty(enrollment, "Id", _id);
+
+ if (_student is not null)
+ SetProperty(enrollment, "Student", _student);
+
+ if (_subject is not null)
+ SetProperty(enrollment, "Subject", _subject);
+
+ if (_enrolledAt.HasValue)
+ SetProperty(enrollment, "EnrolledAt", _enrolledAt.Value);
+
+ return enrollment;
+ }
+
+ private static void SetProperty(T obj, string propertyName, object value) where T : class =>
+ typeof(T).GetProperty(propertyName)!.SetValue(obj, value);
+}
diff --git a/tests/Common/Builders/ProfessorBuilder.cs b/tests/Common/Builders/ProfessorBuilder.cs
new file mode 100644
index 0000000..2471ed7
--- /dev/null
+++ b/tests/Common/Builders/ProfessorBuilder.cs
@@ -0,0 +1,50 @@
+namespace Common.Builders;
+
+using Domain.Entities;
+
+///
+/// Builder pattern for creating Professor test objects.
+///
+public class ProfessorBuilder
+{
+ private int _id = 1;
+ private string _name = "Prof. Smith";
+ private readonly List _subjects = [];
+
+ public ProfessorBuilder WithId(int id)
+ {
+ _id = id;
+ return this;
+ }
+
+ public ProfessorBuilder WithName(string name)
+ {
+ _name = name;
+ return this;
+ }
+
+ public ProfessorBuilder WithSubject(Subject subject)
+ {
+ _subjects.Add(subject);
+ return this;
+ }
+
+ public Professor Build()
+ {
+ var professor = new Professor(_name);
+ SetProperty(professor, "Id", _id);
+
+ // Access private _subjects field to add test subjects
+ var subjectsList = (List)typeof(Professor)
+ .GetField("_subjects", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
+ .GetValue(professor)!;
+
+ foreach (var subject in _subjects)
+ subjectsList.Add(subject);
+
+ return professor;
+ }
+
+ private static void SetProperty(T obj, string propertyName, object value) where T : class =>
+ typeof(T).GetProperty(propertyName)!.SetValue(obj, value);
+}
diff --git a/tests/Common/Builders/StudentBuilder.cs b/tests/Common/Builders/StudentBuilder.cs
new file mode 100644
index 0000000..78ee59e
--- /dev/null
+++ b/tests/Common/Builders/StudentBuilder.cs
@@ -0,0 +1,78 @@
+namespace Common.Builders;
+
+using Domain.Entities;
+using Domain.ValueObjects;
+
+///
+/// Builder pattern for creating Student test objects.
+/// Encapsulates reflection-based ID setting for cleaner tests.
+///
+public class StudentBuilder
+{
+ private int _id = 1;
+ private string _name = "John Doe";
+ private string _email = "john@test.com";
+ private readonly List<(Subject Subject, int EnrollmentId)> _enrollments = [];
+
+ public StudentBuilder WithId(int id)
+ {
+ _id = id;
+ return this;
+ }
+
+ public StudentBuilder WithName(string name)
+ {
+ _name = name;
+ return this;
+ }
+
+ public StudentBuilder WithEmail(string email)
+ {
+ _email = email;
+ return this;
+ }
+
+ public StudentBuilder WithEnrollment(Subject subject, int enrollmentId = 0)
+ {
+ _enrollments.Add((subject, enrollmentId));
+ return this;
+ }
+
+ public StudentBuilder WithEnrollments(int count)
+ {
+ for (int i = 0; i < count; i++)
+ {
+ var subject = new SubjectBuilder()
+ .WithId(i + 100)
+ .WithName($"Subject{i}")
+ .WithProfessorId(i + 1)
+ .WithProfessor(new ProfessorBuilder()
+ .WithId(i + 1)
+ .WithName($"Prof{i}")
+ .Build())
+ .Build();
+ _enrollments.Add((subject, i + 1));
+ }
+ return this;
+ }
+
+ public Student Build()
+ {
+ var student = new Student(_name, Email.Create(_email));
+ SetProperty(student, "Id", _id);
+
+ foreach (var (subject, enrollmentId) in _enrollments)
+ {
+ var enrollment = new Enrollment(student.Id, subject.Id);
+ if (enrollmentId > 0)
+ SetProperty(enrollment, "Id", enrollmentId);
+ SetProperty(enrollment, "Subject", subject);
+ student.AddEnrollment(enrollment);
+ }
+
+ return student;
+ }
+
+ private static void SetProperty(T obj, string propertyName, object value) where T : class =>
+ typeof(T).GetProperty(propertyName)!.SetValue(obj, value);
+}
diff --git a/tests/Common/Builders/SubjectBuilder.cs b/tests/Common/Builders/SubjectBuilder.cs
new file mode 100644
index 0000000..8f672fc
--- /dev/null
+++ b/tests/Common/Builders/SubjectBuilder.cs
@@ -0,0 +1,54 @@
+namespace Common.Builders;
+
+using Domain.Entities;
+
+///
+/// Builder pattern for creating Subject test objects.
+/// Encapsulates reflection-based ID and navigation property setting.
+///
+public class SubjectBuilder
+{
+ private int _id = 1;
+ private string _name = "Mathematics";
+ private int _professorId = 1;
+ private Professor? _professor;
+
+ public SubjectBuilder WithId(int id)
+ {
+ _id = id;
+ return this;
+ }
+
+ public SubjectBuilder WithName(string name)
+ {
+ _name = name;
+ return this;
+ }
+
+ public SubjectBuilder WithProfessorId(int professorId)
+ {
+ _professorId = professorId;
+ return this;
+ }
+
+ public SubjectBuilder WithProfessor(Professor professor)
+ {
+ _professor = professor;
+ _professorId = professor.Id;
+ return this;
+ }
+
+ public Subject Build()
+ {
+ var subject = new Subject(_name, _professorId);
+ SetProperty(subject, "Id", _id);
+
+ if (_professor is not null)
+ SetProperty(subject, "Professor", _professor);
+
+ return subject;
+ }
+
+ private static void SetProperty(T obj, string propertyName, object value) where T : class =>
+ typeof(T).GetProperty(propertyName)!.SetValue(obj, value);
+}
diff --git a/tests/Common/Common.csproj b/tests/Common/Common.csproj
new file mode 100644
index 0000000..17c4ae6
--- /dev/null
+++ b/tests/Common/Common.csproj
@@ -0,0 +1,13 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/tests/Domain.Tests/Domain.Tests.csproj b/tests/Domain.Tests/Domain.Tests.csproj
index 17f587b..b0afc20 100644
--- a/tests/Domain.Tests/Domain.Tests.csproj
+++ b/tests/Domain.Tests/Domain.Tests.csproj
@@ -24,6 +24,7 @@
+