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:
Andrés Eduardo García Márquez 2026-01-07 22:59:10 -05:00
parent dfcfca0b4e
commit 2d6c08e14a
13 changed files with 338 additions and 0 deletions

View File

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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}") { }
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -0,0 +1,6 @@
namespace Domain.Ports.Repositories;
public interface IUnitOfWork
{
Task<int> SaveChangesAsync(CancellationToken ct = default);
}

View File

@ -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;
}
}

View File

@ -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();
}