Part 4 of 7. Start from the beginning if you're new here.
The Infrastructure layer is where the rubber meets the road. This is where we implement the interfaces from Application, connect to the database, and deal with the real-world messiness that architecture diagrams ignore.
What Goes In Infrastructure
- Database access — EF Core, DbContext, migrations
- Repository implementations — The actual data access code
- External services — HTTP clients, message queues, file storage
What doesn't belong here:
- Business logic (that's Domain)
- Use case orchestration (that's Application)
- HTTP handling (that's API)
Setting Up EF Core
cd src/PromptVault.Infrastructure
dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Design
The DbContext
src/PromptVault.Infrastructure/Persistence/AppDbContext.cs
using Microsoft.EntityFrameworkCore;
using PromptVault.Domain.Entities;
namespace PromptVault.Infrastructure.Persistence;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{
}
public DbSet<Prompt> Prompts => Set<Prompt>();
public DbSet<PromptVersion> PromptVersions => Set<PromptVersion>();
public DbSet<Collection> Collections => Set<Collection>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Apply all configurations from this assembly
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}
}
Entity Configuration
EF Core needs to know how to map our domain entities to database tables. We use IEntityTypeConfiguration<T> to keep this organized.
src/PromptVault.Infrastructure/Persistence/Configurations/PromptConfiguration.cs
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using PromptVault.Domain.Entities;
using PromptVault.Domain.ValueObjects;
namespace PromptVault.Infrastructure.Persistence.Configurations;
public class PromptConfiguration : IEntityTypeConfiguration<Prompt>
{
public void Configure(EntityTypeBuilder<Prompt> builder)
{
builder.ToTable("Prompts");
builder.HasKey(p => p.Id);
builder.Property(p => p.Title)
.IsRequired()
.HasMaxLength(200);
builder.Property(p => p.Content)
.IsRequired();
// ModelType value object: convert to/from string
builder.Property(p => p.ModelType)
.HasConversion(
v => v.Value, // To database
v => new ModelType(v)) // From database
.HasMaxLength(50)
.IsRequired();
// Tags collection: store as JSON
builder.Property(p => p.Tags)
.HasConversion(
v => JsonSerializer.Serialize(v.Select(t => t.Value).ToList(), (JsonSerializerOptions?)null),
v => JsonSerializer.Deserialize<List<string>>(v, (JsonSerializerOptions?)null)!
.Select(s => new Tag(s)).ToList())
.HasColumnType("nvarchar(max)");
// Configure relationship to PromptVersions
builder.HasMany(p => p.Versions)
.WithOne()
.HasForeignKey(v => v.PromptId)
.OnDelete(DeleteBehavior.Cascade);
// Tell EF to use backing fields
builder.Navigation(p => p.Versions)
.UsePropertyAccessMode(PropertyAccessMode.Field);
builder.Navigation(p => p.Tags)
.UsePropertyAccessMode(PropertyAccessMode.Field);
// Indexes
builder.HasIndex(p => p.Title);
builder.HasIndex(p => p.CreatedAt);
}
}
Real-World Callout: Value Object Mapping
Notice the HasConversion for ModelType and Tags. EF Core doesn't natively understand value objects. Options:
- Value conversion (what we did) — Convert to/from primitives
- Owned entities — For complex value objects
- Store as JSON — For collections
⚠️ The ugly truth: This mapping code is verbose and tightly coupled to both EF Core and your domain. Some teams create separate "Persistence Models" and map between them and domain entities. That's more pure but doubles your entity count.
Our pragmatic choice: Domain entities with EF Core configuration. Accept the coupling.
PromptVersion Configuration
src/PromptVault.Infrastructure/Persistence/Configurations/PromptVersionConfiguration.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using PromptVault.Domain.Entities;
namespace PromptVault.Infrastructure.Persistence.Configurations;
public class PromptVersionConfiguration : IEntityTypeConfiguration<PromptVersion>
{
public void Configure(EntityTypeBuilder<PromptVersion> builder)
{
builder.ToTable("PromptVersions");
builder.HasKey(v => v.Id);
builder.Property(v => v.VersionNumber).IsRequired();
builder.Property(v => v.Content).IsRequired();
builder.Property(v => v.CreatedAt).IsRequired();
builder.Property(v => v.CreatedBy).HasMaxLength(100);
// Unique: one version number per prompt
builder.HasIndex(v => new { v.PromptId, v.VersionNumber }).IsUnique();
}
}
Repository Implementation
Now we implement the interface defined in Application:
src/PromptVault.Infrastructure/Repositories/PromptRepository.cs
using Microsoft.EntityFrameworkCore;
using PromptVault.Application.Interfaces;
using PromptVault.Domain.Entities;
using PromptVault.Infrastructure.Persistence;
namespace PromptVault.Infrastructure.Repositories;
public class PromptRepository : IPromptRepository
{
private readonly AppDbContext _context;
public PromptRepository(AppDbContext context)
{
_context = context;
}
public async Task<Prompt?> GetByIdAsync(Guid id, CancellationToken ct = default)
{
return await _context.Prompts.FirstOrDefaultAsync(p => p.Id == id, ct);
}
public async Task<Prompt?> GetByIdWithVersionsAsync(Guid id, CancellationToken ct = default)
{
return await _context.Prompts
.Include(p => p.Versions)
.FirstOrDefaultAsync(p => p.Id == id, ct);
}
public async Task<List<Prompt>> SearchAsync(string query, CancellationToken ct = default)
{
var lowerQuery = query.ToLowerInvariant();
return await _context.Prompts
.Where(p =>
p.Title.ToLower().Contains(lowerQuery) ||
p.Content.ToLower().Contains(lowerQuery))
.OrderByDescending(p => p.CreatedAt)
.ToListAsync(ct);
}
public async Task<(List<Prompt> Items, int TotalCount)> GetPagedAsync(
int page, int pageSize, string? searchTerm, string? tag, CancellationToken ct = default)
{
var query = _context.Prompts.AsQueryable();
if (!string.IsNullOrWhiteSpace(searchTerm))
{
var lower = searchTerm.ToLowerInvariant();
query = query.Where(p =>
p.Title.ToLower().Contains(lower) ||
p.Content.ToLower().Contains(lower));
}
if (!string.IsNullOrWhiteSpace(tag))
{
var normalizedTag = tag.ToLowerInvariant();
query = query.Where(p => EF.Functions.Like(
EF.Property<string>(p, "Tags"),
$"%\"{normalizedTag}\"%"));
}
var totalCount = await query.CountAsync(ct);
var items = await query
.OrderByDescending(p => p.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(ct);
return (items, totalCount);
}
public async Task AddAsync(Prompt prompt, CancellationToken ct = default)
{
await _context.Prompts.AddAsync(prompt, ct);
await _context.SaveChangesAsync(ct);
}
public async Task UpdateAsync(Prompt prompt, CancellationToken ct = default)
{
_context.Prompts.Update(prompt);
await _context.SaveChangesAsync(ct);
}
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{
var prompt = await GetByIdAsync(id, ct);
if (prompt != null)
{
_context.Prompts.Remove(prompt);
await _context.SaveChangesAsync(ct);
}
}
public async Task<bool> ExistsAsync(Guid id, CancellationToken ct = default)
{
return await _context.Prompts.AnyAsync(p => p.Id == id, ct);
}
public async Task<bool> TitleExistsAsync(string title, Guid? excludeId = null, CancellationToken ct = default)
{
var query = _context.Prompts.Where(p => p.Title == title);
if (excludeId.HasValue)
query = query.Where(p => p.Id != excludeId.Value);
return await query.AnyAsync(ct);
}
}
The Repository Debate
Let's address the elephant: Do you even need repositories?
The Case Against
EF Core's DbContext already implements Repository and Unit of Work patterns. Adding another layer:
- Is just wrapping
DbContextcalls - Hides EF Core's power
- Can create N+1 query problems
Many successful projects just inject DbContext into handlers:
public class GetPromptByIdQueryHandler : IRequestHandler<GetPromptByIdQuery, Result<PromptDto>>
{
private readonly AppDbContext _context; // Direct DbContext
public async Task<Result<PromptDto>> Handle(GetPromptByIdQuery request, CancellationToken ct)
{
var prompt = await _context.Prompts
.Include(p => p.Versions)
.FirstOrDefaultAsync(p => p.Id == request.Id, ct);
// ...
}
}
The Case For
-
Testability — Mocking
IPromptRepositoryis cleaner than mockingDbContext - Query encapsulation — Complex queries have a home
- Swappability — You can (theoretically) swap EF Core for Dapper
My Recommendation
Use repositories when:
- You have complex queries that deserve encapsulation
- You want to test handlers without an in-memory database
- Multiple handlers share the same queries
Skip repositories when:
- Most operations are simple CRUD
- You're comfortable testing with EF Core's in-memory provider
Real-World Callout: When IQueryable Leaks
A common mistake:
// ❌ DON'T DO THIS
public interface IPromptRepository
{
IQueryable<Prompt> GetAll();
}
If your repository returns IQueryable, you haven't abstracted anything. The calling code can add any LINQ, and you've leaked EF Core into your Application layer.
Instead: Return concrete collections (List<T>) or define specific query methods.
Dependency Injection Registration
Create an extension method to register Infrastructure services:
src/PromptVault.Infrastructure/DependencyInjection.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using PromptVault.Application.Interfaces;
using PromptVault.Infrastructure.Persistence;
using PromptVault.Infrastructure.Repositories;
namespace PromptVault.Infrastructure;
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(
configuration.GetConnectionString("DefaultConnection"),
b => b.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName)));
services.AddScoped<IPromptRepository, PromptRepository>();
services.AddScoped<ICollectionRepository, CollectionRepository>();
return services;
}
}
Migrations
Create and apply migrations:
cd src/PromptVault.API
dotnet ef migrations add InitialCreate \
--project ../PromptVault.Infrastructure \
--startup-project .
dotnet ef database update \
--project ../PromptVault.Infrastructure \
--startup-project .
💡 Tip: Put migration scripts in source control. Review them in PRs. Auto-generated migrations sometimes do stupid things.
Unit of Work: Do You Need It?
The textbook says: Wrap multiple operations in a Unit of Work for atomic commits.
public interface IUnitOfWork
{
IPromptRepository Prompts { get; }
Task<int> CommitAsync();
}
🔥 Real talk:
DbContextIS a Unit of Work. It tracks changes and commits them together. You only need an explicitIUnitOfWorkif you have multiple DbContexts or need to defer commits.
Our approach: Each repository calls SaveChangesAsync(). For atomic cross-entity operations, use explicit transactions:
using var transaction = await _context.Database.BeginTransactionAsync(ct);
try
{
// Multiple operations...
await _context.SaveChangesAsync(ct);
await transaction.CommitAsync(ct);
}
catch
{
await transaction.RollbackAsync(ct);
throw;
}
Infrastructure Project Structure
src/PromptVault.Infrastructure/
├── Persistence/
│ ├── AppDbContext.cs
│ └── Configurations/
│ ├── PromptConfiguration.cs
│ ├── PromptVersionConfiguration.cs
│ └── CollectionConfiguration.cs
├── Repositories/
│ ├── PromptRepository.cs
│ └── CollectionRepository.cs
└── DependencyInjection.cs
Testing Repositories
Use EF Core's in-memory provider:
public class PromptRepositoryTests
{
private AppDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new AppDbContext(options);
}
[Fact]
public async Task AddAsync_SavesPromptToDatabase()
{
await using var context = CreateContext();
var repository = new PromptRepository(context);
var prompt = new Prompt("Test", "Content", new ModelType("gpt-4"));
await repository.AddAsync(prompt);
var saved = await context.Prompts.FindAsync(prompt.Id);
Assert.NotNull(saved);
Assert.Equal("Test", saved.Title);
}
}
⚠️ Caveat: In-memory provider doesn't perfectly replicate SQL Server. For critical queries, also test against a real database.
Key Takeaways
- Value object mapping is verbose — Accept it or use owned entities
- Repositories wrap DbContext — Useful for query encapsulation and testing
- IQueryable leaks your abstraction — Return concrete collections
- DbContext IS a Unit of Work — You rarely need another layer
- Migrations need review — Don't blindly trust auto-generation
Coming Up
In Part 5, we'll build the API layer:
- Controllers that do almost nothing
- Minimal APIs as an alternative
- Error handling and HTTP status codes
👉 Part 5: The API Layer — Controllers vs Minimal APIs
Full source: promptvault
Top comments (0)