diff --git a/src/backend/Adapters/Driven/Persistence/Adapters.Driven.Persistence.csproj b/src/backend/Adapters/Driven/Persistence/Adapters.Driven.Persistence.csproj
new file mode 100644
index 0000000..4a9ad77
--- /dev/null
+++ b/src/backend/Adapters/Driven/Persistence/Adapters.Driven.Persistence.csproj
@@ -0,0 +1,23 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
diff --git a/src/backend/Adapters/Driven/Persistence/CompiledQueries.cs b/src/backend/Adapters/Driven/Persistence/CompiledQueries.cs
new file mode 100644
index 0000000..27b0478
--- /dev/null
+++ b/src/backend/Adapters/Driven/Persistence/CompiledQueries.cs
@@ -0,0 +1,55 @@
+namespace Adapters.Driven.Persistence;
+
+using Adapters.Driven.Persistence.Context;
+using Domain.Entities;
+using Microsoft.EntityFrameworkCore;
+
+///
+/// Pre-compiled EF Core queries for frequently used operations.
+/// Compiled queries skip expression tree parsing on each execution.
+///
+public static class CompiledQueries
+{
+ // Student queries
+ public static readonly Func> GetStudentById =
+ EF.CompileAsyncQuery((AppDbContext ctx, int id) =>
+ ctx.Students.FirstOrDefault(s => s.Id == id));
+
+ public static readonly Func> StudentExists =
+ EF.CompileAsyncQuery((AppDbContext ctx, int id) =>
+ ctx.Students.Any(s => s.Id == id));
+
+ // EmailExists removed - using raw SQL in repository due to value object translation issues
+
+ // Subject queries
+ public static readonly Func> GetSubjectById =
+ EF.CompileAsyncQuery((AppDbContext ctx, int id) =>
+ ctx.Subjects.FirstOrDefault(s => s.Id == id));
+
+ public static readonly Func> GetAllSubjects =
+ EF.CompileAsyncQuery((AppDbContext ctx) =>
+ ctx.Subjects.AsNoTracking());
+
+ // Professor queries
+ public static readonly Func> GetProfessorById =
+ EF.CompileAsyncQuery((AppDbContext ctx, int id) =>
+ ctx.Professors.FirstOrDefault(p => p.Id == id));
+
+ // Enrollment queries
+ public static readonly Func> CountStudentEnrollments =
+ EF.CompileAsyncQuery((AppDbContext ctx, int studentId) =>
+ ctx.Enrollments.Count(e => e.StudentId == studentId));
+
+ public static readonly Func> EnrollmentExists =
+ EF.CompileAsyncQuery((AppDbContext ctx, int studentId, int subjectId) =>
+ ctx.Enrollments.Any(e => e.StudentId == studentId && e.SubjectId == subjectId));
+
+ // Count queries (for pagination)
+ public static readonly Func> CountStudents =
+ EF.CompileAsyncQuery((AppDbContext ctx) =>
+ ctx.Students.Count());
+
+ public static readonly Func> CountSubjects =
+ EF.CompileAsyncQuery((AppDbContext ctx) =>
+ ctx.Subjects.Count());
+}
diff --git a/src/backend/Adapters/Driven/Persistence/Configurations/EnrollmentConfiguration.cs b/src/backend/Adapters/Driven/Persistence/Configurations/EnrollmentConfiguration.cs
new file mode 100644
index 0000000..6ba1331
--- /dev/null
+++ b/src/backend/Adapters/Driven/Persistence/Configurations/EnrollmentConfiguration.cs
@@ -0,0 +1,30 @@
+namespace Adapters.Driven.Persistence.Configurations;
+
+using Domain.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+public class EnrollmentConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("Enrollments");
+
+ builder.HasKey(e => e.Id);
+
+ builder.Property(e => e.EnrolledAt)
+ .IsRequired();
+
+ builder.HasIndex(e => new { e.StudentId, e.SubjectId }).IsUnique();
+
+ builder.HasOne(e => e.Student)
+ .WithMany(s => s.Enrollments)
+ .HasForeignKey(e => e.StudentId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ builder.HasOne(e => e.Subject)
+ .WithMany(s => s.Enrollments)
+ .HasForeignKey(e => e.SubjectId)
+ .OnDelete(DeleteBehavior.Restrict);
+ }
+}
diff --git a/src/backend/Adapters/Driven/Persistence/Configurations/ProfessorConfiguration.cs b/src/backend/Adapters/Driven/Persistence/Configurations/ProfessorConfiguration.cs
new file mode 100644
index 0000000..5a60869
--- /dev/null
+++ b/src/backend/Adapters/Driven/Persistence/Configurations/ProfessorConfiguration.cs
@@ -0,0 +1,23 @@
+namespace Adapters.Driven.Persistence.Configurations;
+
+using Domain.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+public class ProfessorConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("Professors");
+
+ builder.HasKey(p => p.Id);
+
+ builder.Property(p => p.Name)
+ .IsRequired()
+ .HasMaxLength(100);
+
+ builder.HasMany(p => p.Subjects)
+ .WithOne(s => s.Professor)
+ .HasForeignKey(s => s.ProfessorId);
+ }
+}
diff --git a/src/backend/Adapters/Driven/Persistence/Configurations/StudentConfiguration.cs b/src/backend/Adapters/Driven/Persistence/Configurations/StudentConfiguration.cs
new file mode 100644
index 0000000..c8aafde
--- /dev/null
+++ b/src/backend/Adapters/Driven/Persistence/Configurations/StudentConfiguration.cs
@@ -0,0 +1,45 @@
+namespace Adapters.Driven.Persistence.Configurations;
+
+using Domain.Entities;
+using Domain.ValueObjects;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.ChangeTracking;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+public class StudentConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("Students");
+
+ builder.HasKey(s => s.Id);
+
+ builder.Property(s => s.Name)
+ .IsRequired()
+ .HasMaxLength(100);
+
+ // Email value object with proper converter and comparer
+ var emailComparer = new ValueComparer(
+ (e1, e2) => e1 != null && e2 != null && e1.Value == e2.Value,
+ e => e.Value.GetHashCode(),
+ e => Email.Create(e.Value));
+
+ builder.Property(s => s.Email)
+ .IsRequired()
+ .HasMaxLength(150)
+ .HasConversion(
+ e => e.Value,
+ v => Email.Create(v))
+ .Metadata.SetValueComparer(emailComparer);
+
+ // Use raw column name for index to avoid value object issues
+ builder.HasIndex("Email").IsUnique();
+
+ builder.HasMany(s => s.Enrollments)
+ .WithOne(e => e.Student)
+ .HasForeignKey(e => e.StudentId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ builder.Navigation(s => s.Enrollments).AutoInclude(false);
+ }
+}
diff --git a/src/backend/Adapters/Driven/Persistence/Configurations/SubjectConfiguration.cs b/src/backend/Adapters/Driven/Persistence/Configurations/SubjectConfiguration.cs
new file mode 100644
index 0000000..c3adb3e
--- /dev/null
+++ b/src/backend/Adapters/Driven/Persistence/Configurations/SubjectConfiguration.cs
@@ -0,0 +1,33 @@
+namespace Adapters.Driven.Persistence.Configurations;
+
+using Domain.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+public class SubjectConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("Subjects");
+
+ builder.HasKey(s => s.Id);
+
+ builder.Property(s => s.Name)
+ .IsRequired()
+ .HasMaxLength(100);
+
+ builder.Property(s => s.Credits)
+ .IsRequired()
+ .HasDefaultValue(3);
+
+ builder.HasOne(s => s.Professor)
+ .WithMany(p => p.Subjects)
+ .HasForeignKey(s => s.ProfessorId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ builder.HasMany(s => s.Enrollments)
+ .WithOne(e => e.Subject)
+ .HasForeignKey(e => e.SubjectId)
+ .OnDelete(DeleteBehavior.Restrict);
+ }
+}
diff --git a/src/backend/Adapters/Driven/Persistence/Context/AppDbContext.cs b/src/backend/Adapters/Driven/Persistence/Context/AppDbContext.cs
new file mode 100644
index 0000000..10a568a
--- /dev/null
+++ b/src/backend/Adapters/Driven/Persistence/Context/AppDbContext.cs
@@ -0,0 +1,20 @@
+namespace Adapters.Driven.Persistence.Context;
+
+using Adapters.Driven.Persistence.Seeding;
+using Domain.Entities;
+using Microsoft.EntityFrameworkCore;
+
+public class AppDbContext(DbContextOptions options) : DbContext(options)
+{
+ public DbSet Students => Set();
+ public DbSet Subjects => Set();
+ public DbSet Professors => Set();
+ public DbSet Enrollments => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
+ DataSeeder.Seed(modelBuilder);
+ base.OnModelCreating(modelBuilder);
+ }
+}
diff --git a/src/backend/Adapters/Driven/Persistence/DataLoaders/ProfessorByIdDataLoader.cs b/src/backend/Adapters/Driven/Persistence/DataLoaders/ProfessorByIdDataLoader.cs
new file mode 100644
index 0000000..e7d588e
--- /dev/null
+++ b/src/backend/Adapters/Driven/Persistence/DataLoaders/ProfessorByIdDataLoader.cs
@@ -0,0 +1,30 @@
+namespace Adapters.Driven.Persistence.DataLoaders;
+
+using Adapters.Driven.Persistence.Context;
+using Domain.Entities;
+using GreenDonut;
+using Microsoft.EntityFrameworkCore;
+
+public class ProfessorByIdDataLoader : BatchDataLoader
+{
+ private readonly IDbContextFactory _contextFactory;
+
+ public ProfessorByIdDataLoader(
+ IDbContextFactory contextFactory,
+ IBatchScheduler batchScheduler,
+ DataLoaderOptions? options = null)
+ : base(batchScheduler, options ?? new DataLoaderOptions())
+ {
+ _contextFactory = contextFactory;
+ }
+
+ protected override async Task> LoadBatchAsync(
+ IReadOnlyList keys,
+ CancellationToken ct)
+ {
+ await using var context = await _contextFactory.CreateDbContextAsync(ct);
+ return await context.Professors
+ .Where(p => keys.Contains(p.Id))
+ .ToDictionaryAsync(p => p.Id, ct);
+ }
+}
diff --git a/src/backend/Adapters/Driven/Persistence/DataLoaders/StudentByIdDataLoader.cs b/src/backend/Adapters/Driven/Persistence/DataLoaders/StudentByIdDataLoader.cs
new file mode 100644
index 0000000..1aa3eda
--- /dev/null
+++ b/src/backend/Adapters/Driven/Persistence/DataLoaders/StudentByIdDataLoader.cs
@@ -0,0 +1,30 @@
+namespace Adapters.Driven.Persistence.DataLoaders;
+
+using Adapters.Driven.Persistence.Context;
+using Domain.Entities;
+using GreenDonut;
+using Microsoft.EntityFrameworkCore;
+
+public class StudentByIdDataLoader : BatchDataLoader
+{
+ private readonly IDbContextFactory _contextFactory;
+
+ public StudentByIdDataLoader(
+ IDbContextFactory contextFactory,
+ IBatchScheduler batchScheduler,
+ DataLoaderOptions? options = null)
+ : base(batchScheduler, options ?? new DataLoaderOptions())
+ {
+ _contextFactory = contextFactory;
+ }
+
+ protected override async Task> LoadBatchAsync(
+ IReadOnlyList keys,
+ CancellationToken ct)
+ {
+ await using var context = await _contextFactory.CreateDbContextAsync(ct);
+ return await context.Students
+ .Where(s => keys.Contains(s.Id))
+ .ToDictionaryAsync(s => s.Id, ct);
+ }
+}
diff --git a/src/backend/Adapters/Driven/Persistence/DataLoaders/SubjectByIdDataLoader.cs b/src/backend/Adapters/Driven/Persistence/DataLoaders/SubjectByIdDataLoader.cs
new file mode 100644
index 0000000..7756864
--- /dev/null
+++ b/src/backend/Adapters/Driven/Persistence/DataLoaders/SubjectByIdDataLoader.cs
@@ -0,0 +1,31 @@
+namespace Adapters.Driven.Persistence.DataLoaders;
+
+using Adapters.Driven.Persistence.Context;
+using Domain.Entities;
+using GreenDonut;
+using Microsoft.EntityFrameworkCore;
+
+public class SubjectByIdDataLoader : BatchDataLoader
+{
+ private readonly IDbContextFactory _contextFactory;
+
+ public SubjectByIdDataLoader(
+ IDbContextFactory contextFactory,
+ IBatchScheduler batchScheduler,
+ DataLoaderOptions? options = null)
+ : base(batchScheduler, options ?? new DataLoaderOptions())
+ {
+ _contextFactory = contextFactory;
+ }
+
+ protected override async Task> LoadBatchAsync(
+ IReadOnlyList keys,
+ CancellationToken ct)
+ {
+ await using var context = await _contextFactory.CreateDbContextAsync(ct);
+ return await context.Subjects
+ .Include(s => s.Professor)
+ .Where(s => keys.Contains(s.Id))
+ .ToDictionaryAsync(s => s.Id, ct);
+ }
+}
diff --git a/src/backend/Adapters/Driven/Persistence/DependencyInjection.cs b/src/backend/Adapters/Driven/Persistence/DependencyInjection.cs
new file mode 100644
index 0000000..c58fcbc
--- /dev/null
+++ b/src/backend/Adapters/Driven/Persistence/DependencyInjection.cs
@@ -0,0 +1,29 @@
+namespace Adapters.Driven.Persistence;
+
+using Adapters.Driven.Persistence.Context;
+using Adapters.Driven.Persistence.Repositories;
+using Domain.Ports.Repositories;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+
+public static class DependencyInjection
+{
+ public static IServiceCollection AddPersistence(
+ this IServiceCollection services,
+ IConfiguration configuration)
+ {
+ services.AddDbContext(options =>
+ options.UseSqlServer(
+ configuration.GetConnectionString("DefaultConnection"),
+ b => b.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName)));
+
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+
+ return services;
+ }
+}
diff --git a/src/backend/Adapters/Driven/Persistence/Migrations/20260107212215_InitialCreate.Designer.cs b/src/backend/Adapters/Driven/Persistence/Migrations/20260107212215_InitialCreate.Designer.cs
new file mode 100644
index 0000000..7c8e61a
--- /dev/null
+++ b/src/backend/Adapters/Driven/Persistence/Migrations/20260107212215_InitialCreate.Designer.cs
@@ -0,0 +1,174 @@
+//
+using System;
+using Adapters.Driven.Persistence.Context;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Adapters.Driven.Persistence.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260107212215_InitialCreate")]
+ partial class InitialCreate
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.1")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("Domain.Entities.Enrollment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("EnrolledAt")
+ .HasColumnType("datetime2");
+
+ b.Property("StudentId")
+ .HasColumnType("int");
+
+ b.Property("SubjectId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("SubjectId");
+
+ b.HasIndex("StudentId", "SubjectId")
+ .IsUnique();
+
+ b.ToTable("Enrollments", (string)null);
+ });
+
+ modelBuilder.Entity("Domain.Entities.Professor", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.HasKey("Id");
+
+ b.ToTable("Professors", (string)null);
+ });
+
+ modelBuilder.Entity("Domain.Entities.Student", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(150)
+ .HasColumnType("nvarchar(150)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Email")
+ .IsUnique();
+
+ b.ToTable("Students", (string)null);
+ });
+
+ modelBuilder.Entity("Domain.Entities.Subject", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Credits")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int")
+ .HasDefaultValue(3);
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("ProfessorId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ProfessorId");
+
+ b.ToTable("Subjects", (string)null);
+ });
+
+ modelBuilder.Entity("Domain.Entities.Enrollment", b =>
+ {
+ b.HasOne("Domain.Entities.Student", "Student")
+ .WithMany("Enrollments")
+ .HasForeignKey("StudentId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Domain.Entities.Subject", "Subject")
+ .WithMany("Enrollments")
+ .HasForeignKey("SubjectId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Student");
+
+ b.Navigation("Subject");
+ });
+
+ modelBuilder.Entity("Domain.Entities.Subject", b =>
+ {
+ b.HasOne("Domain.Entities.Professor", "Professor")
+ .WithMany("Subjects")
+ .HasForeignKey("ProfessorId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Professor");
+ });
+
+ modelBuilder.Entity("Domain.Entities.Professor", b =>
+ {
+ b.Navigation("Subjects");
+ });
+
+ modelBuilder.Entity("Domain.Entities.Student", b =>
+ {
+ b.Navigation("Enrollments");
+ });
+
+ modelBuilder.Entity("Domain.Entities.Subject", b =>
+ {
+ b.Navigation("Enrollments");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/backend/Adapters/Driven/Persistence/Migrations/20260107212215_InitialCreate.cs b/src/backend/Adapters/Driven/Persistence/Migrations/20260107212215_InitialCreate.cs
new file mode 100644
index 0000000..784ad55
--- /dev/null
+++ b/src/backend/Adapters/Driven/Persistence/Migrations/20260107212215_InitialCreate.cs
@@ -0,0 +1,128 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Adapters.Driven.Persistence.Migrations
+{
+ ///
+ public partial class InitialCreate : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "Professors",
+ columns: table => new
+ {
+ Id = table.Column(type: "int", nullable: false)
+ .Annotation("SqlServer:Identity", "1, 1"),
+ Name = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Professors", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Students",
+ columns: table => new
+ {
+ Id = table.Column(type: "int", nullable: false)
+ .Annotation("SqlServer:Identity", "1, 1"),
+ Name = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false),
+ Email = table.Column(type: "nvarchar(150)", maxLength: 150, nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Students", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Subjects",
+ columns: table => new
+ {
+ Id = table.Column(type: "int", nullable: false)
+ .Annotation("SqlServer:Identity", "1, 1"),
+ Name = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false),
+ Credits = table.Column(type: "int", nullable: false, defaultValue: 3),
+ ProfessorId = table.Column(type: "int", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Subjects", x => x.Id);
+ table.ForeignKey(
+ name: "FK_Subjects_Professors_ProfessorId",
+ column: x => x.ProfessorId,
+ principalTable: "Professors",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Restrict);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Enrollments",
+ columns: table => new
+ {
+ Id = table.Column(type: "int", nullable: false)
+ .Annotation("SqlServer:Identity", "1, 1"),
+ StudentId = table.Column(type: "int", nullable: false),
+ SubjectId = table.Column(type: "int", nullable: false),
+ EnrolledAt = table.Column(type: "datetime2", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Enrollments", x => x.Id);
+ table.ForeignKey(
+ name: "FK_Enrollments_Students_StudentId",
+ column: x => x.StudentId,
+ principalTable: "Students",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "FK_Enrollments_Subjects_SubjectId",
+ column: x => x.SubjectId,
+ principalTable: "Subjects",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Restrict);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Enrollments_StudentId_SubjectId",
+ table: "Enrollments",
+ columns: new[] { "StudentId", "SubjectId" },
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Enrollments_SubjectId",
+ table: "Enrollments",
+ column: "SubjectId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Students_Email",
+ table: "Students",
+ column: "Email",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Subjects_ProfessorId",
+ table: "Subjects",
+ column: "ProfessorId");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "Enrollments");
+
+ migrationBuilder.DropTable(
+ name: "Students");
+
+ migrationBuilder.DropTable(
+ name: "Subjects");
+
+ migrationBuilder.DropTable(
+ name: "Professors");
+ }
+ }
+}
diff --git a/src/backend/Adapters/Driven/Persistence/Migrations/20260107212421_SeedData.Designer.cs b/src/backend/Adapters/Driven/Persistence/Migrations/20260107212421_SeedData.Designer.cs
new file mode 100644
index 0000000..ffcde33
--- /dev/null
+++ b/src/backend/Adapters/Driven/Persistence/Migrations/20260107212421_SeedData.Designer.cs
@@ -0,0 +1,273 @@
+//
+using System;
+using Adapters.Driven.Persistence.Context;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Adapters.Driven.Persistence.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260107212421_SeedData")]
+ partial class SeedData
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.1")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("Domain.Entities.Enrollment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("EnrolledAt")
+ .HasColumnType("datetime2");
+
+ b.Property("StudentId")
+ .HasColumnType("int");
+
+ b.Property("SubjectId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("SubjectId");
+
+ b.HasIndex("StudentId", "SubjectId")
+ .IsUnique();
+
+ b.ToTable("Enrollments", (string)null);
+ });
+
+ modelBuilder.Entity("Domain.Entities.Professor", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.HasKey("Id");
+
+ b.ToTable("Professors", (string)null);
+
+ b.HasData(
+ new
+ {
+ Id = 1,
+ Name = "Dr. García"
+ },
+ new
+ {
+ Id = 2,
+ Name = "Dra. Martínez"
+ },
+ new
+ {
+ Id = 3,
+ Name = "Dr. López"
+ },
+ new
+ {
+ Id = 4,
+ Name = "Dra. Rodríguez"
+ },
+ new
+ {
+ Id = 5,
+ Name = "Dr. Hernández"
+ });
+ });
+
+ modelBuilder.Entity("Domain.Entities.Student", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(150)
+ .HasColumnType("nvarchar(150)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Email")
+ .IsUnique();
+
+ b.ToTable("Students", (string)null);
+ });
+
+ modelBuilder.Entity("Domain.Entities.Subject", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Credits")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int")
+ .HasDefaultValue(3);
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("ProfessorId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ProfessorId");
+
+ b.ToTable("Subjects", (string)null);
+
+ b.HasData(
+ new
+ {
+ Id = 1,
+ Credits = 3,
+ Name = "Matemáticas I",
+ ProfessorId = 1
+ },
+ new
+ {
+ Id = 2,
+ Credits = 3,
+ Name = "Matemáticas II",
+ ProfessorId = 1
+ },
+ new
+ {
+ Id = 3,
+ Credits = 3,
+ Name = "Física I",
+ ProfessorId = 2
+ },
+ new
+ {
+ Id = 4,
+ Credits = 3,
+ Name = "Física II",
+ ProfessorId = 2
+ },
+ new
+ {
+ Id = 5,
+ Credits = 3,
+ Name = "Programación I",
+ ProfessorId = 3
+ },
+ new
+ {
+ Id = 6,
+ Credits = 3,
+ Name = "Programación II",
+ ProfessorId = 3
+ },
+ new
+ {
+ Id = 7,
+ Credits = 3,
+ Name = "Base de Datos I",
+ ProfessorId = 4
+ },
+ new
+ {
+ Id = 8,
+ Credits = 3,
+ Name = "Base de Datos II",
+ ProfessorId = 4
+ },
+ new
+ {
+ Id = 9,
+ Credits = 3,
+ Name = "Redes I",
+ ProfessorId = 5
+ },
+ new
+ {
+ Id = 10,
+ Credits = 3,
+ Name = "Redes II",
+ ProfessorId = 5
+ });
+ });
+
+ modelBuilder.Entity("Domain.Entities.Enrollment", b =>
+ {
+ b.HasOne("Domain.Entities.Student", "Student")
+ .WithMany("Enrollments")
+ .HasForeignKey("StudentId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Domain.Entities.Subject", "Subject")
+ .WithMany("Enrollments")
+ .HasForeignKey("SubjectId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Student");
+
+ b.Navigation("Subject");
+ });
+
+ modelBuilder.Entity("Domain.Entities.Subject", b =>
+ {
+ b.HasOne("Domain.Entities.Professor", "Professor")
+ .WithMany("Subjects")
+ .HasForeignKey("ProfessorId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Professor");
+ });
+
+ modelBuilder.Entity("Domain.Entities.Professor", b =>
+ {
+ b.Navigation("Subjects");
+ });
+
+ modelBuilder.Entity("Domain.Entities.Student", b =>
+ {
+ b.Navigation("Enrollments");
+ });
+
+ modelBuilder.Entity("Domain.Entities.Subject", b =>
+ {
+ b.Navigation("Enrollments");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/backend/Adapters/Driven/Persistence/Migrations/20260107212421_SeedData.cs b/src/backend/Adapters/Driven/Persistence/Migrations/20260107212421_SeedData.cs
new file mode 100644
index 0000000..fc645ab
--- /dev/null
+++ b/src/backend/Adapters/Driven/Persistence/Migrations/20260107212421_SeedData.cs
@@ -0,0 +1,124 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
+
+namespace Adapters.Driven.Persistence.Migrations
+{
+ ///
+ public partial class SeedData : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.InsertData(
+ table: "Professors",
+ columns: new[] { "Id", "Name" },
+ values: new object[,]
+ {
+ { 1, "Dr. García" },
+ { 2, "Dra. Martínez" },
+ { 3, "Dr. López" },
+ { 4, "Dra. Rodríguez" },
+ { 5, "Dr. Hernández" }
+ });
+
+ migrationBuilder.InsertData(
+ table: "Subjects",
+ columns: new[] { "Id", "Credits", "Name", "ProfessorId" },
+ values: new object[,]
+ {
+ { 1, 3, "Matemáticas I", 1 },
+ { 2, 3, "Matemáticas II", 1 },
+ { 3, 3, "Física I", 2 },
+ { 4, 3, "Física II", 2 },
+ { 5, 3, "Programación I", 3 },
+ { 6, 3, "Programación II", 3 },
+ { 7, 3, "Base de Datos I", 4 },
+ { 8, 3, "Base de Datos II", 4 },
+ { 9, 3, "Redes I", 5 },
+ { 10, 3, "Redes II", 5 }
+ });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DeleteData(
+ table: "Subjects",
+ keyColumn: "Id",
+ keyValue: 1);
+
+ migrationBuilder.DeleteData(
+ table: "Subjects",
+ keyColumn: "Id",
+ keyValue: 2);
+
+ migrationBuilder.DeleteData(
+ table: "Subjects",
+ keyColumn: "Id",
+ keyValue: 3);
+
+ migrationBuilder.DeleteData(
+ table: "Subjects",
+ keyColumn: "Id",
+ keyValue: 4);
+
+ migrationBuilder.DeleteData(
+ table: "Subjects",
+ keyColumn: "Id",
+ keyValue: 5);
+
+ migrationBuilder.DeleteData(
+ table: "Subjects",
+ keyColumn: "Id",
+ keyValue: 6);
+
+ migrationBuilder.DeleteData(
+ table: "Subjects",
+ keyColumn: "Id",
+ keyValue: 7);
+
+ migrationBuilder.DeleteData(
+ table: "Subjects",
+ keyColumn: "Id",
+ keyValue: 8);
+
+ migrationBuilder.DeleteData(
+ table: "Subjects",
+ keyColumn: "Id",
+ keyValue: 9);
+
+ migrationBuilder.DeleteData(
+ table: "Subjects",
+ keyColumn: "Id",
+ keyValue: 10);
+
+ migrationBuilder.DeleteData(
+ table: "Professors",
+ keyColumn: "Id",
+ keyValue: 1);
+
+ migrationBuilder.DeleteData(
+ table: "Professors",
+ keyColumn: "Id",
+ keyValue: 2);
+
+ migrationBuilder.DeleteData(
+ table: "Professors",
+ keyColumn: "Id",
+ keyValue: 3);
+
+ migrationBuilder.DeleteData(
+ table: "Professors",
+ keyColumn: "Id",
+ keyValue: 4);
+
+ migrationBuilder.DeleteData(
+ table: "Professors",
+ keyColumn: "Id",
+ keyValue: 5);
+ }
+ }
+}
diff --git a/src/backend/Adapters/Driven/Persistence/Migrations/AppDbContextModelSnapshot.cs b/src/backend/Adapters/Driven/Persistence/Migrations/AppDbContextModelSnapshot.cs
new file mode 100644
index 0000000..9a1c90e
--- /dev/null
+++ b/src/backend/Adapters/Driven/Persistence/Migrations/AppDbContextModelSnapshot.cs
@@ -0,0 +1,270 @@
+//
+using System;
+using Adapters.Driven.Persistence.Context;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Adapters.Driven.Persistence.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ partial class AppDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.1")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("Domain.Entities.Enrollment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("EnrolledAt")
+ .HasColumnType("datetime2");
+
+ b.Property("StudentId")
+ .HasColumnType("int");
+
+ b.Property("SubjectId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("SubjectId");
+
+ b.HasIndex("StudentId", "SubjectId")
+ .IsUnique();
+
+ b.ToTable("Enrollments", (string)null);
+ });
+
+ modelBuilder.Entity("Domain.Entities.Professor", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.HasKey("Id");
+
+ b.ToTable("Professors", (string)null);
+
+ b.HasData(
+ new
+ {
+ Id = 1,
+ Name = "Dr. García"
+ },
+ new
+ {
+ Id = 2,
+ Name = "Dra. Martínez"
+ },
+ new
+ {
+ Id = 3,
+ Name = "Dr. López"
+ },
+ new
+ {
+ Id = 4,
+ Name = "Dra. Rodríguez"
+ },
+ new
+ {
+ Id = 5,
+ Name = "Dr. Hernández"
+ });
+ });
+
+ modelBuilder.Entity("Domain.Entities.Student", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(150)
+ .HasColumnType("nvarchar(150)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Email")
+ .IsUnique();
+
+ b.ToTable("Students", (string)null);
+ });
+
+ modelBuilder.Entity("Domain.Entities.Subject", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Credits")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int")
+ .HasDefaultValue(3);
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("ProfessorId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ProfessorId");
+
+ b.ToTable("Subjects", (string)null);
+
+ b.HasData(
+ new
+ {
+ Id = 1,
+ Credits = 3,
+ Name = "Matemáticas I",
+ ProfessorId = 1
+ },
+ new
+ {
+ Id = 2,
+ Credits = 3,
+ Name = "Matemáticas II",
+ ProfessorId = 1
+ },
+ new
+ {
+ Id = 3,
+ Credits = 3,
+ Name = "Física I",
+ ProfessorId = 2
+ },
+ new
+ {
+ Id = 4,
+ Credits = 3,
+ Name = "Física II",
+ ProfessorId = 2
+ },
+ new
+ {
+ Id = 5,
+ Credits = 3,
+ Name = "Programación I",
+ ProfessorId = 3
+ },
+ new
+ {
+ Id = 6,
+ Credits = 3,
+ Name = "Programación II",
+ ProfessorId = 3
+ },
+ new
+ {
+ Id = 7,
+ Credits = 3,
+ Name = "Base de Datos I",
+ ProfessorId = 4
+ },
+ new
+ {
+ Id = 8,
+ Credits = 3,
+ Name = "Base de Datos II",
+ ProfessorId = 4
+ },
+ new
+ {
+ Id = 9,
+ Credits = 3,
+ Name = "Redes I",
+ ProfessorId = 5
+ },
+ new
+ {
+ Id = 10,
+ Credits = 3,
+ Name = "Redes II",
+ ProfessorId = 5
+ });
+ });
+
+ modelBuilder.Entity("Domain.Entities.Enrollment", b =>
+ {
+ b.HasOne("Domain.Entities.Student", "Student")
+ .WithMany("Enrollments")
+ .HasForeignKey("StudentId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Domain.Entities.Subject", "Subject")
+ .WithMany("Enrollments")
+ .HasForeignKey("SubjectId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Student");
+
+ b.Navigation("Subject");
+ });
+
+ modelBuilder.Entity("Domain.Entities.Subject", b =>
+ {
+ b.HasOne("Domain.Entities.Professor", "Professor")
+ .WithMany("Subjects")
+ .HasForeignKey("ProfessorId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Professor");
+ });
+
+ modelBuilder.Entity("Domain.Entities.Professor", b =>
+ {
+ b.Navigation("Subjects");
+ });
+
+ modelBuilder.Entity("Domain.Entities.Student", b =>
+ {
+ b.Navigation("Enrollments");
+ });
+
+ modelBuilder.Entity("Domain.Entities.Subject", b =>
+ {
+ b.Navigation("Enrollments");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/backend/Adapters/Driven/Persistence/Repositories/EnrollmentRepository.cs b/src/backend/Adapters/Driven/Persistence/Repositories/EnrollmentRepository.cs
new file mode 100644
index 0000000..f0d5c36
--- /dev/null
+++ b/src/backend/Adapters/Driven/Persistence/Repositories/EnrollmentRepository.cs
@@ -0,0 +1,75 @@
+namespace Adapters.Driven.Persistence.Repositories;
+
+using Adapters.Driven.Persistence.Context;
+using Domain.Entities;
+using Domain.Ports.Repositories;
+using Microsoft.EntityFrameworkCore;
+
+public class EnrollmentRepository(AppDbContext context) : IEnrollmentRepository
+{
+ public async Task GetByIdAsync(int id, CancellationToken ct = default) =>
+ await context.Enrollments.FindAsync([id], ct);
+
+ public async Task GetByStudentAndSubjectAsync(int studentId, int subjectId, CancellationToken ct = default) =>
+ await context.Enrollments
+ .FirstOrDefaultAsync(e => e.StudentId == studentId && e.SubjectId == subjectId, ct);
+
+ public async Task> GetByStudentIdAsync(int studentId, CancellationToken ct = default) =>
+ await context.Enrollments
+ .Include(e => e.Subject)
+ .ThenInclude(s => s.Professor)
+ .Where(e => e.StudentId == studentId)
+ .AsNoTracking()
+ .ToListAsync(ct);
+
+ public async Task> GetBySubjectIdAsync(int subjectId, CancellationToken ct = default) =>
+ await context.Enrollments
+ .Include(e => e.Student)
+ .Where(e => e.SubjectId == subjectId)
+ .AsNoTracking()
+ .ToListAsync(ct);
+
+ public async Task> GetClassmatesAsync(int studentId, int subjectId, CancellationToken ct = default) =>
+ await context.Enrollments
+ .Where(e => e.SubjectId == subjectId && e.StudentId != studentId)
+ .Select(e => e.Student)
+ .AsNoTracking()
+ .ToListAsync(ct);
+
+ public async Task>> GetClassmatesBatchAsync(
+ int studentId, IEnumerable subjectIds, CancellationToken ct = default)
+ {
+ var subjectIdList = subjectIds.ToList();
+ if (subjectIdList.Count == 0)
+ return new Dictionary>();
+
+ // Single query to get all classmates for all subjects
+ var enrollments = await context.Enrollments
+ .Where(e => subjectIdList.Contains(e.SubjectId) && e.StudentId != studentId)
+ .Select(e => new { e.SubjectId, e.Student.Id, e.Student.Name })
+ .AsNoTracking()
+ .ToListAsync(ct);
+
+ // Group by SubjectId and project to Student (minimal data needed)
+ return enrollments
+ .GroupBy(e => e.SubjectId)
+ .ToDictionary(
+ g => g.Key,
+ g => (IReadOnlyList)g
+ .Select(e => CreateMinimalStudent(e.Id, e.Name))
+ .ToList());
+ }
+
+ private static Student CreateMinimalStudent(int id, string name)
+ {
+ // Use reflection to set Id since it's private set
+ var student = (Student)System.Runtime.CompilerServices.RuntimeHelpers
+ .GetUninitializedObject(typeof(Student));
+ typeof(Student).GetProperty("Id")!.SetValue(student, id);
+ typeof(Student).GetProperty("Name")!.SetValue(student, name);
+ return student;
+ }
+
+ public void Add(Enrollment enrollment) => context.Enrollments.Add(enrollment);
+ public void Delete(Enrollment enrollment) => context.Enrollments.Remove(enrollment);
+}
diff --git a/src/backend/Adapters/Driven/Persistence/Repositories/ProfessorRepository.cs b/src/backend/Adapters/Driven/Persistence/Repositories/ProfessorRepository.cs
new file mode 100644
index 0000000..82d1057
--- /dev/null
+++ b/src/backend/Adapters/Driven/Persistence/Repositories/ProfessorRepository.cs
@@ -0,0 +1,31 @@
+namespace Adapters.Driven.Persistence.Repositories;
+
+using Adapters.Driven.Persistence.Context;
+using Domain.Entities;
+using Domain.Ports.Repositories;
+using Microsoft.EntityFrameworkCore;
+using System.Linq.Expressions;
+
+public class ProfessorRepository(AppDbContext context) : IProfessorRepository
+{
+ public async Task GetByIdAsync(int id, CancellationToken ct = default) =>
+ await context.Professors.FindAsync([id], ct);
+
+ public async Task> GetAllAsync(CancellationToken ct = default) =>
+ await context.Professors.AsNoTracking().ToListAsync(ct);
+
+ public async Task> GetAllWithSubjectsAsync(CancellationToken ct = default) =>
+ await context.Professors
+ .Include(p => p.Subjects)
+ .AsNoTracking()
+ .ToListAsync(ct);
+
+ // Direct projection - only SELECT needed columns
+ public async Task> GetAllProjectedAsync(
+ Expression> selector,
+ CancellationToken ct = default) =>
+ await context.Professors
+ .AsNoTracking()
+ .Select(selector)
+ .ToListAsync(ct);
+}
diff --git a/src/backend/Adapters/Driven/Persistence/Repositories/StudentRepository.cs b/src/backend/Adapters/Driven/Persistence/Repositories/StudentRepository.cs
new file mode 100644
index 0000000..df35836
--- /dev/null
+++ b/src/backend/Adapters/Driven/Persistence/Repositories/StudentRepository.cs
@@ -0,0 +1,121 @@
+namespace Adapters.Driven.Persistence.Repositories;
+
+using Adapters.Driven.Persistence.Context;
+using Domain.Entities;
+using Domain.Ports.Repositories;
+using Microsoft.EntityFrameworkCore;
+using System.Linq.Expressions;
+
+public class StudentRepository(AppDbContext context) : IStudentRepository
+{
+ public async Task GetByIdAsync(int id, CancellationToken ct = default) =>
+ await context.Students.FindAsync([id], ct);
+
+ public async Task GetByIdWithEnrollmentsAsync(int id, CancellationToken ct = default) =>
+ await context.Students
+ .Include(s => s.Enrollments)
+ .ThenInclude(e => e.Subject)
+ .ThenInclude(sub => sub.Professor)
+ .AsSplitQuery() // Avoid cartesian explosion
+ .FirstOrDefaultAsync(s => s.Id == id, ct);
+
+ public async Task> GetAllAsync(CancellationToken ct = default) =>
+ await context.Students.AsNoTracking().ToListAsync(ct);
+
+ public async Task> GetAllWithEnrollmentsAsync(CancellationToken ct = default) =>
+ await context.Students
+ .Include(s => s.Enrollments)
+ .ThenInclude(e => e.Subject)
+ .ThenInclude(sub => sub.Professor)
+ .AsSplitQuery() // Avoid cartesian explosion
+ .AsNoTrackingWithIdentityResolution() // Better than AsNoTracking with navigations
+ .ToListAsync(ct);
+
+ public async Task ExistsAsync(int id, CancellationToken ct = default) =>
+ await CompiledQueries.StudentExists(context, id);
+
+ public async Task EmailExistsAsync(string email, int? excludeId = null, CancellationToken ct = default)
+ {
+ var normalizedEmail = email.Trim().ToLowerInvariant();
+
+ // Use raw SQL to avoid value object translation issues
+ // SQL Server requires column alias for SqlQueryRaw
+ var sql = excludeId.HasValue
+ ? "SELECT 1 AS Value FROM Students WHERE LOWER(Email) = {0} AND Id != {1}"
+ : "SELECT 1 AS Value FROM Students WHERE LOWER(Email) = {0}";
+
+ var parameters = excludeId.HasValue
+ ? new object[] { normalizedEmail, excludeId.Value }
+ : new object[] { normalizedEmail };
+
+ return await context.Database
+ .SqlQueryRaw(sql, parameters)
+ .AnyAsync(ct);
+ }
+
+ // Direct projection - query only needed columns
+ public async Task> GetAllProjectedAsync(
+ Expression> selector,
+ CancellationToken ct = default) =>
+ await context.Students
+ .AsNoTracking()
+ .Select(selector)
+ .ToListAsync(ct);
+
+ public async Task GetByIdProjectedAsync(
+ int id,
+ Expression> selector,
+ CancellationToken ct = default) =>
+ await context.Students
+ .Where(s => s.Id == id)
+ .AsNoTracking()
+ .Select(selector)
+ .FirstOrDefaultAsync(ct);
+
+ // Keyset pagination - more efficient than OFFSET for large datasets
+ public async Task<(IReadOnlyList Items, int? NextCursor, int TotalCount)> GetPagedProjectedAsync(
+ Expression> selector,
+ int? afterId = null,
+ int pageSize = 10,
+ CancellationToken ct = default)
+ {
+ var query = context.Students.AsNoTracking();
+
+ // Keyset: WHERE Id > afterId (uses index efficiently)
+ if (afterId.HasValue)
+ query = query.Where(s => s.Id > afterId.Value);
+
+ // Get one extra to determine if there's a next page
+ var items = await query
+ .OrderBy(s => s.Id)
+ .Take(pageSize + 1)
+ .Select(selector)
+ .ToListAsync(ct);
+
+ var hasMore = items.Count > pageSize;
+ var resultItems = hasMore ? items.Take(pageSize).ToList() : items;
+
+ // Get next cursor from last item (requires Id in projection or separate query)
+ int? nextCursor = null;
+ if (hasMore && resultItems.Count > 0)
+ {
+ var lastId = await context.Students
+ .AsNoTracking()
+ .Where(s => afterId.HasValue ? s.Id > afterId.Value : true)
+ .OrderBy(s => s.Id)
+ .Skip(pageSize - 1)
+ .Select(s => s.Id)
+ .FirstOrDefaultAsync(ct);
+ nextCursor = lastId > 0 ? lastId : null;
+ }
+
+ // Use compiled query for count
+ var totalCount = await CompiledQueries.CountStudents(context);
+
+ return (resultItems, nextCursor, totalCount);
+ }
+
+ public void Add(Student student) => context.Students.Add(student);
+ public void Update(Student student) => context.Students.Update(student);
+ public void Delete(Student student) => context.Students.Remove(student);
+}
diff --git a/src/backend/Adapters/Driven/Persistence/Repositories/SubjectRepository.cs b/src/backend/Adapters/Driven/Persistence/Repositories/SubjectRepository.cs
new file mode 100644
index 0000000..2c7d6ad
--- /dev/null
+++ b/src/backend/Adapters/Driven/Persistence/Repositories/SubjectRepository.cs
@@ -0,0 +1,54 @@
+namespace Adapters.Driven.Persistence.Repositories;
+
+using Adapters.Driven.Persistence.Context;
+using Domain.Entities;
+using Domain.Ports.Repositories;
+using Microsoft.EntityFrameworkCore;
+using System.Linq.Expressions;
+
+public class SubjectRepository(AppDbContext context) : ISubjectRepository
+{
+ public async Task GetByIdAsync(int id, CancellationToken ct = default) =>
+ await context.Subjects.FindAsync([id], ct);
+
+ public async Task GetByIdWithProfessorAsync(int id, CancellationToken ct = default) =>
+ await context.Subjects
+ .Include(s => s.Professor)
+ .FirstOrDefaultAsync(s => s.Id == id, ct);
+
+ public async Task> GetAllAsync(CancellationToken ct = default) =>
+ await context.Subjects.AsNoTracking().ToListAsync(ct);
+
+ public async Task> GetAllWithProfessorsAsync(CancellationToken ct = default) =>
+ await context.Subjects
+ .Include(s => s.Professor)
+ .AsNoTracking()
+ .ToListAsync(ct);
+
+ public async Task> GetAvailableForStudentAsync(int studentId, CancellationToken ct = default)
+ {
+ // Optimized: single query instead of two separate queries
+ var enrollmentData = await context.Enrollments
+ .Where(e => e.StudentId == studentId)
+ .Select(e => new { e.SubjectId, e.Subject.ProfessorId })
+ .ToListAsync(ct);
+
+ var enrolledSubjectIds = enrollmentData.Select(e => e.SubjectId).ToHashSet();
+ var enrolledProfessorIds = enrollmentData.Select(e => e.ProfessorId).ToHashSet();
+
+ return await context.Subjects
+ .Include(s => s.Professor)
+ .Where(s => !enrolledSubjectIds.Contains(s.Id) && !enrolledProfessorIds.Contains(s.ProfessorId))
+ .AsNoTracking()
+ .ToListAsync(ct);
+ }
+
+ // Direct projection - only SELECT needed columns
+ public async Task> GetAllProjectedAsync(
+ Expression> selector,
+ CancellationToken ct = default) =>
+ await context.Subjects
+ .AsNoTracking()
+ .Select(selector)
+ .ToListAsync(ct);
+}
diff --git a/src/backend/Adapters/Driven/Persistence/Repositories/UnitOfWork.cs b/src/backend/Adapters/Driven/Persistence/Repositories/UnitOfWork.cs
new file mode 100644
index 0000000..6ca5a8a
--- /dev/null
+++ b/src/backend/Adapters/Driven/Persistence/Repositories/UnitOfWork.cs
@@ -0,0 +1,10 @@
+namespace Adapters.Driven.Persistence.Repositories;
+
+using Adapters.Driven.Persistence.Context;
+using Domain.Ports.Repositories;
+
+public class UnitOfWork(AppDbContext context) : IUnitOfWork
+{
+ public async Task SaveChangesAsync(CancellationToken ct = default) =>
+ await context.SaveChangesAsync(ct);
+}
diff --git a/src/backend/Adapters/Driven/Persistence/Seeding/DataSeeder.cs b/src/backend/Adapters/Driven/Persistence/Seeding/DataSeeder.cs
new file mode 100644
index 0000000..b7c8f89
--- /dev/null
+++ b/src/backend/Adapters/Driven/Persistence/Seeding/DataSeeder.cs
@@ -0,0 +1,32 @@
+namespace Adapters.Driven.Persistence.Seeding;
+
+using Microsoft.EntityFrameworkCore;
+
+public static class DataSeeder
+{
+ public static void Seed(ModelBuilder modelBuilder)
+ {
+ // 5 Profesores
+ modelBuilder.Entity().HasData(
+ new { Id = 1, Name = "Dr. García" },
+ new { Id = 2, Name = "Dra. Martínez" },
+ new { Id = 3, Name = "Dr. López" },
+ new { Id = 4, Name = "Dra. Rodríguez" },
+ new { Id = 5, Name = "Dr. Hernández" }
+ );
+
+ // 10 Materias (2 por profesor, 3 créditos cada una)
+ modelBuilder.Entity().HasData(
+ new { Id = 1, Name = "Matemáticas I", Credits = 3, ProfessorId = 1 },
+ new { Id = 2, Name = "Matemáticas II", Credits = 3, ProfessorId = 1 },
+ new { Id = 3, Name = "Física I", Credits = 3, ProfessorId = 2 },
+ new { Id = 4, Name = "Física II", Credits = 3, ProfessorId = 2 },
+ new { Id = 5, Name = "Programación I", Credits = 3, ProfessorId = 3 },
+ new { Id = 6, Name = "Programación II", Credits = 3, ProfessorId = 3 },
+ new { Id = 7, Name = "Base de Datos I", Credits = 3, ProfessorId = 4 },
+ new { Id = 8, Name = "Base de Datos II", Credits = 3, ProfessorId = 4 },
+ new { Id = 9, Name = "Redes I", Credits = 3, ProfessorId = 5 },
+ new { Id = 10, Name = "Redes II", Credits = 3, ProfessorId = 5 }
+ );
+ }
+}
diff --git a/src/backend/Adapters/Driving/Api/Adapters.Driving.Api.csproj b/src/backend/Adapters/Driving/Api/Adapters.Driving.Api.csproj
new file mode 100644
index 0000000..f775099
--- /dev/null
+++ b/src/backend/Adapters/Driving/Api/Adapters.Driving.Api.csproj
@@ -0,0 +1,20 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/backend/Adapters/Driving/Api/Extensions/GraphQLExtensions.cs b/src/backend/Adapters/Driving/Api/Extensions/GraphQLExtensions.cs
new file mode 100644
index 0000000..0774c7a
--- /dev/null
+++ b/src/backend/Adapters/Driving/Api/Extensions/GraphQLExtensions.cs
@@ -0,0 +1,54 @@
+namespace Adapters.Driving.Api.Extensions;
+
+using Adapters.Driven.Persistence.DataLoaders;
+using Adapters.Driving.Api.Middleware;
+using Adapters.Driving.Api.Types;
+using HotChocolate.Execution.Options;
+using Microsoft.Extensions.DependencyInjection;
+
+public static class GraphQLExtensions
+{
+ private const int MaxQueryDepth = 5;
+ private const int MaxQueryComplexity = 100;
+ private const int MaxQueryNodes = 50;
+
+ public static IServiceCollection AddGraphQLApi(this IServiceCollection services)
+ {
+ services
+ .AddGraphQLServer()
+ .AddQueryType()
+ .AddMutationType()
+ .AddProjections()
+ .AddFiltering()
+ .AddSorting()
+ .AddErrorFilter()
+ .AddDataLoader()
+ .AddDataLoader()
+ .AddDataLoader()
+ // Security: Query depth, complexity, and execution limits
+ .ModifyRequestOptions(ConfigureRequestOptions)
+ .AddMaxExecutionDepthRule(MaxQueryDepth)
+ // Cost analysis for query complexity
+ .ModifyCostOptions(opt =>
+ {
+ opt.MaxFieldCost = MaxQueryComplexity;
+ opt.MaxTypeCost = MaxQueryComplexity;
+ opt.EnforceCostLimits = true;
+ })
+ // Pagination limits to prevent large result sets
+ .ModifyPagingOptions(opt =>
+ {
+ opt.MaxPageSize = MaxQueryNodes;
+ opt.DefaultPageSize = 20;
+ opt.IncludeTotalCount = true;
+ });
+
+ return services;
+ }
+
+ private static void ConfigureRequestOptions(RequestExecutorOptions opt)
+ {
+ opt.IncludeExceptionDetails = false; // Don't expose internal errors in production
+ opt.ExecutionTimeout = TimeSpan.FromSeconds(30);
+ }
+}
diff --git a/src/backend/Adapters/Driving/Api/Middleware/GraphQLErrorFilter.cs b/src/backend/Adapters/Driving/Api/Middleware/GraphQLErrorFilter.cs
new file mode 100644
index 0000000..e25b615
--- /dev/null
+++ b/src/backend/Adapters/Driving/Api/Middleware/GraphQLErrorFilter.cs
@@ -0,0 +1,29 @@
+namespace Adapters.Driving.Api.Middleware;
+
+using Domain.Exceptions;
+using FluentValidation;
+
+public class GraphQLErrorFilter : IErrorFilter
+{
+ public IError OnError(IError error)
+ {
+ return error.Exception switch
+ {
+ DomainException domainException => error
+ .WithMessage(domainException.Message)
+ .WithCode(domainException.Code),
+
+ ValidationException validationException => error
+ .WithMessage("Validation failed")
+ .WithCode("VALIDATION_ERROR")
+ .SetExtension("errors", validationException.Errors
+ .Select(e => new { e.PropertyName, e.ErrorMessage })),
+
+ ArgumentException argException => error
+ .WithMessage(argException.Message)
+ .WithCode("INVALID_ARGUMENT"),
+
+ _ => error.WithMessage(error.Exception?.Message ?? error.Message)
+ };
+ }
+}
diff --git a/src/backend/Adapters/Driving/Api/Types/Enrollments/EnrollmentType.cs b/src/backend/Adapters/Driving/Api/Types/Enrollments/EnrollmentType.cs
new file mode 100644
index 0000000..b06b66b
--- /dev/null
+++ b/src/backend/Adapters/Driving/Api/Types/Enrollments/EnrollmentType.cs
@@ -0,0 +1,22 @@
+namespace Adapters.Driving.Api.Types.Enrollments;
+
+using Adapters.Driven.Persistence.DataLoaders;
+using Domain.Entities;
+
+[ObjectType]
+public static partial class EnrollmentType
+{
+ [GraphQLDescription("Student enrolled")]
+ public static async Task GetStudent(
+ [Parent] Enrollment enrollment,
+ StudentByIdDataLoader dataLoader,
+ CancellationToken ct) =>
+ await dataLoader.LoadAsync(enrollment.StudentId, ct);
+
+ [GraphQLDescription("Subject enrolled in")]
+ public static async Task GetSubject(
+ [Parent] Enrollment enrollment,
+ SubjectByIdDataLoader dataLoader,
+ CancellationToken ct) =>
+ await dataLoader.LoadAsync(enrollment.SubjectId, ct);
+}
diff --git a/src/backend/Adapters/Driving/Api/Types/Mutation.cs b/src/backend/Adapters/Driving/Api/Types/Mutation.cs
new file mode 100644
index 0000000..95001e2
--- /dev/null
+++ b/src/backend/Adapters/Driving/Api/Types/Mutation.cs
@@ -0,0 +1,72 @@
+namespace Adapters.Driving.Api.Types;
+
+using Application.Enrollments.Commands;
+using Application.Students.Commands;
+using Application.Students.DTOs;
+using MediatR;
+
+public class Mutation
+{
+ [GraphQLDescription("Create a new student")]
+ public async Task CreateStudent(
+ CreateStudentInput input,
+ [Service] IMediator mediator,
+ CancellationToken ct)
+ {
+ var result = await mediator.Send(new CreateStudentCommand(input.Name, input.Email), ct);
+ return new CreateStudentPayload(result);
+ }
+
+ [GraphQLDescription("Update an existing student")]
+ public async Task UpdateStudent(
+ int id,
+ UpdateStudentInput input,
+ [Service] IMediator mediator,
+ CancellationToken ct)
+ {
+ var result = await mediator.Send(new UpdateStudentCommand(id, input.Name, input.Email), ct);
+ return new UpdateStudentPayload(result);
+ }
+
+ [GraphQLDescription("Delete a student")]
+ public async Task DeleteStudent(
+ int id,
+ [Service] IMediator mediator,
+ CancellationToken ct)
+ {
+ var success = await mediator.Send(new DeleteStudentCommand(id), ct);
+ return new DeleteStudentPayload(success);
+ }
+
+ [GraphQLDescription("Enroll a student in a subject")]
+ public async Task EnrollStudent(
+ EnrollStudentInput input,
+ [Service] IMediator mediator,
+ CancellationToken ct)
+ {
+ var result = await mediator.Send(new EnrollStudentCommand(input.StudentId, input.SubjectId), ct);
+ return new EnrollStudentPayload(result);
+ }
+
+ [GraphQLDescription("Unenroll a student from a subject")]
+ public async Task UnenrollStudent(
+ int enrollmentId,
+ [Service] IMediator mediator,
+ CancellationToken ct)
+ {
+ var success = await mediator.Send(new UnenrollStudentCommand(enrollmentId), ct);
+ return new UnenrollStudentPayload(success);
+ }
+}
+
+// Inputs
+public record CreateStudentInput(string Name, string Email);
+public record UpdateStudentInput(string Name, string Email);
+public record EnrollStudentInput(int StudentId, int SubjectId);
+
+// Payloads
+public record CreateStudentPayload(StudentDto Student);
+public record UpdateStudentPayload(StudentDto Student);
+public record DeleteStudentPayload(bool Success);
+public record EnrollStudentPayload(EnrollmentDto Enrollment);
+public record UnenrollStudentPayload(bool Success);
diff --git a/src/backend/Adapters/Driving/Api/Types/Professors/ProfessorType.cs b/src/backend/Adapters/Driving/Api/Types/Professors/ProfessorType.cs
new file mode 100644
index 0000000..d13a616
--- /dev/null
+++ b/src/backend/Adapters/Driving/Api/Types/Professors/ProfessorType.cs
@@ -0,0 +1,11 @@
+namespace Adapters.Driving.Api.Types.Professors;
+
+using Domain.Entities;
+
+[ObjectType]
+public static partial class ProfessorType
+{
+ [GraphQLDescription("Subjects taught by this professor")]
+ public static IEnumerable GetSubjects([Parent] Professor professor) =>
+ professor.Subjects;
+}
diff --git a/src/backend/Adapters/Driving/Api/Types/Query.cs b/src/backend/Adapters/Driving/Api/Types/Query.cs
new file mode 100644
index 0000000..e030ee2
--- /dev/null
+++ b/src/backend/Adapters/Driving/Api/Types/Query.cs
@@ -0,0 +1,61 @@
+namespace Adapters.Driving.Api.Types;
+
+using Application.Enrollments.DTOs;
+using Application.Enrollments.Queries;
+using Application.Professors.DTOs;
+using Application.Professors.Queries;
+using Application.Students.DTOs;
+using Application.Students.Queries;
+using Application.Subjects.DTOs;
+using Application.Subjects.Queries;
+using MediatR;
+
+public class Query
+{
+ [GraphQLDescription("Get all students")]
+ public async Task> GetStudents(
+ [Service] IMediator mediator,
+ CancellationToken ct) =>
+ await mediator.Send(new GetStudentsQuery(), ct);
+
+ [GraphQLDescription("Get students with cursor-based pagination (keyset)")]
+ public async Task> GetStudentsPaged(
+ int? afterCursor,
+ int pageSize,
+ [Service] IMediator mediator,
+ CancellationToken ct) =>
+ await mediator.Send(new GetStudentsPagedQuery(afterCursor, pageSize), ct);
+
+ [GraphQLDescription("Get student by ID")]
+ public async Task GetStudent(
+ int id,
+ [Service] IMediator mediator,
+ CancellationToken ct) =>
+ await mediator.Send(new GetStudentByIdQuery(id), ct);
+
+ [GraphQLDescription("Get all subjects")]
+ public async Task> GetSubjects(
+ [Service] IMediator mediator,
+ CancellationToken ct) =>
+ await mediator.Send(new GetSubjectsQuery(), ct);
+
+ [GraphQLDescription("Get available subjects for a student")]
+ public async Task> GetAvailableSubjects(
+ int studentId,
+ [Service] IMediator mediator,
+ CancellationToken ct) =>
+ await mediator.Send(new GetAvailableSubjectsQuery(studentId), ct);
+
+ [GraphQLDescription("Get all professors")]
+ public async Task> GetProfessors(
+ [Service] IMediator mediator,
+ CancellationToken ct) =>
+ await mediator.Send(new GetProfessorsQuery(), ct);
+
+ [GraphQLDescription("Get classmates for a student by subject")]
+ public async Task> GetClassmates(
+ int studentId,
+ [Service] IMediator mediator,
+ CancellationToken ct) =>
+ await mediator.Send(new GetClassmatesQuery(studentId), ct);
+}
diff --git a/src/backend/Adapters/Driving/Api/Types/Students/StudentType.cs b/src/backend/Adapters/Driving/Api/Types/Students/StudentType.cs
new file mode 100644
index 0000000..f2ee869
--- /dev/null
+++ b/src/backend/Adapters/Driving/Api/Types/Students/StudentType.cs
@@ -0,0 +1,16 @@
+namespace Adapters.Driving.Api.Types.Students;
+
+using Domain.Entities;
+
+[ObjectType]
+public static partial class StudentType
+{
+ public static string GetEmail([Parent] Student student) => student.Email.Value;
+
+ [GraphQLDescription("Total credits enrolled")]
+ public static int GetTotalCredits([Parent] Student student) => student.TotalCredits;
+
+ [GraphQLDescription("List of enrollments")]
+ public static IEnumerable GetEnrollments([Parent] Student student) =>
+ student.Enrollments;
+}
diff --git a/src/backend/Adapters/Driving/Api/Types/Subjects/SubjectType.cs b/src/backend/Adapters/Driving/Api/Types/Subjects/SubjectType.cs
new file mode 100644
index 0000000..97ae5f2
--- /dev/null
+++ b/src/backend/Adapters/Driving/Api/Types/Subjects/SubjectType.cs
@@ -0,0 +1,15 @@
+namespace Adapters.Driving.Api.Types.Subjects;
+
+using Adapters.Driven.Persistence.DataLoaders;
+using Domain.Entities;
+
+[ObjectType]
+public static partial class SubjectType
+{
+ [GraphQLDescription("Professor teaching this subject")]
+ public static async Task GetProfessor(
+ [Parent] Subject subject,
+ ProfessorByIdDataLoader dataLoader,
+ CancellationToken ct) =>
+ await dataLoader.LoadAsync(subject.ProfessorId, ct);
+}