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 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
5568d1c60a
commit
ffc879fe8a
|
|
@ -0,0 +1,9 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Subject> _subjects = [];
|
||||
public IReadOnlyCollection<Subject> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Enrollment> _enrollments = [];
|
||||
public IReadOnlyCollection<Enrollment> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Enrollment> _enrollments = [];
|
||||
public IReadOnlyCollection<Enrollment> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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}") { }
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
namespace Domain.Ports.Repositories;
|
||||
|
||||
using Domain.Entities;
|
||||
|
||||
public interface IEnrollmentRepository
|
||||
{
|
||||
Task<Enrollment?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||
Task<Enrollment?> GetByStudentAndSubjectAsync(int studentId, int subjectId, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<Enrollment>> GetByStudentIdAsync(int studentId, 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(
|
||||
int studentId, IEnumerable<int> subjectIds, CancellationToken ct = default);
|
||||
void Add(Enrollment enrollment);
|
||||
void Delete(Enrollment enrollment);
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
namespace Domain.Ports.Repositories;
|
||||
|
||||
using Domain.Entities;
|
||||
using System.Linq.Expressions;
|
||||
|
||||
public interface IProfessorRepository
|
||||
{
|
||||
Task<Professor?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<Professor>> GetAllAsync(CancellationToken ct = default);
|
||||
Task<IReadOnlyList<Professor>> GetAllWithSubjectsAsync(CancellationToken ct = default);
|
||||
|
||||
// Projection support
|
||||
Task<IReadOnlyList<TResult>> GetAllProjectedAsync<TResult>(
|
||||
Expression<Func<Professor, TResult>> selector,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
namespace Domain.Ports.Repositories;
|
||||
|
||||
using Domain.Entities;
|
||||
using System.Linq.Expressions;
|
||||
|
||||
public interface IStudentRepository
|
||||
{
|
||||
Task<Student?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||
Task<Student?> GetByIdWithEnrollmentsAsync(int id, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<Student>> GetAllAsync(CancellationToken ct = default);
|
||||
Task<IReadOnlyList<Student>> GetAllWithEnrollmentsAsync(CancellationToken ct = default);
|
||||
Task<bool> ExistsAsync(int id, CancellationToken ct = default);
|
||||
Task<bool> EmailExistsAsync(string email, int? excludeId = null, CancellationToken ct = default);
|
||||
|
||||
// Projection support for optimized queries
|
||||
Task<IReadOnlyList<TResult>> GetAllProjectedAsync<TResult>(
|
||||
Expression<Func<Student, TResult>> selector,
|
||||
CancellationToken ct = default);
|
||||
Task<TResult?> GetByIdProjectedAsync<TResult>(
|
||||
int id,
|
||||
Expression<Func<Student, TResult>> selector,
|
||||
CancellationToken ct = default);
|
||||
|
||||
// Keyset pagination (more efficient than OFFSET)
|
||||
Task<(IReadOnlyList<TResult> Items, int? NextCursor, int TotalCount)> GetPagedProjectedAsync<TResult>(
|
||||
Expression<Func<Student, TResult>> selector,
|
||||
int? afterId = null,
|
||||
int pageSize = 10,
|
||||
CancellationToken ct = default);
|
||||
|
||||
void Add(Student student);
|
||||
void Update(Student student);
|
||||
void Delete(Student student);
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
namespace Domain.Ports.Repositories;
|
||||
|
||||
using Domain.Entities;
|
||||
using System.Linq.Expressions;
|
||||
|
||||
public interface ISubjectRepository
|
||||
{
|
||||
Task<Subject?> GetByIdAsync(int id, CancellationToken ct = default);
|
||||
Task<Subject?> GetByIdWithProfessorAsync(int id, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<Subject>> GetAllAsync(CancellationToken ct = default);
|
||||
Task<IReadOnlyList<Subject>> GetAllWithProfessorsAsync(CancellationToken ct = default);
|
||||
Task<IReadOnlyList<Subject>> GetAvailableForStudentAsync(int studentId, CancellationToken ct = default);
|
||||
|
||||
// Projection support
|
||||
Task<IReadOnlyList<TResult>> GetAllProjectedAsync<TResult>(
|
||||
Expression<Func<Subject, TResult>> selector,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
namespace Domain.Ports.Repositories;
|
||||
|
||||
public interface IUnitOfWork
|
||||
{
|
||||
Task<int> SaveChangesAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
Loading…
Reference in New Issue