feat(application): add CQRS application layer

Commands:
- CreateStudent, UpdateStudent, DeleteStudent
- EnrollStudent, UnenrollStudent

Queries:
- GetStudents, GetStudentById, GetStudentsPaged
- GetSubjects, GetAvailableSubjects
- GetProfessors
- GetClassmates

DTOs:
- StudentDto, SubjectDto, ProfessorDto, EnrollmentDtos

Validation:
- FluentValidation with ValidationBehavior pipeline
- EnrollStudentValidator for input validation

Uses MediatR for command/query dispatching
This commit is contained in:
Andrés Eduardo García Márquez 2026-01-07 22:59:23 -05:00
parent 2d6c08e14a
commit 68e420fdf2
21 changed files with 647 additions and 0 deletions

View File

@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Domain\Domain.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentValidation" Version="*" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="*" />
<PackageReference Include="MediatR" Version="*" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="*" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,33 @@
namespace Application.Common.Behaviors;
using FluentValidation;
using MediatR;
public class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken ct)
{
if (!validators.Any())
return await next();
var context = new ValidationContext<TRequest>(request);
var validationResults = await Task.WhenAll(
validators.Select(v => v.ValidateAsync(context, ct)));
var failures = validationResults
.SelectMany(r => r.Errors)
.Where(f => f != null)
.ToList();
if (failures.Count != 0)
throw new ValidationException(failures);
return await next();
}
}

View File

@ -0,0 +1,22 @@
namespace Application;
using Application.Common.Behaviors;
using Domain.Services;
using FluentValidation;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
public static class DependencyInjection
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
var assembly = typeof(DependencyInjection).Assembly;
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly));
services.AddValidatorsFromAssembly(assembly);
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
services.AddScoped<EnrollmentDomainService>();
return services;
}
}

View File

@ -0,0 +1,39 @@
namespace Application.Enrollments.Commands;
using Application.Students.DTOs;
using Domain.Exceptions;
using Domain.Ports.Repositories;
using Domain.Services;
using MediatR;
public record EnrollStudentCommand(int StudentId, int SubjectId) : IRequest<EnrollmentDto>;
public class EnrollStudentHandler(
IStudentRepository studentRepository,
ISubjectRepository subjectRepository,
IEnrollmentRepository enrollmentRepository,
EnrollmentDomainService enrollmentService,
IUnitOfWork unitOfWork)
: IRequestHandler<EnrollStudentCommand, EnrollmentDto>
{
public async Task<EnrollmentDto> Handle(EnrollStudentCommand request, CancellationToken ct)
{
var student = await studentRepository.GetByIdWithEnrollmentsAsync(request.StudentId, ct)
?? throw new StudentNotFoundException(request.StudentId);
var subject = await subjectRepository.GetByIdWithProfessorAsync(request.SubjectId, ct)
?? throw new SubjectNotFoundException(request.SubjectId);
var enrollment = enrollmentService.CreateEnrollment(student, subject);
enrollmentRepository.Add(enrollment);
await unitOfWork.SaveChangesAsync(ct);
return new EnrollmentDto(
enrollment.Id,
subject.Id,
subject.Name,
subject.Credits,
subject.Professor?.Name ?? "",
enrollment.EnrolledAt);
}
}

View File

@ -0,0 +1,24 @@
namespace Application.Enrollments.Commands;
using FluentValidation;
public class EnrollStudentValidator : AbstractValidator<EnrollStudentCommand>
{
public EnrollStudentValidator()
{
RuleFor(x => x.StudentId)
.GreaterThan(0).WithMessage("Invalid student ID");
RuleFor(x => x.SubjectId)
.GreaterThan(0).WithMessage("Invalid subject ID");
}
}
public class UnenrollStudentValidator : AbstractValidator<UnenrollStudentCommand>
{
public UnenrollStudentValidator()
{
RuleFor(x => x.EnrollmentId)
.GreaterThan(0).WithMessage("Invalid enrollment ID");
}
}

View File

@ -0,0 +1,28 @@
namespace Application.Enrollments.Commands;
using Domain.Exceptions;
using Domain.Ports.Repositories;
using MediatR;
public record UnenrollStudentCommand(int EnrollmentId) : IRequest<bool>;
public class UnenrollStudentHandler(
IEnrollmentRepository enrollmentRepository,
IStudentRepository studentRepository,
IUnitOfWork unitOfWork)
: IRequestHandler<UnenrollStudentCommand, bool>
{
public async Task<bool> Handle(UnenrollStudentCommand request, CancellationToken ct)
{
var enrollment = await enrollmentRepository.GetByIdAsync(request.EnrollmentId, ct)
?? throw new EnrollmentNotFoundException(request.EnrollmentId);
var student = await studentRepository.GetByIdWithEnrollmentsAsync(enrollment.StudentId, ct);
student?.RemoveEnrollment(enrollment);
enrollmentRepository.Delete(enrollment);
await unitOfWork.SaveChangesAsync(ct);
return true;
}
}

View File

@ -0,0 +1,10 @@
namespace Application.Enrollments.DTOs;
public record EnrollStudentRequest(int StudentId, int SubjectId);
public record ClassmatesBySubjectDto(
int SubjectId,
string SubjectName,
IReadOnlyList<ClassmateDto> Classmates);
public record ClassmateDto(int StudentId, string Name);

View File

@ -0,0 +1,38 @@
namespace Application.Enrollments.Queries;
using Application.Enrollments.DTOs;
using Domain.Exceptions;
using Domain.Ports.Repositories;
using MediatR;
public record GetClassmatesQuery(int StudentId) : IRequest<IReadOnlyList<ClassmatesBySubjectDto>>;
public class GetClassmatesHandler(
IStudentRepository studentRepository,
IEnrollmentRepository enrollmentRepository)
: IRequestHandler<GetClassmatesQuery, IReadOnlyList<ClassmatesBySubjectDto>>
{
public async Task<IReadOnlyList<ClassmatesBySubjectDto>> Handle(
GetClassmatesQuery request,
CancellationToken ct)
{
var student = await studentRepository.GetByIdWithEnrollmentsAsync(request.StudentId, ct)
?? throw new StudentNotFoundException(request.StudentId);
if (student.Enrollments.Count == 0)
return [];
// Single batch query instead of N queries (eliminates N+1)
var subjectIds = student.Enrollments.Select(e => e.SubjectId);
var classmatesBySubject = await enrollmentRepository
.GetClassmatesBatchAsync(request.StudentId, subjectIds, ct);
return student.Enrollments.Select(enrollment => new ClassmatesBySubjectDto(
enrollment.SubjectId,
enrollment.Subject?.Name ?? "",
classmatesBySubject.TryGetValue(enrollment.SubjectId, out var classmates)
? classmates.Select(c => new ClassmateDto(c.Id, c.Name)).ToList()
: []
)).ToList();
}
}

View File

@ -0,0 +1,8 @@
namespace Application.Professors.DTOs;
public record ProfessorDto(
int Id,
string Name,
IReadOnlyList<ProfessorSubjectDto> Subjects);
public record ProfessorSubjectDto(int Id, string Name, int Credits);

View File

@ -0,0 +1,22 @@
namespace Application.Professors.Queries;
using Application.Professors.DTOs;
using Domain.Ports.Repositories;
using MediatR;
public record GetProfessorsQuery : IRequest<IReadOnlyList<ProfessorDto>>;
public class GetProfessorsHandler(IProfessorRepository professorRepository)
: IRequestHandler<GetProfessorsQuery, IReadOnlyList<ProfessorDto>>
{
public async Task<IReadOnlyList<ProfessorDto>> Handle(GetProfessorsQuery request, CancellationToken ct)
{
// Direct projection - only SELECT needed columns
return await professorRepository.GetAllProjectedAsync(
p => new ProfessorDto(
p.Id,
p.Name,
p.Subjects.Select(s => new ProfessorSubjectDto(s.Id, s.Name, s.Credits)).ToList()
), ct);
}
}

View File

@ -0,0 +1,26 @@
namespace Application.Students.Commands;
using Application.Students.DTOs;
using Domain.Entities;
using Domain.Ports.Repositories;
using Domain.ValueObjects;
using MediatR;
public record CreateStudentCommand(string Name, string Email) : IRequest<StudentDto>;
public class CreateStudentHandler(
IStudentRepository studentRepository,
IUnitOfWork unitOfWork)
: IRequestHandler<CreateStudentCommand, StudentDto>
{
public async Task<StudentDto> Handle(CreateStudentCommand request, CancellationToken ct)
{
var email = Email.Create(request.Email);
var student = new Student(request.Name, email);
studentRepository.Add(student);
await unitOfWork.SaveChangesAsync(ct);
return new StudentDto(student.Id, student.Name, student.Email.Value, 0, []);
}
}

View File

@ -0,0 +1,70 @@
namespace Application.Students.Commands;
using System.Text.RegularExpressions;
using Domain.Ports.Repositories;
using FluentValidation;
public partial 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)
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Name is required")
.MinimumLength(3).WithMessage("Name must be at least 3 characters")
.MaximumLength(100).WithMessage("Name must not exceed 100 characters")
.Matches(SafeNamePattern()).WithMessage("Name contains invalid characters")
.Must(NotContainDangerousContent).WithMessage("Name contains prohibited content");
RuleFor(x => x.Email)
.NotEmpty().WithMessage("Email is required")
.MaximumLength(254).WithMessage("Email must not exceed 254 characters")
.EmailAddress().WithMessage("Invalid email format")
.Must(NotContainDangerousContent).WithMessage("Email contains prohibited content")
.MustAsync(async (email, ct) =>
!await studentRepository.EmailExistsAsync(email, null, ct))
.WithMessage("Email already exists");
}
private static bool NotContainDangerousContent(string? value) =>
string.IsNullOrEmpty(value) || !DangerousPattern().IsMatch(value);
}
public partial 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)
{
RuleFor(x => x.Id)
.GreaterThan(0).WithMessage("Invalid student ID");
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Name is required")
.MinimumLength(3).WithMessage("Name must be at least 3 characters")
.MaximumLength(100).WithMessage("Name must not exceed 100 characters")
.Matches(SafeNamePattern()).WithMessage("Name contains invalid characters")
.Must(NotContainDangerousContent).WithMessage("Name contains prohibited content");
RuleFor(x => x.Email)
.NotEmpty().WithMessage("Email is required")
.MaximumLength(254).WithMessage("Email must not exceed 254 characters")
.EmailAddress().WithMessage("Invalid email format")
.Must(NotContainDangerousContent).WithMessage("Email contains prohibited content")
.MustAsync(async (command, email, ct) =>
!await studentRepository.EmailExistsAsync(email, command.Id, ct))
.WithMessage("Email already exists");
}
private static bool NotContainDangerousContent(string? value) =>
string.IsNullOrEmpty(value) || !DangerousPattern().IsMatch(value);
}

View File

@ -0,0 +1,24 @@
namespace Application.Students.Commands;
using Domain.Exceptions;
using Domain.Ports.Repositories;
using MediatR;
public record DeleteStudentCommand(int Id) : IRequest<bool>;
public class DeleteStudentHandler(
IStudentRepository studentRepository,
IUnitOfWork unitOfWork)
: IRequestHandler<DeleteStudentCommand, bool>
{
public async Task<bool> Handle(DeleteStudentCommand request, CancellationToken ct)
{
var student = await studentRepository.GetByIdAsync(request.Id, ct)
?? throw new StudentNotFoundException(request.Id);
studentRepository.Delete(student);
await unitOfWork.SaveChangesAsync(ct);
return true;
}
}

View File

@ -0,0 +1,41 @@
namespace Application.Students.Commands;
using Application.Students.DTOs;
using Domain.Exceptions;
using Domain.Ports.Repositories;
using Domain.ValueObjects;
using MediatR;
public record UpdateStudentCommand(int Id, string Name, string Email) : IRequest<StudentDto>;
public class UpdateStudentHandler(
IStudentRepository studentRepository,
IUnitOfWork unitOfWork)
: IRequestHandler<UpdateStudentCommand, StudentDto>
{
public async Task<StudentDto> Handle(UpdateStudentCommand request, CancellationToken ct)
{
var student = await studentRepository.GetByIdWithEnrollmentsAsync(request.Id, ct)
?? throw new StudentNotFoundException(request.Id);
student.UpdateName(request.Name);
student.UpdateEmail(Email.Create(request.Email));
studentRepository.Update(student);
await unitOfWork.SaveChangesAsync(ct);
return new StudentDto(
student.Id,
student.Name,
student.Email.Value,
student.TotalCredits,
student.Enrollments.Select(e => new EnrollmentDto(
e.Id,
e.SubjectId,
e.Subject?.Name ?? "",
e.Subject?.Credits ?? 3,
e.Subject?.Professor?.Name ?? "",
e.EnrolledAt)).ToList()
);
}
}

View File

@ -0,0 +1,33 @@
namespace Application.Students.DTOs;
public record StudentDto(
int Id,
string Name,
string Email,
int TotalCredits,
IReadOnlyList<EnrollmentDto> Enrollments);
public record EnrollmentDto(
int Id,
int SubjectId,
string SubjectName,
int Credits,
string ProfessorName,
DateTime EnrolledAt);
public record CreateStudentRequest(string Name, string Email);
public record UpdateStudentRequest(string Name, string Email);
// Pagination DTOs
public record PagedResult<T>(
IReadOnlyList<T> Items,
int? NextCursor,
int TotalCount,
bool HasNextPage);
public record StudentPagedDto(
int Id,
string Name,
string Email,
int TotalCredits);

View File

@ -0,0 +1,35 @@
namespace Application.Students.Queries;
using Application.Students.DTOs;
using Domain.Entities;
using Domain.Exceptions;
using Domain.Ports.Repositories;
using MediatR;
public record GetStudentByIdQuery(int Id) : IRequest<StudentDto>;
public class GetStudentByIdHandler(IStudentRepository studentRepository)
: IRequestHandler<GetStudentByIdQuery, StudentDto>
{
public async Task<StudentDto> Handle(GetStudentByIdQuery request, CancellationToken ct)
{
// Direct projection - only SELECT needed columns
var dto = await studentRepository.GetByIdProjectedAsync(
request.Id,
s => new StudentDto(
s.Id,
s.Name,
s.Email.Value,
s.Enrollments.Sum(e => e.Subject != null ? e.Subject.Credits : Subject.CreditsPerSubject),
s.Enrollments.Select(e => new EnrollmentDto(
e.Id,
e.SubjectId,
e.Subject != null ? e.Subject.Name : "",
e.Subject != null ? e.Subject.Credits : Subject.CreditsPerSubject,
e.Subject != null && e.Subject.Professor != null ? e.Subject.Professor.Name : "",
e.EnrolledAt)).ToList()
), ct);
return dto ?? throw new StudentNotFoundException(request.Id);
}
}

View File

@ -0,0 +1,39 @@
namespace Application.Students.Queries;
using Application.Students.DTOs;
using Domain.Entities;
using Domain.Ports.Repositories;
using MediatR;
public record GetStudentsPagedQuery(int? AfterCursor = null, int PageSize = 10)
: IRequest<PagedResult<StudentPagedDto>>;
public class GetStudentsPagedHandler(IStudentRepository studentRepository)
: IRequestHandler<GetStudentsPagedQuery, PagedResult<StudentPagedDto>>
{
private const int MaxPageSize = 50;
public async Task<PagedResult<StudentPagedDto>> Handle(
GetStudentsPagedQuery request,
CancellationToken ct)
{
var pageSize = Math.Min(request.PageSize, MaxPageSize);
var (items, nextCursor, totalCount) = await studentRepository.GetPagedProjectedAsync(
s => new StudentPagedDto(
s.Id,
s.Name,
s.Email.Value,
s.Enrollments.Sum(e => e.Subject != null ? e.Subject.Credits : Subject.CreditsPerSubject)
),
request.AfterCursor,
pageSize,
ct);
return new PagedResult<StudentPagedDto>(
items,
nextCursor,
totalCount,
nextCursor.HasValue);
}
}

View File

@ -0,0 +1,33 @@
namespace Application.Students.Queries;
using Application.Students.DTOs;
using Domain.Entities;
using Domain.Ports.Repositories;
using MediatR;
public record GetStudentsQuery : IRequest<IReadOnlyList<StudentDto>>;
public class GetStudentsHandler(IStudentRepository studentRepository)
: IRequestHandler<GetStudentsQuery, IReadOnlyList<StudentDto>>
{
public async Task<IReadOnlyList<StudentDto>> Handle(
GetStudentsQuery request,
CancellationToken ct)
{
// Direct projection - only SELECT needed columns, no entity materialization
return await studentRepository.GetAllProjectedAsync(
s => new StudentDto(
s.Id,
s.Name,
s.Email.Value,
s.Enrollments.Sum(e => e.Subject != null ? e.Subject.Credits : Subject.CreditsPerSubject),
s.Enrollments.Select(e => new EnrollmentDto(
e.Id,
e.SubjectId,
e.Subject != null ? e.Subject.Name : "",
e.Subject != null ? e.Subject.Credits : Subject.CreditsPerSubject,
e.Subject != null && e.Subject.Professor != null ? e.Subject.Professor.Name : "",
e.EnrolledAt)).ToList()
), ct);
}
}

View File

@ -0,0 +1,17 @@
namespace Application.Subjects.DTOs;
public record SubjectDto(
int Id,
string Name,
int Credits,
int ProfessorId,
string ProfessorName);
public record AvailableSubjectDto(
int Id,
string Name,
int Credits,
int ProfessorId,
string ProfessorName,
bool IsAvailable,
string? UnavailableReason);

View File

@ -0,0 +1,62 @@
namespace Application.Subjects.Queries;
using Application.Subjects.DTOs;
using Domain.Entities;
using Domain.Exceptions;
using Domain.Ports.Repositories;
using MediatR;
public record GetAvailableSubjectsQuery(int StudentId) : IRequest<IReadOnlyList<AvailableSubjectDto>>;
public class GetAvailableSubjectsHandler(
IStudentRepository studentRepository,
ISubjectRepository subjectRepository)
: IRequestHandler<GetAvailableSubjectsQuery, IReadOnlyList<AvailableSubjectDto>>
{
public async Task<IReadOnlyList<AvailableSubjectDto>> Handle(
GetAvailableSubjectsQuery request,
CancellationToken ct)
{
var student = await studentRepository.GetByIdWithEnrollmentsAsync(request.StudentId, ct)
?? throw new StudentNotFoundException(request.StudentId);
var allSubjects = await subjectRepository.GetAllWithProfessorsAsync(ct);
var enrolledSubjectIds = student.Enrollments.Select(e => e.SubjectId).ToHashSet();
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);
return new AvailableSubjectDto(
s.Id,
s.Name,
s.Credits,
s.ProfessorId,
s.Professor?.Name ?? "",
isAvailable,
reason);
}).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,23 @@
namespace Application.Subjects.Queries;
using Application.Subjects.DTOs;
using Domain.Ports.Repositories;
using MediatR;
public record GetSubjectsQuery : IRequest<IReadOnlyList<SubjectDto>>;
public class GetSubjectsHandler(ISubjectRepository subjectRepository)
: IRequestHandler<GetSubjectsQuery, IReadOnlyList<SubjectDto>>
{
public async Task<IReadOnlyList<SubjectDto>> Handle(GetSubjectsQuery request, CancellationToken ct)
{
// Direct projection - only SELECT needed columns
return await subjectRepository.GetAllProjectedAsync(
s => new SubjectDto(
s.Id,
s.Name,
s.Credits,
s.ProfessorId,
s.Professor != null ? s.Professor.Name : ""), ct);
}
}