Index ยท How it works
1 min read- How it works
- The stack
- DbContext setup
- Query patterns โ LINQ
- Query patterns โ raw SQL
- Transactions
- Migrations โ full EF Core CLI workflow
- Creating a migration
- Applying migrations at service startup (current codebase pattern)
- State tracking
- Design-time DbContext factory (for `dotnet ef` outside the host)
- Reverting
How it works
The stack
[your code]
โ
[query layer: EF Core LINQ OR EF Core raw SQL OR Dapper OR NpgsqlCommand]
โ
[provider: Npgsql.EntityFrameworkCore.PostgreSQL]
โ
[driver: Npgsql โ binary protocol, allocation-aware]
โ
[Postgres]
DbContext setup
Per service convention used across this codebase (TenantService, QuestionerService, etc.):
// Registration
builder.Services.AddDbContext<IdentityDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("IdentityDb"))
);
// For high-traffic services, DbContext pooling cuts allocations
builder.Services.AddDbContextPool<IdentityDbContext>(options =>
options.UseNpgsql(connectionString), poolSize: 128);
AddDbContextPool reuses DbContext instances across requests โ meaningful win on hot paths because change-tracker / state-manager allocation is the bulk of per-request overhead.
Query patterns โ LINQ
// SELECT one
var tenant = await db.Tenants
.Where(t => t.Slug == slug)
.FirstOrDefaultAsync();
// SELECT many with projection (avoids loading full entity)
var summaries = await db.Tenants
.Where(t => t.Active)
.Select(t => new TenantSummary(t.TenantId, t.Slug, t.Name))
.ToListAsync();
// Read-only query โ disable change tracking
var tenants = await db.Tenants
.AsNoTracking()
.Where(t => t.Active)
.ToListAsync();
// INSERT
db.Tenants.Add(new Tenant { Slug = "kucy", Name = "Kizomba Union CY" });
await db.SaveChangesAsync();
// UPDATE โ change tracker generates the SQL
var tenant = await db.Tenants.FirstAsync(t => t.Slug == slug);
tenant.ThemeConfigJson = newJson;
await db.SaveChangesAsync();
// UPDATE โ bulk, no entity load (EF Core 7+)
await db.Tenants
.Where(t => t.Slug == slug)
.ExecuteUpdateAsync(setters => setters
.SetProperty(t => t.ThemeConfigJson, newJson)
.SetProperty(t => t.UpdatedAt, DateTime.UtcNow));
// DELETE โ bulk, no entity load (EF Core 7+)
await db.Tenants
.Where(t => t.TenantId == id)
.ExecuteDeleteAsync();
ExecuteUpdateAsync / ExecuteDeleteAsync (EF Core 7+) are the modern way to do bulk modifications without loading entities into the tracker. Use them whenever you don't need the entity in memory.
Query patterns โ raw SQL
// FromSqlRaw โ returns entities, still uses change tracker
var tenants = await db.Tenants
.FromSqlRaw("SELECT * FROM tenants WHERE slug LIKE {0}", $"{prefix}%")
.AsNoTracking()
.ToListAsync();
// FromSqlInterpolated โ safer, parameterized via string interpolation
var tenants = await db.Tenants
.FromSqlInterpolated($"SELECT * FROM tenants WHERE slug = {slug}")
.ToListAsync();
// Raw execution
await db.Database.ExecuteSqlRawAsync(
"UPDATE tenants SET last_seen = now() WHERE tenant_id = {0}", tenantId);
Transactions
await using var transaction = await db.Database.BeginTransactionAsync();
try
{
db.Tenants.Add(tenant);
await db.SaveChangesAsync();
foreach (var evt in events)
{
db.OutboxEntries.Add(new OutboxEntry(evt.GetType().Name, JsonSerializer.Serialize(evt)));
}
await db.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
The await using pattern auto-disposes the transaction. If you didn't commit, dispose rolls back. Most code uses this without explicit Rollback.
For savepoints (nested transactions):
await transaction.CreateSavepointAsync("before_outbox");
// ... do work ...
await transaction.RollbackToSavepointAsync("before_outbox"); // if needed
Migrations โ full EF Core CLI workflow
Creating a migration
# Inside the project that has the DbContext
dotnet ef migrations add AddOnboardingState --project src/TenantService.Infrastructure
EF Core diffs your current entity model against the last snapshot, generates a migration class:
public partial class AddOnboardingState : Migration
{
protected override void Up(MigrationBuilder mb)
{
mb.AddColumn<string>(
name: "OnboardingState",
table: "UserPreferences",
type: "text",
nullable: true);
}
protected override void Down(MigrationBuilder mb)
{
mb.DropColumn(name: "OnboardingState", table: "UserPreferences");
}
}
Applying migrations at service startup (current codebase pattern)
// From TenantService/src/TenantService.API/ProgramExtensions.cs
try
{
await dbContext.Database.MigrateAsync();
Log.Information("Database migrations applied successfully");
}
catch (Exception ex)
{
Log.Error(ex, "Database migration failed โ service starting in degraded mode");
}
MigrateAsync is idempotent: it reads __EFMigrationsHistory, applies anything missing, no-ops if current.
State tracking
EF Core auto-creates:
CREATE TABLE "__EFMigrationsHistory" (
"MigrationId" VARCHAR(150) PRIMARY KEY,
"ProductVersion" VARCHAR(32) NOT NULL
);
Design-time DbContext factory (for dotnet ef outside the host)
For services where the host requires runtime config the CLI doesn't have (Keycloak issuer URLs, etc.):
public class IdentityDbContextFactory : IDesignTimeDbContextFactory<IdentityDbContext>
{
public IdentityDbContext CreateDbContext(string[] args)
{
var options = new DbContextOptionsBuilder<IdentityDbContext>()
.UseNpgsql("Host=localhost;Database=identity;Username=postgres;Password=postgres")
.Options;
return new IdentityDbContext(options);
}
}
Reverting
# Apply migrations up to a target
dotnet ef database update PreviousMigrationName
# Generate idempotent SQL script (for prod review)
dotnet ef migrations script --idempotent --output migrate.sql
# Bundle migrations into a standalone executable (for CI/CD)
dotnet ef migrations bundle --output efbundle.exe