refactor(backend): add validation patterns, query validators, and test builders

- Add centralized ValidationPatterns with XSS protection and name regex
- Add query validators (GetClassmates, GetStudentById, GetStudentsPaged, GetAvailableSubjects)
- Add ClassmateInfo read model for optimized query projections
- Add test builders (Student, Subject, Professor, Enrollment) for cleaner tests
- Improve repository interfaces with XML documentation
- Refactor EnrollmentRepository for better performance
- Update EnrollmentDomainService with additional validation helpers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andrés Eduardo García Márquez 2026-01-08 09:18:27 -05:00
parent 815ebf5673
commit 4ede4d4cef
26 changed files with 740 additions and 106 deletions

View File

@ -3,8 +3,12 @@ namespace Adapters.Driven.Persistence.Repositories;
using Adapters.Driven.Persistence.Context; using Adapters.Driven.Persistence.Context;
using Domain.Entities; using Domain.Entities;
using Domain.Ports.Repositories; using Domain.Ports.Repositories;
using Domain.ReadModels;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
/// <summary>
/// EF Core implementation of <see cref="IEnrollmentRepository"/>.
/// </summary>
public class EnrollmentRepository(AppDbContext context) : IEnrollmentRepository public class EnrollmentRepository(AppDbContext context) : IEnrollmentRepository
{ {
public async Task<Enrollment?> GetByIdAsync(int id, CancellationToken ct = default) => public async Task<Enrollment?> GetByIdAsync(int id, CancellationToken ct = default) =>
@ -29,45 +33,32 @@ public class EnrollmentRepository(AppDbContext context) : IEnrollmentRepository
.AsNoTracking() .AsNoTracking()
.ToListAsync(ct); .ToListAsync(ct);
public async Task<IReadOnlyList<Student>> GetClassmatesAsync(int studentId, int subjectId, CancellationToken ct = default) => public async Task<IReadOnlyList<ClassmateInfo>> GetClassmatesAsync(int studentId, int subjectId, CancellationToken ct = default) =>
await context.Enrollments await context.Enrollments
.Where(e => e.SubjectId == subjectId && e.StudentId != studentId) .Where(e => e.SubjectId == subjectId && e.StudentId != studentId)
.Select(e => e.Student) .Select(e => new ClassmateInfo(e.Student.Id, e.Student.Name))
.AsNoTracking()
.ToListAsync(ct); .ToListAsync(ct);
public async Task<IReadOnlyDictionary<int, IReadOnlyList<Student>>> GetClassmatesBatchAsync( public async Task<IReadOnlyDictionary<int, IReadOnlyList<ClassmateInfo>>> GetClassmatesBatchAsync(
int studentId, IEnumerable<int> subjectIds, CancellationToken ct = default) int studentId, IEnumerable<int> subjectIds, CancellationToken ct = default)
{ {
var subjectIdList = subjectIds.ToList(); var subjectIdList = subjectIds.ToList();
if (subjectIdList.Count == 0) if (subjectIdList.Count == 0)
return new Dictionary<int, IReadOnlyList<Student>>(); return new Dictionary<int, IReadOnlyList<ClassmateInfo>>();
// 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 var enrollments = await context.Enrollments
.Where(e => subjectIdList.Contains(e.SubjectId) && e.StudentId != studentId) .Where(e => subjectIdList.Contains(e.SubjectId) && e.StudentId != studentId)
.Select(e => new { e.SubjectId, e.Student.Id, e.Student.Name }) .Select(e => new { e.SubjectId, Classmate = new ClassmateInfo(e.Student.Id, e.Student.Name) })
.AsNoTracking()
.ToListAsync(ct); .ToListAsync(ct);
// Group by SubjectId and project to Student (minimal data needed) // Group by SubjectId
return enrollments return enrollments
.GroupBy(e => e.SubjectId) .GroupBy(e => e.SubjectId)
.ToDictionary( .ToDictionary(
g => g.Key, g => g.Key,
g => (IReadOnlyList<Student>)g g => (IReadOnlyList<ClassmateInfo>)g.Select(e => e.Classmate).ToList());
.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;
} }
public void Add(Enrollment enrollment) => context.Enrollments.Add(enrollment); public void Add(Enrollment enrollment) => context.Enrollments.Add(enrollment);

View File

@ -0,0 +1,69 @@
namespace Application.Common;
using System.Text.RegularExpressions;
/// <summary>
/// Shared validation patterns and constants for input validation.
/// Used across validators to ensure consistency and DRY principle.
/// </summary>
public static partial class ValidationPatterns
{
#region Name Validation
/// <summary>
/// Minimum length for student/person names.
/// </summary>
public const int MinNameLength = 3;
/// <summary>
/// Maximum length for student/person names.
/// </summary>
public const int MaxNameLength = 100;
/// <summary>
/// Pattern for valid name characters.
/// Allows: Unicode letters, spaces, hyphens, apostrophes, and periods.
/// Examples: "Juan Pérez", "María O'Brien", "Jean-Pierre"
/// </summary>
[GeneratedRegex(@"^[\p{L}\s\-'\.]+$", RegexOptions.Compiled)]
public static partial Regex SafeNamePattern();
#endregion
#region Email Validation
/// <summary>
/// Maximum length for email addresses (RFC 5321).
/// </summary>
public const int MaxEmailLength = 254;
#endregion
#region Security Patterns
/// <summary>
/// Pattern to detect potentially dangerous content (XSS, script injection).
/// Matches: HTML tags, javascript: protocol, event handlers (onclick, onload, etc.)
/// </summary>
[GeneratedRegex(@"<[^>]*>|javascript:|on\w+=", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
public static partial Regex DangerousContentPattern();
/// <summary>
/// Checks if a string contains potentially dangerous content.
/// </summary>
/// <param name="value">The string to check.</param>
/// <returns>True if the content is safe (no dangerous patterns found).</returns>
public static bool IsSafeContent(string? value) =>
string.IsNullOrEmpty(value) || !DangerousContentPattern().IsMatch(value);
#endregion
#region ID Validation
/// <summary>
/// Error message for invalid entity ID.
/// </summary>
public const string InvalidIdMessage = "ID must be greater than 0";
#endregion
}

View File

@ -1,24 +1,33 @@
namespace Application.Enrollments.Commands; namespace Application.Enrollments.Commands;
using Application.Common;
using FluentValidation; using FluentValidation;
/// <summary>
/// Validator for <see cref="EnrollStudentCommand"/>.
/// Validates that student and subject IDs are valid.
/// </summary>
public class EnrollStudentValidator : AbstractValidator<EnrollStudentCommand> public class EnrollStudentValidator : AbstractValidator<EnrollStudentCommand>
{ {
public EnrollStudentValidator() public EnrollStudentValidator()
{ {
RuleFor(x => x.StudentId) RuleFor(x => x.StudentId)
.GreaterThan(0).WithMessage("Invalid student ID"); .GreaterThan(0).WithMessage(ValidationPatterns.InvalidIdMessage);
RuleFor(x => x.SubjectId) RuleFor(x => x.SubjectId)
.GreaterThan(0).WithMessage("Invalid subject ID"); .GreaterThan(0).WithMessage(ValidationPatterns.InvalidIdMessage);
} }
} }
/// <summary>
/// Validator for <see cref="UnenrollStudentCommand"/>.
/// Validates that enrollment ID is valid.
/// </summary>
public class UnenrollStudentValidator : AbstractValidator<UnenrollStudentCommand> public class UnenrollStudentValidator : AbstractValidator<UnenrollStudentCommand>
{ {
public UnenrollStudentValidator() public UnenrollStudentValidator()
{ {
RuleFor(x => x.EnrollmentId) RuleFor(x => x.EnrollmentId)
.GreaterThan(0).WithMessage("Invalid enrollment ID"); .GreaterThan(0).WithMessage(ValidationPatterns.InvalidIdMessage);
} }
} }

View File

@ -5,8 +5,16 @@ using Domain.Exceptions;
using Domain.Ports.Repositories; using Domain.Ports.Repositories;
using MediatR; using MediatR;
/// <summary>
/// Query to retrieve classmates for all subjects a student is enrolled in.
/// </summary>
/// <param name="StudentId">The student's unique identifier.</param>
public record GetClassmatesQuery(int StudentId) : IRequest<IReadOnlyList<ClassmatesBySubjectDto>>; public record GetClassmatesQuery(int StudentId) : IRequest<IReadOnlyList<ClassmatesBySubjectDto>>;
/// <summary>
/// Handler for <see cref="GetClassmatesQuery"/>.
/// Uses batch query to avoid N+1 problem.
/// </summary>
public class GetClassmatesHandler( public class GetClassmatesHandler(
IStudentRepository studentRepository, IStudentRepository studentRepository,
IEnrollmentRepository enrollmentRepository) IEnrollmentRepository enrollmentRepository)
@ -31,7 +39,7 @@ public class GetClassmatesHandler(
enrollment.SubjectId, enrollment.SubjectId,
enrollment.Subject?.Name ?? "", enrollment.Subject?.Name ?? "",
classmatesBySubject.TryGetValue(enrollment.SubjectId, out var classmates) 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(); )).ToList();
} }

View File

@ -0,0 +1,17 @@
namespace Application.Enrollments.Queries;
using Application.Common;
using FluentValidation;
/// <summary>
/// Validator for <see cref="GetClassmatesQuery"/>.
/// Ensures the student ID is valid before querying classmates.
/// </summary>
public class GetClassmatesValidator : AbstractValidator<GetClassmatesQuery>
{
public GetClassmatesValidator()
{
RuleFor(x => x.StudentId)
.GreaterThan(0).WithMessage(ValidationPatterns.InvalidIdMessage);
}
}

View File

@ -1,70 +1,72 @@
namespace Application.Students.Commands; namespace Application.Students.Commands;
using System.Text.RegularExpressions; using Application.Common;
using Domain.Ports.Repositories; using Domain.Ports.Repositories;
using FluentValidation; using FluentValidation;
public partial class CreateStudentValidator : AbstractValidator<CreateStudentCommand> /// <summary>
/// Validator for <see cref="CreateStudentCommand"/>.
/// Validates name format, email uniqueness, and prevents XSS attacks.
/// </summary>
public class CreateStudentValidator : AbstractValidator<CreateStudentCommand>
{ {
[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) public CreateStudentValidator(IStudentRepository studentRepository)
{ {
RuleFor(x => x.Name) RuleFor(x => x.Name)
.NotEmpty().WithMessage("Name is required") .NotEmpty().WithMessage("Name is required")
.MinimumLength(3).WithMessage("Name must be at least 3 characters") .MinimumLength(ValidationPatterns.MinNameLength)
.MaximumLength(100).WithMessage("Name must not exceed 100 characters") .WithMessage($"Name must be at least {ValidationPatterns.MinNameLength} characters")
.Matches(SafeNamePattern()).WithMessage("Name contains invalid characters") .MaximumLength(ValidationPatterns.MaxNameLength)
.Must(NotContainDangerousContent).WithMessage("Name contains prohibited content"); .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) RuleFor(x => x.Email)
.NotEmpty().WithMessage("Email is required") .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") .EmailAddress().WithMessage("Invalid email format")
.Must(NotContainDangerousContent).WithMessage("Email contains prohibited content") .Must(ValidationPatterns.IsSafeContent)
.WithMessage("Email contains prohibited content")
.MustAsync(async (email, ct) => .MustAsync(async (email, ct) =>
!await studentRepository.EmailExistsAsync(email, null, ct)) !await studentRepository.EmailExistsAsync(email, null, ct))
.WithMessage("Email already exists"); .WithMessage("Email already exists");
} }
private static bool NotContainDangerousContent(string? value) =>
string.IsNullOrEmpty(value) || !DangerousPattern().IsMatch(value);
} }
public partial class UpdateStudentValidator : AbstractValidator<UpdateStudentCommand> /// <summary>
/// Validator for <see cref="UpdateStudentCommand"/>.
/// Validates name format, email uniqueness (excluding current student), and prevents XSS attacks.
/// </summary>
public class UpdateStudentValidator : AbstractValidator<UpdateStudentCommand>
{ {
[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) public UpdateStudentValidator(IStudentRepository studentRepository)
{ {
RuleFor(x => x.Id) RuleFor(x => x.Id)
.GreaterThan(0).WithMessage("Invalid student ID"); .GreaterThan(0).WithMessage(ValidationPatterns.InvalidIdMessage);
RuleFor(x => x.Name) RuleFor(x => x.Name)
.NotEmpty().WithMessage("Name is required") .NotEmpty().WithMessage("Name is required")
.MinimumLength(3).WithMessage("Name must be at least 3 characters") .MinimumLength(ValidationPatterns.MinNameLength)
.MaximumLength(100).WithMessage("Name must not exceed 100 characters") .WithMessage($"Name must be at least {ValidationPatterns.MinNameLength} characters")
.Matches(SafeNamePattern()).WithMessage("Name contains invalid characters") .MaximumLength(ValidationPatterns.MaxNameLength)
.Must(NotContainDangerousContent).WithMessage("Name contains prohibited content"); .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) RuleFor(x => x.Email)
.NotEmpty().WithMessage("Email is required") .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") .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) => .MustAsync(async (command, email, ct) =>
!await studentRepository.EmailExistsAsync(email, command.Id, ct)) !await studentRepository.EmailExistsAsync(email, command.Id, ct))
.WithMessage("Email already exists"); .WithMessage("Email already exists");
} }
private static bool NotContainDangerousContent(string? value) =>
string.IsNullOrEmpty(value) || !DangerousPattern().IsMatch(value);
} }

View File

@ -0,0 +1,17 @@
namespace Application.Students.Queries;
using Application.Common;
using FluentValidation;
/// <summary>
/// Validator for <see cref="GetStudentByIdQuery"/>.
/// Ensures the student ID is valid before querying.
/// </summary>
public class GetStudentByIdValidator : AbstractValidator<GetStudentByIdQuery>
{
public GetStudentByIdValidator()
{
RuleFor(x => x.Id)
.GreaterThan(0).WithMessage(ValidationPatterns.InvalidIdMessage);
}
}

View File

@ -0,0 +1,22 @@
namespace Application.Students.Queries;
using Application.Common;
using FluentValidation;
/// <summary>
/// Validator for <see cref="GetStudentsPagedQuery"/>.
/// Validates pagination parameters.
/// </summary>
public class GetStudentsPagedValidator : AbstractValidator<GetStudentsPagedQuery>
{
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);
}
}

View File

@ -1,16 +1,24 @@
namespace Application.Subjects.Queries; namespace Application.Subjects.Queries;
using Application.Subjects.DTOs; using Application.Subjects.DTOs;
using Domain.Entities;
using Domain.Exceptions; using Domain.Exceptions;
using Domain.Ports.Repositories; using Domain.Ports.Repositories;
using Domain.Services;
using MediatR; using MediatR;
/// <summary>
/// Query to get all subjects with availability status for a specific student.
/// </summary>
public record GetAvailableSubjectsQuery(int StudentId) : IRequest<IReadOnlyList<AvailableSubjectDto>>; public record GetAvailableSubjectsQuery(int StudentId) : IRequest<IReadOnlyList<AvailableSubjectDto>>;
/// <summary>
/// Handler for <see cref="GetAvailableSubjectsQuery"/>.
/// Uses domain service for availability logic.
/// </summary>
public class GetAvailableSubjectsHandler( public class GetAvailableSubjectsHandler(
IStudentRepository studentRepository, IStudentRepository studentRepository,
ISubjectRepository subjectRepository) ISubjectRepository subjectRepository,
EnrollmentDomainService enrollmentDomainService)
: IRequestHandler<GetAvailableSubjectsQuery, IReadOnlyList<AvailableSubjectDto>> : IRequestHandler<GetAvailableSubjectsQuery, IReadOnlyList<AvailableSubjectDto>>
{ {
public async Task<IReadOnlyList<AvailableSubjectDto>> Handle( public async Task<IReadOnlyList<AvailableSubjectDto>> Handle(
@ -22,41 +30,17 @@ public class GetAvailableSubjectsHandler(
var allSubjects = await subjectRepository.GetAllWithProfessorsAsync(ct); var allSubjects = await subjectRepository.GetAllWithProfessorsAsync(ct);
var enrolledSubjectIds = student.Enrollments.Select(e => e.SubjectId).ToHashSet(); return allSubjects.Select(subject =>
var enrolledProfessorIds = student.Enrollments
.Select(e => e.Subject?.ProfessorId ?? 0)
.Where(id => id > 0)
.ToHashSet();
return allSubjects.Select(s =>
{ {
var (isAvailable, reason) = GetAvailability(s, student, enrolledSubjectIds, enrolledProfessorIds); var (isAvailable, reason) = enrollmentDomainService.CheckSubjectAvailability(student, subject);
return new AvailableSubjectDto( return new AvailableSubjectDto(
s.Id, subject.Id,
s.Name, subject.Name,
s.Credits, subject.Credits,
s.ProfessorId, subject.ProfessorId,
s.Professor?.Name ?? "", subject.Professor?.Name ?? "",
isAvailable, isAvailable,
reason); reason);
}).ToList(); }).ToList();
} }
private static (bool IsAvailable, string? Reason) GetAvailability(
Subject subject,
Student student,
HashSet<int> enrolledSubjectIds,
HashSet<int> 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);
}
} }

View File

@ -0,0 +1,17 @@
namespace Application.Subjects.Queries;
using Application.Common;
using FluentValidation;
/// <summary>
/// Validator for <see cref="GetAvailableSubjectsQuery"/>.
/// Ensures the student ID is valid before querying available subjects.
/// </summary>
public class GetAvailableSubjectsValidator : AbstractValidator<GetAvailableSubjectsQuery>
{
public GetAvailableSubjectsValidator()
{
RuleFor(x => x.StudentId)
.GreaterThan(0).WithMessage(ValidationPatterns.InvalidIdMessage);
}
}

View File

@ -1,7 +1,14 @@
namespace Domain.Exceptions; namespace Domain.Exceptions;
/// <summary>
/// Base class for all domain exceptions.
/// Provides a machine-readable code for client error handling.
/// </summary>
public abstract class DomainException : Exception public abstract class DomainException : Exception
{ {
/// <summary>
/// Machine-readable error code for client handling.
/// </summary>
public string Code { get; } public string Code { get; }
protected DomainException(string code, string message) : base(message) protected DomainException(string code, string message) : base(message)
@ -10,38 +17,75 @@ public abstract class DomainException : Exception
} }
} }
/// <summary>
/// Thrown when a student attempts to enroll in more than 3 subjects.
/// </summary>
public class MaxEnrollmentsExceededException : DomainException public class MaxEnrollmentsExceededException : DomainException
{ {
public MaxEnrollmentsExceededException(int studentId) public MaxEnrollmentsExceededException(int studentId)
: base("MAX_ENROLLMENTS", $"Student {studentId} cannot enroll in more than 3 subjects") { } : base("MAX_ENROLLMENTS", $"Student {studentId} cannot enroll in more than 3 subjects") { }
} }
/// <summary>
/// Thrown when a student attempts to enroll in a subject with a professor they already have.
/// </summary>
public class SameProfessorConstraintException : DomainException public class SameProfessorConstraintException : DomainException
{ {
public SameProfessorConstraintException(int studentId, int professorId) public SameProfessorConstraintException(int studentId, int professorId)
: base("SAME_PROFESSOR", $"Student {studentId} already has a subject with professor {professorId}") { } : base("SAME_PROFESSOR", $"Student {studentId} already has a subject with professor {professorId}") { }
} }
/// <summary>
/// Thrown when a requested student is not found.
/// </summary>
public class StudentNotFoundException : DomainException public class StudentNotFoundException : DomainException
{ {
public StudentNotFoundException(int studentId) public StudentNotFoundException(int studentId)
: base("STUDENT_NOT_FOUND", $"Student {studentId} was not found") { } : base("STUDENT_NOT_FOUND", $"Student {studentId} was not found") { }
} }
/// <summary>
/// Thrown when a requested subject is not found.
/// </summary>
public class SubjectNotFoundException : DomainException public class SubjectNotFoundException : DomainException
{ {
public SubjectNotFoundException(int subjectId) public SubjectNotFoundException(int subjectId)
: base("SUBJECT_NOT_FOUND", $"Subject {subjectId} was not found") { } : base("SUBJECT_NOT_FOUND", $"Subject {subjectId} was not found") { }
} }
/// <summary>
/// Thrown when a requested enrollment is not found.
/// </summary>
public class EnrollmentNotFoundException : DomainException public class EnrollmentNotFoundException : DomainException
{ {
public EnrollmentNotFoundException(int enrollmentId) public EnrollmentNotFoundException(int enrollmentId)
: base("ENROLLMENT_NOT_FOUND", $"Enrollment {enrollmentId} was not found") { } : base("ENROLLMENT_NOT_FOUND", $"Enrollment {enrollmentId} was not found") { }
} }
/// <summary>
/// Thrown when a student attempts to enroll in a subject they're already enrolled in.
/// </summary>
public class DuplicateEnrollmentException : DomainException public class DuplicateEnrollmentException : DomainException
{ {
public DuplicateEnrollmentException(int studentId, int subjectId) public DuplicateEnrollmentException(int studentId, int subjectId)
: base("DUPLICATE_ENROLLMENT", $"Student {studentId} is already enrolled in subject {subjectId}") { } : base("DUPLICATE_ENROLLMENT", $"Student {studentId} is already enrolled in subject {subjectId}") { }
} }
/// <summary>
/// Thrown when attempting to assign more than 2 subjects to a professor.
/// Business rule: Each professor teaches exactly 2 subjects.
/// </summary>
public class ProfessorMaxSubjectsExceededException : DomainException
{
public ProfessorMaxSubjectsExceededException(int professorId)
: base("PROFESSOR_MAX_SUBJECTS", $"Professor {professorId} already has the maximum of 2 subjects") { }
}
/// <summary>
/// Thrown when a requested professor is not found.
/// </summary>
public class ProfessorNotFoundException : DomainException
{
public ProfessorNotFoundException(int professorId)
: base("PROFESSOR_NOT_FOUND", $"Professor {professorId} was not found") { }
}

View File

@ -1,16 +1,52 @@
namespace Domain.Ports.Repositories; namespace Domain.Ports.Repositories;
using Domain.Entities; using Domain.Entities;
using Domain.ReadModels;
/// <summary>
/// Repository interface for enrollment persistence operations.
/// </summary>
public interface IEnrollmentRepository public interface IEnrollmentRepository
{ {
/// <summary>
/// Gets an enrollment by its unique identifier.
/// </summary>
Task<Enrollment?> GetByIdAsync(int id, CancellationToken ct = default); Task<Enrollment?> GetByIdAsync(int id, CancellationToken ct = default);
/// <summary>
/// Gets an enrollment by student and subject combination.
/// </summary>
Task<Enrollment?> GetByStudentAndSubjectAsync(int studentId, int subjectId, CancellationToken ct = default); Task<Enrollment?> GetByStudentAndSubjectAsync(int studentId, int subjectId, CancellationToken ct = default);
/// <summary>
/// Gets all enrollments for a specific student with related entities.
/// </summary>
Task<IReadOnlyList<Enrollment>> GetByStudentIdAsync(int studentId, CancellationToken ct = default); Task<IReadOnlyList<Enrollment>> GetByStudentIdAsync(int studentId, CancellationToken ct = default);
/// <summary>
/// Gets all enrollments for a specific subject with related entities.
/// </summary>
Task<IReadOnlyList<Enrollment>> GetBySubjectIdAsync(int subjectId, CancellationToken ct = default); Task<IReadOnlyList<Enrollment>> GetBySubjectIdAsync(int subjectId, CancellationToken ct = default);
Task<IReadOnlyList<Student>> GetClassmatesAsync(int studentId, int subjectId, CancellationToken ct = default);
Task<IReadOnlyDictionary<int, IReadOnlyList<Student>>> GetClassmatesBatchAsync( /// <summary>
/// Gets classmates (other students) enrolled in the same subject.
/// </summary>
Task<IReadOnlyList<ClassmateInfo>> GetClassmatesAsync(int studentId, int subjectId, CancellationToken ct = default);
/// <summary>
/// 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.
/// </summary>
Task<IReadOnlyDictionary<int, IReadOnlyList<ClassmateInfo>>> GetClassmatesBatchAsync(
int studentId, IEnumerable<int> subjectIds, CancellationToken ct = default); int studentId, IEnumerable<int> subjectIds, CancellationToken ct = default);
/// <summary>
/// Adds a new enrollment to the context.
/// </summary>
void Add(Enrollment enrollment); void Add(Enrollment enrollment);
/// <summary>
/// Removes an enrollment from the context.
/// </summary>
void Delete(Enrollment enrollment); void Delete(Enrollment enrollment);
} }

View File

@ -3,13 +3,29 @@ namespace Domain.Ports.Repositories;
using Domain.Entities; using Domain.Entities;
using System.Linq.Expressions; using System.Linq.Expressions;
/// <summary>
/// Repository interface for professor persistence operations.
/// </summary>
public interface IProfessorRepository public interface IProfessorRepository
{ {
/// <summary>
/// Gets a professor by ID without related entities.
/// </summary>
Task<Professor?> GetByIdAsync(int id, CancellationToken ct = default); Task<Professor?> GetByIdAsync(int id, CancellationToken ct = default);
/// <summary>
/// Gets all professors without related entities.
/// </summary>
Task<IReadOnlyList<Professor>> GetAllAsync(CancellationToken ct = default); Task<IReadOnlyList<Professor>> GetAllAsync(CancellationToken ct = default);
/// <summary>
/// Gets all professors with their subjects.
/// </summary>
Task<IReadOnlyList<Professor>> GetAllWithSubjectsAsync(CancellationToken ct = default); Task<IReadOnlyList<Professor>> GetAllWithSubjectsAsync(CancellationToken ct = default);
// Projection support /// <summary>
/// Gets all professors with custom projection to avoid over-fetching.
/// </summary>
Task<IReadOnlyList<TResult>> GetAllProjectedAsync<TResult>( Task<IReadOnlyList<TResult>> GetAllProjectedAsync<TResult>(
Expression<Func<Professor, TResult>> selector, Expression<Func<Professor, TResult>> selector,
CancellationToken ct = default); CancellationToken ct = default);

View File

@ -3,32 +3,85 @@ namespace Domain.Ports.Repositories;
using Domain.Entities; using Domain.Entities;
using System.Linq.Expressions; using System.Linq.Expressions;
/// <summary>
/// Repository interface for student persistence operations.
/// </summary>
public interface IStudentRepository public interface IStudentRepository
{ {
/// <summary>
/// Gets a student by ID without related entities.
/// </summary>
Task<Student?> GetByIdAsync(int id, CancellationToken ct = default); Task<Student?> GetByIdAsync(int id, CancellationToken ct = default);
/// <summary>
/// Gets a student by ID with enrollments and related subjects.
/// </summary>
Task<Student?> GetByIdWithEnrollmentsAsync(int id, CancellationToken ct = default); Task<Student?> GetByIdWithEnrollmentsAsync(int id, CancellationToken ct = default);
/// <summary>
/// Gets all students without related entities.
/// </summary>
Task<IReadOnlyList<Student>> GetAllAsync(CancellationToken ct = default); Task<IReadOnlyList<Student>> GetAllAsync(CancellationToken ct = default);
/// <summary>
/// Gets all students with enrollments and related subjects.
/// </summary>
Task<IReadOnlyList<Student>> GetAllWithEnrollmentsAsync(CancellationToken ct = default); Task<IReadOnlyList<Student>> GetAllWithEnrollmentsAsync(CancellationToken ct = default);
/// <summary>
/// Checks if a student exists by ID.
/// </summary>
Task<bool> ExistsAsync(int id, CancellationToken ct = default); Task<bool> ExistsAsync(int id, CancellationToken ct = default);
/// <summary>
/// Checks if an email is already in use, optionally excluding a student.
/// </summary>
/// <param name="email">The email to check.</param>
/// <param name="excludeId">Optional student ID to exclude from the check.</param>
/// <param name="ct">Cancellation token.</param>
Task<bool> EmailExistsAsync(string email, int? excludeId = null, CancellationToken ct = default); Task<bool> EmailExistsAsync(string email, int? excludeId = null, CancellationToken ct = default);
// Projection support for optimized queries /// <summary>
/// Gets all students with custom projection to avoid over-fetching.
/// </summary>
Task<IReadOnlyList<TResult>> GetAllProjectedAsync<TResult>( Task<IReadOnlyList<TResult>> GetAllProjectedAsync<TResult>(
Expression<Func<Student, TResult>> selector, Expression<Func<Student, TResult>> selector,
CancellationToken ct = default); CancellationToken ct = default);
/// <summary>
/// Gets a student by ID with custom projection.
/// </summary>
Task<TResult?> GetByIdProjectedAsync<TResult>( Task<TResult?> GetByIdProjectedAsync<TResult>(
int id, int id,
Expression<Func<Student, TResult>> selector, Expression<Func<Student, TResult>> selector,
CancellationToken ct = default); CancellationToken ct = default);
// Keyset pagination (more efficient than OFFSET) /// <summary>
/// Gets students with keyset pagination (more efficient than OFFSET).
/// </summary>
/// <param name="selector">Projection selector.</param>
/// <param name="afterId">Cursor: ID after which to start.</param>
/// <param name="pageSize">Number of items per page.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Items, next cursor (if any), and total count.</returns>
Task<(IReadOnlyList<TResult> Items, int? NextCursor, int TotalCount)> GetPagedProjectedAsync<TResult>( Task<(IReadOnlyList<TResult> Items, int? NextCursor, int TotalCount)> GetPagedProjectedAsync<TResult>(
Expression<Func<Student, TResult>> selector, Expression<Func<Student, TResult>> selector,
int? afterId = null, int? afterId = null,
int pageSize = 10, int pageSize = 10,
CancellationToken ct = default); CancellationToken ct = default);
/// <summary>
/// Adds a new student to the context.
/// </summary>
void Add(Student student); void Add(Student student);
/// <summary>
/// Marks a student as modified in the context.
/// </summary>
void Update(Student student); void Update(Student student);
/// <summary>
/// Removes a student from the context.
/// </summary>
void Delete(Student student); void Delete(Student student);
} }

View File

@ -3,15 +3,39 @@ namespace Domain.Ports.Repositories;
using Domain.Entities; using Domain.Entities;
using System.Linq.Expressions; using System.Linq.Expressions;
/// <summary>
/// Repository interface for subject persistence operations.
/// </summary>
public interface ISubjectRepository public interface ISubjectRepository
{ {
/// <summary>
/// Gets a subject by ID without related entities.
/// </summary>
Task<Subject?> GetByIdAsync(int id, CancellationToken ct = default); Task<Subject?> GetByIdAsync(int id, CancellationToken ct = default);
/// <summary>
/// Gets a subject by ID with its professor.
/// </summary>
Task<Subject?> GetByIdWithProfessorAsync(int id, CancellationToken ct = default); Task<Subject?> GetByIdWithProfessorAsync(int id, CancellationToken ct = default);
/// <summary>
/// Gets all subjects without related entities.
/// </summary>
Task<IReadOnlyList<Subject>> GetAllAsync(CancellationToken ct = default); Task<IReadOnlyList<Subject>> GetAllAsync(CancellationToken ct = default);
/// <summary>
/// Gets all subjects with their professors.
/// </summary>
Task<IReadOnlyList<Subject>> GetAllWithProfessorsAsync(CancellationToken ct = default); Task<IReadOnlyList<Subject>> GetAllWithProfessorsAsync(CancellationToken ct = default);
/// <summary>
/// Gets subjects available for a student (not enrolled, no professor conflict).
/// </summary>
Task<IReadOnlyList<Subject>> GetAvailableForStudentAsync(int studentId, CancellationToken ct = default); Task<IReadOnlyList<Subject>> GetAvailableForStudentAsync(int studentId, CancellationToken ct = default);
// Projection support /// <summary>
/// Gets all subjects with custom projection to avoid over-fetching.
/// </summary>
Task<IReadOnlyList<TResult>> GetAllProjectedAsync<TResult>( Task<IReadOnlyList<TResult>> GetAllProjectedAsync<TResult>(
Expression<Func<Subject, TResult>> selector, Expression<Func<Subject, TResult>> selector,
CancellationToken ct = default); CancellationToken ct = default);

View File

@ -0,0 +1,9 @@
namespace Domain.ReadModels;
/// <summary>
/// Minimal read-only projection of a student for classmate queries.
/// Used to avoid loading full Student entities when only Id and Name are needed.
/// </summary>
/// <param name="StudentId">The student's unique identifier.</param>
/// <param name="Name">The student's full name.</param>
public readonly record struct ClassmateInfo(int StudentId, string Name);

View File

@ -3,8 +3,20 @@ namespace Domain.Services;
using Domain.Entities; using Domain.Entities;
using Domain.Exceptions; using Domain.Exceptions;
/// <summary>
/// Domain service for enrollment business rules.
/// Contains validation logic that spans multiple aggregates.
/// </summary>
public class EnrollmentDomainService public class EnrollmentDomainService
{ {
/// <summary>
/// Validates that a student can enroll in a subject.
/// </summary>
/// <param name="student">The student attempting to enroll.</param>
/// <param name="subject">The subject to enroll in.</param>
/// <exception cref="MaxEnrollmentsExceededException">Student has reached max enrollments.</exception>
/// <exception cref="SameProfessorConstraintException">Student already has a subject with this professor.</exception>
/// <exception cref="DuplicateEnrollmentException">Student is already enrolled in this subject.</exception>
public void ValidateEnrollment(Student student, Subject subject) public void ValidateEnrollment(Student student, Subject subject)
{ {
if (!student.CanEnroll()) if (!student.CanEnroll())
@ -17,6 +29,12 @@ public class EnrollmentDomainService
throw new DuplicateEnrollmentException(student.Id, subject.Id); throw new DuplicateEnrollmentException(student.Id, subject.Id);
} }
/// <summary>
/// Creates an enrollment after validating business rules.
/// </summary>
/// <param name="student">The student to enroll.</param>
/// <param name="subject">The subject to enroll in.</param>
/// <returns>The created enrollment.</returns>
public Enrollment CreateEnrollment(Student student, Subject subject) public Enrollment CreateEnrollment(Student student, Subject subject)
{ {
ValidateEnrollment(student, subject); ValidateEnrollment(student, subject);
@ -26,4 +44,30 @@ public class EnrollmentDomainService
return enrollment; return enrollment;
} }
/// <summary>
/// Checks if a subject is available for enrollment by a student.
/// Returns availability status and reason without throwing exceptions.
/// </summary>
/// <param name="student">The student checking availability.</param>
/// <param name="subject">The subject to check.</param>
/// <returns>A tuple with availability status and optional reason if unavailable.</returns>
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);
}
} }

View File

@ -25,6 +25,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\backend\Application\Application.csproj" /> <ProjectReference Include="..\..\src\backend\Application\Application.csproj" />
<ProjectReference Include="..\Common\Common.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -4,6 +4,7 @@ using Application.Enrollments.Queries;
using Domain.Entities; using Domain.Entities;
using Domain.Exceptions; using Domain.Exceptions;
using Domain.Ports.Repositories; using Domain.Ports.Repositories;
using Domain.ReadModels;
using Domain.ValueObjects; using Domain.ValueObjects;
using FluentAssertions; using FluentAssertions;
using NSubstitute; using NSubstitute;
@ -84,12 +85,10 @@ public class GetClassmatesQueryTests
_studentRepository.GetByIdWithEnrollmentsAsync(1, Arg.Any<CancellationToken>()) _studentRepository.GetByIdWithEnrollmentsAsync(1, Arg.Any<CancellationToken>())
.Returns(student); .Returns(student);
var classmate1 = new Student("Jane Doe", Email.Create("jane@test.com")); var classmate1 = new ClassmateInfo(2, "Jane Doe");
var classmate2 = new Student("Bob Smith", Email.Create("bob@test.com")); var classmate2 = new ClassmateInfo(3, "Bob Smith");
SetEntityId(classmate1, 2);
SetEntityId(classmate2, 3);
var batchResult = new Dictionary<int, IReadOnlyList<Student>> var batchResult = new Dictionary<int, IReadOnlyList<ClassmateInfo>>
{ {
[1] = [classmate1], [1] = [classmate1],
[2] = [classmate2] [2] = [classmate2]
@ -129,7 +128,7 @@ public class GetClassmatesQueryTests
_studentRepository.GetByIdWithEnrollmentsAsync(1, Arg.Any<CancellationToken>()) _studentRepository.GetByIdWithEnrollmentsAsync(1, Arg.Any<CancellationToken>())
.Returns(student); .Returns(student);
var emptyBatchResult = new Dictionary<int, IReadOnlyList<Student>>(); var emptyBatchResult = new Dictionary<int, IReadOnlyList<ClassmateInfo>>();
_enrollmentRepository.GetClassmatesBatchAsync(1, Arg.Any<IEnumerable<int>>(), Arg.Any<CancellationToken>()) _enrollmentRepository.GetClassmatesBatchAsync(1, Arg.Any<IEnumerable<int>>(), Arg.Any<CancellationToken>())
.Returns(emptyBatchResult); .Returns(emptyBatchResult);

View File

@ -92,13 +92,15 @@ public class GetAvailableSubjectsQueryTests
{ {
private readonly IStudentRepository _studentRepository; private readonly IStudentRepository _studentRepository;
private readonly ISubjectRepository _subjectRepository; private readonly ISubjectRepository _subjectRepository;
private readonly Domain.Services.EnrollmentDomainService _enrollmentDomainService;
private readonly GetAvailableSubjectsHandler _handler; private readonly GetAvailableSubjectsHandler _handler;
public GetAvailableSubjectsQueryTests() public GetAvailableSubjectsQueryTests()
{ {
_studentRepository = Substitute.For<IStudentRepository>(); _studentRepository = Substitute.For<IStudentRepository>();
_subjectRepository = Substitute.For<ISubjectRepository>(); _subjectRepository = Substitute.For<ISubjectRepository>();
_handler = new GetAvailableSubjectsHandler(_studentRepository, _subjectRepository); _enrollmentDomainService = new Domain.Services.EnrollmentDomainService();
_handler = new GetAvailableSubjectsHandler(_studentRepository, _subjectRepository, _enrollmentDomainService);
} }
[Fact] [Fact]

View File

@ -0,0 +1,74 @@
namespace Common.Builders;
using Domain.Entities;
/// <summary>
/// Builder pattern for creating Enrollment test objects.
/// </summary>
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>(T obj, string propertyName, object value) where T : class =>
typeof(T).GetProperty(propertyName)!.SetValue(obj, value);
}

View File

@ -0,0 +1,50 @@
namespace Common.Builders;
using Domain.Entities;
/// <summary>
/// Builder pattern for creating Professor test objects.
/// </summary>
public class ProfessorBuilder
{
private int _id = 1;
private string _name = "Prof. Smith";
private readonly List<Subject> _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<Subject>)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>(T obj, string propertyName, object value) where T : class =>
typeof(T).GetProperty(propertyName)!.SetValue(obj, value);
}

View File

@ -0,0 +1,78 @@
namespace Common.Builders;
using Domain.Entities;
using Domain.ValueObjects;
/// <summary>
/// Builder pattern for creating Student test objects.
/// Encapsulates reflection-based ID setting for cleaner tests.
/// </summary>
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>(T obj, string propertyName, object value) where T : class =>
typeof(T).GetProperty(propertyName)!.SetValue(obj, value);
}

View File

@ -0,0 +1,54 @@
namespace Common.Builders;
using Domain.Entities;
/// <summary>
/// Builder pattern for creating Subject test objects.
/// Encapsulates reflection-based ID and navigation property setting.
/// </summary>
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>(T obj, string propertyName, object value) where T : class =>
typeof(T).GetProperty(propertyName)!.SetValue(obj, value);
}

View File

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\backend\Domain\Domain.csproj" />
</ItemGroup>
</Project>

View File

@ -24,6 +24,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\backend\Domain\Domain.csproj" /> <ProjectReference Include="..\..\src\backend\Domain\Domain.csproj" />
<ProjectReference Include="..\Common\Common.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>