Skip to content

.NET Core · Entity Framework Core

EF Core DbContext, DbSet, and Change Tracking

5 min read Updated 2026-06-23 Share:

Practice DbContext & DbSet interview questions

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 SaveChanges call
  • 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:

StateSQL
AddedINSERT
ModifiedUPDATE
DeletedDELETE
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.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel