feat(adapters): add driven and driving adapters

Driven Adapters (Persistence):
- AppDbContext with EF Core configurations
- Repository implementations (Student, Subject, Professor, Enrollment)
- UnitOfWork pattern for transactions
- DataLoaders for GraphQL N+1 optimization
- Database seeding with 5 professors and 10 subjects
- EF Core migrations for SQL Server

Driving Adapters (API):
- GraphQL API with HotChocolate
- Query and Mutation types
- Type definitions for all entities
- GraphQLErrorFilter for domain exceptions
This commit is contained in:
Andrés Eduardo García Márquez 2026-01-07 22:59:38 -05:00
parent 68e420fdf2
commit 60d35f1040
31 changed files with 1941 additions and 0 deletions

View File

@ -0,0 +1,23 @@
<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="Microsoft.EntityFrameworkCore" Version="*" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="*" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="*">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="GreenDonut" Version="*" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,55 @@
namespace Adapters.Driven.Persistence;
using Adapters.Driven.Persistence.Context;
using Domain.Entities;
using Microsoft.EntityFrameworkCore;
/// <summary>
/// Pre-compiled EF Core queries for frequently used operations.
/// Compiled queries skip expression tree parsing on each execution.
/// </summary>
public static class CompiledQueries
{
// Student queries
public static readonly Func<AppDbContext, int, Task<Student?>> GetStudentById =
EF.CompileAsyncQuery((AppDbContext ctx, int id) =>
ctx.Students.FirstOrDefault(s => s.Id == id));
public static readonly Func<AppDbContext, int, Task<bool>> 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<AppDbContext, int, Task<Subject?>> GetSubjectById =
EF.CompileAsyncQuery((AppDbContext ctx, int id) =>
ctx.Subjects.FirstOrDefault(s => s.Id == id));
public static readonly Func<AppDbContext, IAsyncEnumerable<Subject>> GetAllSubjects =
EF.CompileAsyncQuery((AppDbContext ctx) =>
ctx.Subjects.AsNoTracking());
// Professor queries
public static readonly Func<AppDbContext, int, Task<Professor?>> GetProfessorById =
EF.CompileAsyncQuery((AppDbContext ctx, int id) =>
ctx.Professors.FirstOrDefault(p => p.Id == id));
// Enrollment queries
public static readonly Func<AppDbContext, int, Task<int>> CountStudentEnrollments =
EF.CompileAsyncQuery((AppDbContext ctx, int studentId) =>
ctx.Enrollments.Count(e => e.StudentId == studentId));
public static readonly Func<AppDbContext, int, int, Task<bool>> 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<AppDbContext, Task<int>> CountStudents =
EF.CompileAsyncQuery((AppDbContext ctx) =>
ctx.Students.Count());
public static readonly Func<AppDbContext, Task<int>> CountSubjects =
EF.CompileAsyncQuery((AppDbContext ctx) =>
ctx.Subjects.Count());
}

View File

@ -0,0 +1,30 @@
namespace Adapters.Driven.Persistence.Configurations;
using Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class EnrollmentConfiguration : IEntityTypeConfiguration<Enrollment>
{
public void Configure(EntityTypeBuilder<Enrollment> 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);
}
}

View File

@ -0,0 +1,23 @@
namespace Adapters.Driven.Persistence.Configurations;
using Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class ProfessorConfiguration : IEntityTypeConfiguration<Professor>
{
public void Configure(EntityTypeBuilder<Professor> 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);
}
}

View File

@ -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<Student>
{
public void Configure(EntityTypeBuilder<Student> 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<Email>(
(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);
}
}

View File

@ -0,0 +1,33 @@
namespace Adapters.Driven.Persistence.Configurations;
using Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public class SubjectConfiguration : IEntityTypeConfiguration<Subject>
{
public void Configure(EntityTypeBuilder<Subject> 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);
}
}

View File

@ -0,0 +1,20 @@
namespace Adapters.Driven.Persistence.Context;
using Adapters.Driven.Persistence.Seeding;
using Domain.Entities;
using Microsoft.EntityFrameworkCore;
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<Student> Students => Set<Student>();
public DbSet<Subject> Subjects => Set<Subject>();
public DbSet<Professor> Professors => Set<Professor>();
public DbSet<Enrollment> Enrollments => Set<Enrollment>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
DataSeeder.Seed(modelBuilder);
base.OnModelCreating(modelBuilder);
}
}

View File

@ -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<int, Professor>
{
private readonly IDbContextFactory<AppDbContext> _contextFactory;
public ProfessorByIdDataLoader(
IDbContextFactory<AppDbContext> contextFactory,
IBatchScheduler batchScheduler,
DataLoaderOptions? options = null)
: base(batchScheduler, options ?? new DataLoaderOptions())
{
_contextFactory = contextFactory;
}
protected override async Task<IReadOnlyDictionary<int, Professor>> LoadBatchAsync(
IReadOnlyList<int> 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);
}
}

View File

@ -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<int, Student>
{
private readonly IDbContextFactory<AppDbContext> _contextFactory;
public StudentByIdDataLoader(
IDbContextFactory<AppDbContext> contextFactory,
IBatchScheduler batchScheduler,
DataLoaderOptions? options = null)
: base(batchScheduler, options ?? new DataLoaderOptions())
{
_contextFactory = contextFactory;
}
protected override async Task<IReadOnlyDictionary<int, Student>> LoadBatchAsync(
IReadOnlyList<int> 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);
}
}

View File

@ -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<int, Subject>
{
private readonly IDbContextFactory<AppDbContext> _contextFactory;
public SubjectByIdDataLoader(
IDbContextFactory<AppDbContext> contextFactory,
IBatchScheduler batchScheduler,
DataLoaderOptions? options = null)
: base(batchScheduler, options ?? new DataLoaderOptions())
{
_contextFactory = contextFactory;
}
protected override async Task<IReadOnlyDictionary<int, Subject>> LoadBatchAsync(
IReadOnlyList<int> 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);
}
}

View File

@ -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<AppDbContext>(options =>
options.UseSqlServer(
configuration.GetConnectionString("DefaultConnection"),
b => b.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName)));
services.AddScoped<IStudentRepository, StudentRepository>();
services.AddScoped<ISubjectRepository, SubjectRepository>();
services.AddScoped<IProfessorRepository, ProfessorRepository>();
services.AddScoped<IEnrollmentRepository, EnrollmentRepository>();
services.AddScoped<IUnitOfWork, UnitOfWork>();
return services;
}
}

View File

@ -0,0 +1,174 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("EnrolledAt")
.HasColumnType("datetime2");
b.Property<int>("StudentId")
.HasColumnType("int");
b.Property<int>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.HasKey("Id");
b.ToTable("Professors", (string)null);
});
modelBuilder.Entity("Domain.Entities.Student", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("nvarchar(150)");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("Credits")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(3);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int>("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
}
}
}

View File

@ -0,0 +1,128 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Adapters.Driven.Persistence.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Professors",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(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<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
Email = table.Column<string>(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<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
Credits = table.Column<int>(type: "int", nullable: false, defaultValue: 3),
ProfessorId = table.Column<int>(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<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
StudentId = table.Column<int>(type: "int", nullable: false),
SubjectId = table.Column<int>(type: "int", nullable: false),
EnrolledAt = table.Column<DateTime>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Enrollments");
migrationBuilder.DropTable(
name: "Students");
migrationBuilder.DropTable(
name: "Subjects");
migrationBuilder.DropTable(
name: "Professors");
}
}
}

View File

@ -0,0 +1,273 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("EnrolledAt")
.HasColumnType("datetime2");
b.Property<int>("StudentId")
.HasColumnType("int");
b.Property<int>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("nvarchar(150)");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("Credits")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(3);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int>("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
}
}
}

View File

@ -0,0 +1,124 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace Adapters.Driven.Persistence.Migrations
{
/// <inheritdoc />
public partial class SeedData : Migration
{
/// <inheritdoc />
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 }
});
}
/// <inheritdoc />
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);
}
}
}

View File

@ -0,0 +1,270 @@
// <auto-generated />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("EnrolledAt")
.HasColumnType("datetime2");
b.Property<int>("StudentId")
.HasColumnType("int");
b.Property<int>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("nvarchar(150)");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("Credits")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(3);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int>("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
}
}
}

View File

@ -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<Enrollment?> GetByIdAsync(int id, CancellationToken ct = default) =>
await context.Enrollments.FindAsync([id], ct);
public async Task<Enrollment?> GetByStudentAndSubjectAsync(int studentId, int subjectId, CancellationToken ct = default) =>
await context.Enrollments
.FirstOrDefaultAsync(e => e.StudentId == studentId && e.SubjectId == subjectId, ct);
public async Task<IReadOnlyList<Enrollment>> 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<IReadOnlyList<Enrollment>> GetBySubjectIdAsync(int subjectId, CancellationToken ct = default) =>
await context.Enrollments
.Include(e => e.Student)
.Where(e => e.SubjectId == subjectId)
.AsNoTracking()
.ToListAsync(ct);
public async Task<IReadOnlyList<Student>> 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<IReadOnlyDictionary<int, IReadOnlyList<Student>>> GetClassmatesBatchAsync(
int studentId, IEnumerable<int> subjectIds, CancellationToken ct = default)
{
var subjectIdList = subjectIds.ToList();
if (subjectIdList.Count == 0)
return new Dictionary<int, IReadOnlyList<Student>>();
// 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<Student>)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);
}

View File

@ -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<Professor?> GetByIdAsync(int id, CancellationToken ct = default) =>
await context.Professors.FindAsync([id], ct);
public async Task<IReadOnlyList<Professor>> GetAllAsync(CancellationToken ct = default) =>
await context.Professors.AsNoTracking().ToListAsync(ct);
public async Task<IReadOnlyList<Professor>> GetAllWithSubjectsAsync(CancellationToken ct = default) =>
await context.Professors
.Include(p => p.Subjects)
.AsNoTracking()
.ToListAsync(ct);
// Direct projection - only SELECT needed columns
public async Task<IReadOnlyList<TResult>> GetAllProjectedAsync<TResult>(
Expression<Func<Professor, TResult>> selector,
CancellationToken ct = default) =>
await context.Professors
.AsNoTracking()
.Select(selector)
.ToListAsync(ct);
}

View File

@ -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<Student?> GetByIdAsync(int id, CancellationToken ct = default) =>
await context.Students.FindAsync([id], ct);
public async Task<Student?> 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<IReadOnlyList<Student>> GetAllAsync(CancellationToken ct = default) =>
await context.Students.AsNoTracking().ToListAsync(ct);
public async Task<IReadOnlyList<Student>> 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<bool> ExistsAsync(int id, CancellationToken ct = default) =>
await CompiledQueries.StudentExists(context, id);
public async Task<bool> 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<int>(sql, parameters)
.AnyAsync(ct);
}
// Direct projection - query only needed columns
public async Task<IReadOnlyList<TResult>> GetAllProjectedAsync<TResult>(
Expression<Func<Student, TResult>> selector,
CancellationToken ct = default) =>
await context.Students
.AsNoTracking()
.Select(selector)
.ToListAsync(ct);
public async Task<TResult?> GetByIdProjectedAsync<TResult>(
int id,
Expression<Func<Student, TResult>> 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<TResult> Items, int? NextCursor, int TotalCount)> GetPagedProjectedAsync<TResult>(
Expression<Func<Student, TResult>> 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);
}

View File

@ -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<Subject?> GetByIdAsync(int id, CancellationToken ct = default) =>
await context.Subjects.FindAsync([id], ct);
public async Task<Subject?> GetByIdWithProfessorAsync(int id, CancellationToken ct = default) =>
await context.Subjects
.Include(s => s.Professor)
.FirstOrDefaultAsync(s => s.Id == id, ct);
public async Task<IReadOnlyList<Subject>> GetAllAsync(CancellationToken ct = default) =>
await context.Subjects.AsNoTracking().ToListAsync(ct);
public async Task<IReadOnlyList<Subject>> GetAllWithProfessorsAsync(CancellationToken ct = default) =>
await context.Subjects
.Include(s => s.Professor)
.AsNoTracking()
.ToListAsync(ct);
public async Task<IReadOnlyList<Subject>> 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<IReadOnlyList<TResult>> GetAllProjectedAsync<TResult>(
Expression<Func<Subject, TResult>> selector,
CancellationToken ct = default) =>
await context.Subjects
.AsNoTracking()
.Select(selector)
.ToListAsync(ct);
}

View File

@ -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<int> SaveChangesAsync(CancellationToken ct = default) =>
await context.SaveChangesAsync(ct);
}

View File

@ -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<Domain.Entities.Professor>().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<Domain.Entities.Subject>().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 }
);
}
}

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="..\..\..\Application\Application.csproj" />
<ProjectReference Include="..\..\Driven\Persistence\Adapters.Driven.Persistence.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="HotChocolate.AspNetCore" Version="*" />
<PackageReference Include="HotChocolate.Data" Version="*" />
<PackageReference Include="HotChocolate.Data.EntityFramework" Version="*" />
</ItemGroup>
</Project>

View File

@ -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<Query>()
.AddMutationType<Mutation>()
.AddProjections()
.AddFiltering()
.AddSorting()
.AddErrorFilter<GraphQLErrorFilter>()
.AddDataLoader<StudentByIdDataLoader>()
.AddDataLoader<SubjectByIdDataLoader>()
.AddDataLoader<ProfessorByIdDataLoader>()
// 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);
}
}

View File

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

View File

@ -0,0 +1,22 @@
namespace Adapters.Driving.Api.Types.Enrollments;
using Adapters.Driven.Persistence.DataLoaders;
using Domain.Entities;
[ObjectType<Enrollment>]
public static partial class EnrollmentType
{
[GraphQLDescription("Student enrolled")]
public static async Task<Student?> GetStudent(
[Parent] Enrollment enrollment,
StudentByIdDataLoader dataLoader,
CancellationToken ct) =>
await dataLoader.LoadAsync(enrollment.StudentId, ct);
[GraphQLDescription("Subject enrolled in")]
public static async Task<Subject?> GetSubject(
[Parent] Enrollment enrollment,
SubjectByIdDataLoader dataLoader,
CancellationToken ct) =>
await dataLoader.LoadAsync(enrollment.SubjectId, ct);
}

View File

@ -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<CreateStudentPayload> 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<UpdateStudentPayload> 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<DeleteStudentPayload> 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<EnrollStudentPayload> 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<UnenrollStudentPayload> 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);

View File

@ -0,0 +1,11 @@
namespace Adapters.Driving.Api.Types.Professors;
using Domain.Entities;
[ObjectType<Professor>]
public static partial class ProfessorType
{
[GraphQLDescription("Subjects taught by this professor")]
public static IEnumerable<Subject> GetSubjects([Parent] Professor professor) =>
professor.Subjects;
}

View File

@ -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<IReadOnlyList<StudentDto>> GetStudents(
[Service] IMediator mediator,
CancellationToken ct) =>
await mediator.Send(new GetStudentsQuery(), ct);
[GraphQLDescription("Get students with cursor-based pagination (keyset)")]
public async Task<PagedResult<StudentPagedDto>> 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<StudentDto> GetStudent(
int id,
[Service] IMediator mediator,
CancellationToken ct) =>
await mediator.Send(new GetStudentByIdQuery(id), ct);
[GraphQLDescription("Get all subjects")]
public async Task<IReadOnlyList<SubjectDto>> GetSubjects(
[Service] IMediator mediator,
CancellationToken ct) =>
await mediator.Send(new GetSubjectsQuery(), ct);
[GraphQLDescription("Get available subjects for a student")]
public async Task<IReadOnlyList<AvailableSubjectDto>> GetAvailableSubjects(
int studentId,
[Service] IMediator mediator,
CancellationToken ct) =>
await mediator.Send(new GetAvailableSubjectsQuery(studentId), ct);
[GraphQLDescription("Get all professors")]
public async Task<IReadOnlyList<ProfessorDto>> GetProfessors(
[Service] IMediator mediator,
CancellationToken ct) =>
await mediator.Send(new GetProfessorsQuery(), ct);
[GraphQLDescription("Get classmates for a student by subject")]
public async Task<IReadOnlyList<ClassmatesBySubjectDto>> GetClassmates(
int studentId,
[Service] IMediator mediator,
CancellationToken ct) =>
await mediator.Send(new GetClassmatesQuery(studentId), ct);
}

View File

@ -0,0 +1,16 @@
namespace Adapters.Driving.Api.Types.Students;
using Domain.Entities;
[ObjectType<Student>]
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<Enrollment> GetEnrollments([Parent] Student student) =>
student.Enrollments;
}

View File

@ -0,0 +1,15 @@
namespace Adapters.Driving.Api.Types.Subjects;
using Adapters.Driven.Persistence.DataLoaders;
using Domain.Entities;
[ObjectType<Subject>]
public static partial class SubjectType
{
[GraphQLDescription("Professor teaching this subject")]
public static async Task<Professor?> GetProfessor(
[Parent] Subject subject,
ProfessorByIdDataLoader dataLoader,
CancellationToken ct) =>
await dataLoader.LoadAsync(subject.ProfessorId, ct);
}