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
This commit is contained in:
parent
dfcfca0b4e
commit
2d6c08e14a
|
|
@ -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