diff --git a/src/backend/Application/Application.csproj b/src/backend/Application/Application.csproj
new file mode 100644
index 0000000..f1c4b91
--- /dev/null
+++ b/src/backend/Application/Application.csproj
@@ -0,0 +1,20 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/backend/Application/Common/Behaviors/ValidationBehavior.cs b/src/backend/Application/Common/Behaviors/ValidationBehavior.cs
new file mode 100644
index 0000000..7edad0b
--- /dev/null
+++ b/src/backend/Application/Common/Behaviors/ValidationBehavior.cs
@@ -0,0 +1,33 @@
+namespace Application.Common.Behaviors;
+
+using FluentValidation;
+using MediatR;
+
+public class ValidationBehavior(IEnumerable> validators)
+ : IPipelineBehavior
+ where TRequest : notnull
+{
+ public async Task Handle(
+ TRequest request,
+ RequestHandlerDelegate next,
+ CancellationToken ct)
+ {
+ if (!validators.Any())
+ return await next();
+
+ var context = new ValidationContext(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();
+ }
+}
diff --git a/src/backend/Application/DependencyInjection.cs b/src/backend/Application/DependencyInjection.cs
new file mode 100644
index 0000000..2278412
--- /dev/null
+++ b/src/backend/Application/DependencyInjection.cs
@@ -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();
+
+ return services;
+ }
+}
diff --git a/src/backend/Application/Enrollments/Commands/EnrollStudentCommand.cs b/src/backend/Application/Enrollments/Commands/EnrollStudentCommand.cs
new file mode 100644
index 0000000..d1672fb
--- /dev/null
+++ b/src/backend/Application/Enrollments/Commands/EnrollStudentCommand.cs
@@ -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;
+
+public class EnrollStudentHandler(
+ IStudentRepository studentRepository,
+ ISubjectRepository subjectRepository,
+ IEnrollmentRepository enrollmentRepository,
+ EnrollmentDomainService enrollmentService,
+ IUnitOfWork unitOfWork)
+ : IRequestHandler
+{
+ public async Task 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);
+ }
+}
diff --git a/src/backend/Application/Enrollments/Commands/EnrollStudentValidator.cs b/src/backend/Application/Enrollments/Commands/EnrollStudentValidator.cs
new file mode 100644
index 0000000..3fac178
--- /dev/null
+++ b/src/backend/Application/Enrollments/Commands/EnrollStudentValidator.cs
@@ -0,0 +1,24 @@
+namespace Application.Enrollments.Commands;
+
+using FluentValidation;
+
+public class EnrollStudentValidator : AbstractValidator
+{
+ 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
+{
+ public UnenrollStudentValidator()
+ {
+ RuleFor(x => x.EnrollmentId)
+ .GreaterThan(0).WithMessage("Invalid enrollment ID");
+ }
+}
diff --git a/src/backend/Application/Enrollments/Commands/UnenrollStudentCommand.cs b/src/backend/Application/Enrollments/Commands/UnenrollStudentCommand.cs
new file mode 100644
index 0000000..a52d1a0
--- /dev/null
+++ b/src/backend/Application/Enrollments/Commands/UnenrollStudentCommand.cs
@@ -0,0 +1,28 @@
+namespace Application.Enrollments.Commands;
+
+using Domain.Exceptions;
+using Domain.Ports.Repositories;
+using MediatR;
+
+public record UnenrollStudentCommand(int EnrollmentId) : IRequest;
+
+public class UnenrollStudentHandler(
+ IEnrollmentRepository enrollmentRepository,
+ IStudentRepository studentRepository,
+ IUnitOfWork unitOfWork)
+ : IRequestHandler
+{
+ public async Task 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;
+ }
+}
diff --git a/src/backend/Application/Enrollments/DTOs/EnrollmentDtos.cs b/src/backend/Application/Enrollments/DTOs/EnrollmentDtos.cs
new file mode 100644
index 0000000..48df6be
--- /dev/null
+++ b/src/backend/Application/Enrollments/DTOs/EnrollmentDtos.cs
@@ -0,0 +1,10 @@
+namespace Application.Enrollments.DTOs;
+
+public record EnrollStudentRequest(int StudentId, int SubjectId);
+
+public record ClassmatesBySubjectDto(
+ int SubjectId,
+ string SubjectName,
+ IReadOnlyList Classmates);
+
+public record ClassmateDto(int StudentId, string Name);
diff --git a/src/backend/Application/Enrollments/Queries/GetClassmatesQuery.cs b/src/backend/Application/Enrollments/Queries/GetClassmatesQuery.cs
new file mode 100644
index 0000000..1b7ae3f
--- /dev/null
+++ b/src/backend/Application/Enrollments/Queries/GetClassmatesQuery.cs
@@ -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>;
+
+public class GetClassmatesHandler(
+ IStudentRepository studentRepository,
+ IEnrollmentRepository enrollmentRepository)
+ : IRequestHandler>
+{
+ public async Task> 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();
+ }
+}
diff --git a/src/backend/Application/Professors/DTOs/ProfessorDto.cs b/src/backend/Application/Professors/DTOs/ProfessorDto.cs
new file mode 100644
index 0000000..aa61fcb
--- /dev/null
+++ b/src/backend/Application/Professors/DTOs/ProfessorDto.cs
@@ -0,0 +1,8 @@
+namespace Application.Professors.DTOs;
+
+public record ProfessorDto(
+ int Id,
+ string Name,
+ IReadOnlyList Subjects);
+
+public record ProfessorSubjectDto(int Id, string Name, int Credits);
diff --git a/src/backend/Application/Professors/Queries/GetProfessorsQuery.cs b/src/backend/Application/Professors/Queries/GetProfessorsQuery.cs
new file mode 100644
index 0000000..076882d
--- /dev/null
+++ b/src/backend/Application/Professors/Queries/GetProfessorsQuery.cs
@@ -0,0 +1,22 @@
+namespace Application.Professors.Queries;
+
+using Application.Professors.DTOs;
+using Domain.Ports.Repositories;
+using MediatR;
+
+public record GetProfessorsQuery : IRequest>;
+
+public class GetProfessorsHandler(IProfessorRepository professorRepository)
+ : IRequestHandler>
+{
+ public async Task> 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);
+ }
+}
diff --git a/src/backend/Application/Students/Commands/CreateStudentCommand.cs b/src/backend/Application/Students/Commands/CreateStudentCommand.cs
new file mode 100644
index 0000000..f0d68f8
--- /dev/null
+++ b/src/backend/Application/Students/Commands/CreateStudentCommand.cs
@@ -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;
+
+public class CreateStudentHandler(
+ IStudentRepository studentRepository,
+ IUnitOfWork unitOfWork)
+ : IRequestHandler
+{
+ public async Task 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, []);
+ }
+}
diff --git a/src/backend/Application/Students/Commands/CreateStudentValidator.cs b/src/backend/Application/Students/Commands/CreateStudentValidator.cs
new file mode 100644
index 0000000..e371de4
--- /dev/null
+++ b/src/backend/Application/Students/Commands/CreateStudentValidator.cs
@@ -0,0 +1,70 @@
+namespace Application.Students.Commands;
+
+using System.Text.RegularExpressions;
+using Domain.Ports.Repositories;
+using FluentValidation;
+
+public partial class CreateStudentValidator : AbstractValidator
+{
+ [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
+{
+ [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);
+}
diff --git a/src/backend/Application/Students/Commands/DeleteStudentCommand.cs b/src/backend/Application/Students/Commands/DeleteStudentCommand.cs
new file mode 100644
index 0000000..ab4a7c9
--- /dev/null
+++ b/src/backend/Application/Students/Commands/DeleteStudentCommand.cs
@@ -0,0 +1,24 @@
+namespace Application.Students.Commands;
+
+using Domain.Exceptions;
+using Domain.Ports.Repositories;
+using MediatR;
+
+public record DeleteStudentCommand(int Id) : IRequest;
+
+public class DeleteStudentHandler(
+ IStudentRepository studentRepository,
+ IUnitOfWork unitOfWork)
+ : IRequestHandler
+{
+ public async Task 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;
+ }
+}
diff --git a/src/backend/Application/Students/Commands/UpdateStudentCommand.cs b/src/backend/Application/Students/Commands/UpdateStudentCommand.cs
new file mode 100644
index 0000000..9823473
--- /dev/null
+++ b/src/backend/Application/Students/Commands/UpdateStudentCommand.cs
@@ -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;
+
+public class UpdateStudentHandler(
+ IStudentRepository studentRepository,
+ IUnitOfWork unitOfWork)
+ : IRequestHandler
+{
+ public async Task 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()
+ );
+ }
+}
diff --git a/src/backend/Application/Students/DTOs/StudentDto.cs b/src/backend/Application/Students/DTOs/StudentDto.cs
new file mode 100644
index 0000000..3090252
--- /dev/null
+++ b/src/backend/Application/Students/DTOs/StudentDto.cs
@@ -0,0 +1,33 @@
+namespace Application.Students.DTOs;
+
+public record StudentDto(
+ int Id,
+ string Name,
+ string Email,
+ int TotalCredits,
+ IReadOnlyList 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(
+ IReadOnlyList Items,
+ int? NextCursor,
+ int TotalCount,
+ bool HasNextPage);
+
+public record StudentPagedDto(
+ int Id,
+ string Name,
+ string Email,
+ int TotalCredits);
diff --git a/src/backend/Application/Students/Queries/GetStudentByIdQuery.cs b/src/backend/Application/Students/Queries/GetStudentByIdQuery.cs
new file mode 100644
index 0000000..b0496d4
--- /dev/null
+++ b/src/backend/Application/Students/Queries/GetStudentByIdQuery.cs
@@ -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;
+
+public class GetStudentByIdHandler(IStudentRepository studentRepository)
+ : IRequestHandler
+{
+ public async Task 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);
+ }
+}
diff --git a/src/backend/Application/Students/Queries/GetStudentsPagedQuery.cs b/src/backend/Application/Students/Queries/GetStudentsPagedQuery.cs
new file mode 100644
index 0000000..9404441
--- /dev/null
+++ b/src/backend/Application/Students/Queries/GetStudentsPagedQuery.cs
@@ -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>;
+
+public class GetStudentsPagedHandler(IStudentRepository studentRepository)
+ : IRequestHandler>
+{
+ private const int MaxPageSize = 50;
+
+ public async Task> 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(
+ items,
+ nextCursor,
+ totalCount,
+ nextCursor.HasValue);
+ }
+}
diff --git a/src/backend/Application/Students/Queries/GetStudentsQuery.cs b/src/backend/Application/Students/Queries/GetStudentsQuery.cs
new file mode 100644
index 0000000..b4908fb
--- /dev/null
+++ b/src/backend/Application/Students/Queries/GetStudentsQuery.cs
@@ -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>;
+
+public class GetStudentsHandler(IStudentRepository studentRepository)
+ : IRequestHandler>
+{
+ public async Task> 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);
+ }
+}
diff --git a/src/backend/Application/Subjects/DTOs/SubjectDto.cs b/src/backend/Application/Subjects/DTOs/SubjectDto.cs
new file mode 100644
index 0000000..7db1330
--- /dev/null
+++ b/src/backend/Application/Subjects/DTOs/SubjectDto.cs
@@ -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);
diff --git a/src/backend/Application/Subjects/Queries/GetAvailableSubjectsQuery.cs b/src/backend/Application/Subjects/Queries/GetAvailableSubjectsQuery.cs
new file mode 100644
index 0000000..19fa1dc
--- /dev/null
+++ b/src/backend/Application/Subjects/Queries/GetAvailableSubjectsQuery.cs
@@ -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>;
+
+public class GetAvailableSubjectsHandler(
+ IStudentRepository studentRepository,
+ ISubjectRepository subjectRepository)
+ : IRequestHandler>
+{
+ public async Task> 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 enrolledSubjectIds,
+ HashSet 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);
+ }
+}
diff --git a/src/backend/Application/Subjects/Queries/GetSubjectsQuery.cs b/src/backend/Application/Subjects/Queries/GetSubjectsQuery.cs
new file mode 100644
index 0000000..06ac4c9
--- /dev/null
+++ b/src/backend/Application/Subjects/Queries/GetSubjectsQuery.cs
@@ -0,0 +1,23 @@
+namespace Application.Subjects.Queries;
+
+using Application.Subjects.DTOs;
+using Domain.Ports.Repositories;
+using MediatR;
+
+public record GetSubjectsQuery : IRequest>;
+
+public class GetSubjectsHandler(ISubjectRepository subjectRepository)
+ : IRequestHandler>
+{
+ public async Task> 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);
+ }
+}