Why DbContext knowledge matters in interviews
DbContext is the single most important class in EF Core. Interviewers ask about it because
misconfiguring its lifetime is one of the most common causes of production data corruption in
ASP.NET Core — and the bugs it produces are notoriously hard to reproduce.
What DbContext is
DbContext combines three patterns in one class:
- Unit of Work — batches all changes into one
SaveChangescall - Repository — exposes
DbSet<T>as the query surface for each entity - Identity Map — returns the same object instance for the same primary key within one scope
public class AppDbContext : DbContext
{
public DbSet<Order> Orders { get; set; }
public DbSet<Product> Products { get; set; }
public DbSet<Customer> Customers { get; set; }
public AppDbContext(DbContextOptions<AppDbContext> options)
: base(options) { }
protected override void OnModelCreating(ModelBuilder mb)
=> mb.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}
// Register as Scoped (the default from AddDbContext):
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
DbSet — the query and change surface
DbSet<T> is the entry point for querying and staging changes on one entity type.
// LINQ queries build SQL — nothing executes until ToListAsync:
var orders = await _db.Orders
.Where(o => o.CustomerId == id)
.OrderByDescending(o => o.CreatedAt)
.Take(20)
.ToListAsync(); // SELECT TOP 20 ... ORDER BY CreatedAt DESC
// FindAsync checks the identity map before hitting the DB:
var order = await _db.Orders.FindAsync(42);
// Stage changes — no DB call until SaveChanges:
_db.Orders.Add(new Order { CustomerId = 1 });
_db.Orders.Update(existingOrder);
_db.Orders.Remove(existingOrder);
await _db.SaveChangesAsync(); // one transaction, all staged ops
The change tracker
The change tracker watches every entity loaded through the context and records mutations. Five states map to SQL operations:
| State | SQL |
|---|---|
Added | INSERT |
Modified | UPDATE |
Deleted | DELETE |
Unchanged | (none) |
Detached | (not tracked) |
var order = await _db.Orders.FindAsync(42); // state: Unchanged
order.Status = "Shipped"; // state: Modified (auto-detected)
await _db.SaveChangesAsync(); // UPDATE Orders SET Status='Shipped' WHERE Id=42
// Skip the change tracker for read-only queries:
var orders = await _db.Orders
.AsNoTracking()
.Where(o => o.Status == "Pending")
.ToListAsync(); // faster — no snapshot, no identity map registration
AsNoTracking() is 10–20% faster on large result sets and uses less memory. Use it on
every read-only path.
SaveChanges and transactions
SaveChangesAsync collects all Added/Modified/Deleted entries and executes them in
one implicit transaction.
// One unit of work — three operations, one transaction:
_db.Customers.Add(customer);
product.Stock -= 1;
_db.Orders.Add(order);
await _db.SaveChangesAsync(); // INSERT Customer + UPDATE Product + INSERT Order in one tx
// Multiple SaveChanges calls need an explicit transaction:
await using var tx = await _db.Database.BeginTransactionAsync();
try
{
_db.Orders.Add(order);
await _db.SaveChangesAsync();
_db.Invoices.Add(invoice);
await _db.SaveChangesAsync();
await tx.CommitAsync();
}
catch { await tx.RollbackAsync(); throw; }
DbContext lifetime — why Scoped is the only right answer
DbContext is stateful — the change tracker is not thread-safe and holds open a
database connection. The wrong lifetime causes production bugs.
Scoped — one context per HTTP request. Default from AddDbContext.
Singleton — shared across all concurrent requests. Change tracker corruption.
Transient — new context per injection. Changes in ServiceA invisible to ServiceB.
Background services (hosted services, workers) must create their own scope:
public class DataSyncWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
public DataSyncWorker(IServiceScopeFactory f) => _scopeFactory = f;
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// db is a properly scoped context for this job
}
}
Never inject AppDbContext directly into a Singleton service. Use IServiceScopeFactory.
DbContext configuration — DI vs OnConfiguring
Always pass DbContextOptions through the constructor so tests can inject a different provider:
// Constructor options — testable:
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
}
builder.Services.AddDbContext<AppDbContext>(o => o.UseSqlServer(connectionString));
// Test: swap to SQLite in-memory without changing the context class:
services.AddDbContext<AppDbContext>(o => o.UseSqlite("Data Source=:memory:"));
// OnConfiguring — hardcoded; can't swap in tests:
protected override void OnConfiguring(DbContextOptionsBuilder b)
=> b.UseSqlServer("Server=prod;..."); // untestable
Entity configuration — Fluent API vs data annotations
Fluent API via IEntityTypeConfiguration<T> is the preferred approach for production codebases.
It keeps entity classes free of EF-specific attributes:
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> b)
{
b.ToTable("Orders");
b.HasKey(o => o.Id);
b.Property(o => o.Status).IsRequired().HasMaxLength(50);
b.Property(o => o.Total).HasColumnType("decimal(18,2)");
b.HasIndex(o => o.CustomerId);
}
}
// Register all configurations automatically:
protected override void OnModelCreating(ModelBuilder mb)
=> mb.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
DbContext pooling for high throughput
AddDbContextPool reuses context instances from a pool, removing per-request allocation cost:
builder.Services.AddDbContextPool<AppDbContext>(options =>
options.UseSqlServer(connectionString), poolSize: 128);
// Constraints: context constructor must accept only DbContextOptions.
// EF resets the change tracker between uses — custom fields are NOT reset.
Use IDbContextFactory<T> instead when you need fine-grained control over context lifetime
(Blazor Server components, background jobs):
builder.Services.AddDbContextFactory<AppDbContext>(o => o.UseSqlServer(connectionString));
public class ProductImporter
{
private readonly IDbContextFactory<AppDbContext> _factory;
public ProductImporter(IDbContextFactory<AppDbContext> f) => _factory = f;
public async Task ImportAsync(IEnumerable<Product> products)
{
await using var db = await _factory.CreateDbContextAsync();
db.Products.AddRange(products);
await db.SaveChangesAsync();
}
}
Interceptors for auditing
SaveChangesInterceptor stamps audit fields on every save without touching entity classes:
public class AuditInterceptor : SaveChangesInterceptor
{
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData data, InterceptionResult<int> result, CancellationToken ct = default)
{
var now = DateTime.UtcNow;
foreach (var entry in data.Context!.ChangeTracker.Entries<IAuditable>())
{
if (entry.State == EntityState.Added) entry.Entity.CreatedAt = now;
if (entry.State is EntityState.Added or EntityState.Modified)
entry.Entity.UpdatedAt = now;
}
return base.SavingChangesAsync(data, result, ct);
}
}
// Register:
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString)
.AddInterceptors(new AuditInterceptor()));
Value conversions
Value conversions let EF Core store rich domain types in simple column types:
// Enum as string (readable in the DB):
builder.Property(o => o.Status).HasConversion<string>().HasMaxLength(20);
// Strongly typed ID:
builder.Property(o => o.Id)
.HasConversion(id => id.Value, val => new OrderId(val));
// JSON column (EF Core 7+):
builder.OwnsOne(o => o.ShippingAddress, a => a.ToJson());
Global query filters
Apply automatic WHERE clauses for soft delete and multi-tenancy:
modelBuilder.Entity<Order>().HasQueryFilter(o => !o.IsDeleted);
// Every query: WHERE IsDeleted = 0 — bypass with .IgnoreQueryFilters()
Recap
DbContext is a Unit of Work + Repository + Identity Map in one. Register it Scoped.
Use AsNoTracking() on read-only queries. Pass DbContextOptions through the constructor
so tests can swap providers. Use AddDbContextPool for high-throughput APIs. Use
SaveChangesInterceptor for auditing. Use value conversions and global query filters for
cross-cutting concerns.