academia/docs/entregables/02-diseno/modelo-dominio/DI-002-modelo-dominio.md

8.9 KiB

DI-002: Modelo de Dominio

Proyecto: Sistema de Registro de Estudiantes Fecha: 2026-01-07


1. Diagrama de Entidades

┌─────────────────┐       ┌─────────────────────────┐
│    PROFESSOR    │       │        STUDENT          │
├─────────────────┤       ├─────────────────────────┤
│ Id: int (PK)    │       │ Id: int (PK)            │
│ Name: string    │       │ Name: string            │
└────────┬────────┘       │ Email: Email            │
         │                │ ActivationCodeHash?     │  ← Nuevo
         │ 1:2            │ ActivationExpiresAt?    │  ← Nuevo
         ▼                │ IsActivated (computed)  │  ← Nuevo
┌─────────────────┐       │ RowVersion              │
│     SUBJECT     │       └────────┬────────────────┘
├─────────────────┤                │
│ Id: int (PK)    │                │ 0..3
│ Name: string    │                ▼
│ Credits: 3      │       ┌─────────────────┐
│ ProfessorId: FK │◄──────│   ENROLLMENT    │
└─────────────────┘  1:N  ├─────────────────┤
                          │ Id: int (PK)    │
┌─────────────────┐       │ StudentId: FK   │
│      USER       │       │ SubjectId: FK   │
├─────────────────┤       │ EnrolledAt      │
│ Id: int (PK)    │       └─────────────────┘
│ Username        │
│ PasswordHash    │
│ RecoveryCodeHash│
│ Role (Admin/    │
│       Student)  │
│ StudentId?: FK  │───────► 0..1 Student
│ CreatedAt       │
│ LastLoginAt?    │
└─────────────────┘

2. Entidades

Student (Aggregate Root)

public class Student
{
    public const int MaxEnrollments = 3;

    public int Id { get; private set; }
    public string Name { get; private set; }
    public Email Email { get; private set; }

    // Campos de Activación (nuevo flujo)
    public string? ActivationCodeHash { get; private set; }
    public DateTime? ActivationExpiresAt { get; private set; }
    public bool IsActivated => ActivationCodeHash == null;

    private readonly List<Enrollment> _enrollments = new();
    public IReadOnlyCollection<Enrollment> Enrollments => _enrollments;

    public int TotalCredits => _enrollments.Count * 3;

    public void Enroll(Subject subject, IEnrollmentPolicy policy)
    {
        policy.Validate(this, subject);
        _enrollments.Add(new Enrollment(this, subject));
    }

    public void Unenroll(int subjectId)
    {
        var enrollment = _enrollments.FirstOrDefault(e => e.SubjectId == subjectId);
        if (enrollment != null) _enrollments.Remove(enrollment);
    }

    // Métodos de activación
    public void SetActivationCode(string codeHash, TimeSpan expiresIn)
    {
        ActivationCodeHash = codeHash;
        ActivationExpiresAt = DateTime.UtcNow.Add(expiresIn);
    }

    public void ClearActivationCode()
    {
        ActivationCodeHash = null;
        ActivationExpiresAt = null;
    }

    public bool IsActivationExpired() =>
        ActivationExpiresAt.HasValue && DateTime.UtcNow > ActivationExpiresAt.Value;
}

User (Autenticación)

public class User
{
    public int Id { get; private set; }
    public string Username { get; private set; }      // Almacenado en minúsculas
    public string PasswordHash { get; private set; }  // PBKDF2-SHA256
    public string RecoveryCodeHash { get; private set; }
    public string Role { get; private set; }          // "Admin" | "Student"
    public int? StudentId { get; private set; }       // FK opcional a Student
    public DateTime CreatedAt { get; private set; }
    public DateTime? LastLoginAt { get; private set; }

    public Student? Student { get; private set; }     // Navegación
}

Subject

public class Subject
{
    public int Id { get; private set; }
    public string Name { get; private set; }
    public int Credits { get; } = 3;  // Constante: RN-002
    public int ProfessorId { get; private set; }
    public Professor Professor { get; private set; }
}

Professor

public class Professor
{
    public int Id { get; private set; }
    public string Name { get; private set; }

    private readonly List<Subject> _subjects = new();
    public IReadOnlyCollection<Subject> Subjects => _subjects;
}

Enrollment

public class Enrollment
{
    public int Id { get; private set; }
    public int StudentId { get; private set; }
    public int SubjectId { get; private set; }
    public DateTime EnrolledAt { get; private set; }

    public Student Student { get; private set; }
    public Subject Subject { get; private set; }
}

3. Value Objects

Email

public record Email
{
    public string Value { get; }

    private Email(string value) => Value = value;

    public static Result<Email> Create(string email)
    {
        if (string.IsNullOrWhiteSpace(email))
            return Result.Failure<Email>("Email requerido");

        if (!Regex.IsMatch(email, @"^[^@\s]+@[^@\s]+\.[^@\s]+$"))
            return Result.Failure<Email>("Formato de email inválido");

        return Result.Success(new Email(email.ToLowerInvariant()));
    }
}

4. Domain Service

EnrollmentDomainService (Implementa RN-003 y RN-005)

public class EnrollmentDomainService : IEnrollmentPolicy
{
    public void Validate(Student student, Subject newSubject)
    {
        // RN-003: Máximo 3 materias
        if (student.Enrollments.Count >= 3)
            throw new MaxEnrollmentsExceededException(student.Id);

        // RN-005: No repetir profesor
        var professorIds = student.Enrollments
            .Select(e => e.Subject.ProfessorId)
            .ToHashSet();

        if (professorIds.Contains(newSubject.ProfessorId))
            throw new SameProfessorConstraintException(
                student.Id,
                newSubject.ProfessorId);
    }
}

5. Excepciones de Dominio

public class MaxEnrollmentsExceededException : DomainException
{
    public MaxEnrollmentsExceededException(int studentId)
        : base($"Estudiante {studentId} ya tiene 3 materias inscritas") { }
}

public class SameProfessorConstraintException : DomainException
{
    public SameProfessorConstraintException(int studentId, int professorId)
        : base($"Estudiante {studentId} ya tiene materia con profesor {professorId}") { }
}

6. Invariantes del Dominio

Invariante Entidad Validación
Email único Student DB Constraint + Validator
Email válido Student Value Object
Max 3 inscripciones Student Domain Service
No repetir profesor Student Domain Service
3 créditos/materia Subject Constante
2 materias/profesor Professor Seed Data
Username único User DB Constraint
Código activación expira Student ActivationExpiresAt
Student ↔ User (1:0..1) User StudentId nullable

7. Flujo de Activación

┌──────────────┐    ┌──────────────┐    ┌──────────────┐
│    ADMIN     │    │   STUDENT    │    │     USER     │
│  (creates)   │───►│ (pending)    │    │  (not yet)   │
└──────────────┘    └──────┬───────┘    └──────────────┘
                           │
                    SetActivationCode()
                           │
                           ▼
                    ┌──────────────┐
                    │   STUDENT    │
                    │ (has code)   │
                    └──────┬───────┘
                           │
                    Activate(username, password)
                           │
                           ▼
┌──────────────┐    ┌──────────────┐    ┌──────────────┐
│    ADMIN     │    │   STUDENT    │◄───│     USER     │
│  (creates)   │    │ (activated)  │    │ StudentId=X  │
└──────────────┘    └──────────────┘    └──────────────┘