Skip to main content
ANVISoftware Solutions
Lesson 3 of 17Intermediate18 min

DbContext

By the end of this lesson

Configure a context and understand its lifetime and responsibilities.

A DbContext is your session with the database. You create one, do some reading and writing through it, save, and let it go.

It has two jobs worth naming before any code. It is a unit of work: it collects the changes you make and writes them in one transaction when you call SaveChanges. It is also an identity map: within one context, one row from the database is represented by exactly one object in memory, however many times you ask for it.

Those two jobs explain nearly every rule about DbContext, including the one people get wrong most often — how long it should live.

AnviContext.cs — a context for the HR and orders model
C#
using Microsoft.EntityFrameworkCore;

public class AnviContext : DbContext
{
    public AnviContext(DbContextOptions<AnviContext> options)
        : base(options)
    {
    }

    public DbSet<Employee> Employees => Set<Employee>();
    public DbSet<Department> Departments => Set<Department>();
    public DbSet<Customer> Customers => Set<Customer>();
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<OrderItem> OrderItems => Set<OrderItem>();
    public DbSet<Product> Products => Set<Product>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfigurationsFromAssembly(
            typeof(AnviContext).Assembly);
    }
}
  • The constructor takes DbContextOptions rather than building its own connection. That is what lets the application decide the database and the settings, and what lets a test point the same context at something else.
  • Each DbSet property is an entry point for queries against one entity type. A DbSet is not a collection of loaded objects — it is the starting point of an IQueryable, as the previous lesson described.
  • Set<Employee>() is used instead of an auto-property so the properties can be read-only. Either form works; this one removes a settable property that nothing should ever set.
  • OnModelCreating is where mapping rules go. ApplyConfigurationsFromAssembly picks up every configuration class in the project, which keeps this method from growing into a wall of rules. The next module covers what goes in those classes.

What one context instance is responsible for:

Holding the model
The mapping between your classes and the database. The model itself is built once and cached application-wide, so creating a context is cheap after the first one.
Managing the connection
A context opens a connection when it needs one and closes it again. It does not hold a connection open for its whole lifetime, which is why a context is not itself a scarce resource.
Tracking changes
Every entity loaded through the context is recorded along with its original property values. Edit a loaded Employee and the context knows which properties differ.
Acting as a unit of work
SaveChanges writes every pending insert, update and delete inside a single transaction. If one statement fails, none of them are applied.
Acting as an identity map
One database row maps to one object per context. Two queries that return employee 7 hand you the same instance, so an edit made through one reference is visible through the other.
The identity map, demonstrated
C#
Employee first = await context.Employees.FindAsync(7);

// No SQL is sent this time. Employee 7 is already tracked.
Employee second = await context.Employees.FindAsync(7);

Console.WriteLine(ReferenceEquals(first, second)); // True

first.AnnualSalary = 62000m;
Console.WriteLine(second.AnnualSalary);            // 62000

await context.SaveChangesAsync();                  // one UPDATE
  • FindAsync checks the change tracker before the database. The first call queries; the second finds employee 7 already tracked and returns the instance it has.
  • ReferenceEquals is true because there is one object, not two copies. That is the identity map doing its job.
  • The consequence is the line that surprises people: assigning through first changes what second reports, because they are the same object.
  • SaveChangesAsync produces a single UPDATE containing only the changed column, because the tracker still holds the original salary to compare against.
Program.cs — registering the context, and using it
C#
builder.Services.AddDbContext<AnviContext>(options =>
    options.UseSqlServer(
        builder.Configuration.GetConnectionString("AnviDatabase")));

builder.Services.AddScoped<OrderService>();

// ---

public class OrderService
{
    private readonly AnviContext _context;

    public OrderService(AnviContext context) => _context = context;

    public async Task<List<Order>> GetRecentOrdersAsync(int customerId)
    {
        return await _context.Orders
            .Where(o => o.CustomerId == customerId)
            .OrderByDescending(o => o.PlacedOn)
            .Take(20)
            .ToListAsync();
    }
}
  • AddDbContext registers the context with a scoped lifetime by default. In a web application a scope is created per request, so each request gets its own context and it is disposed when the response is finished.
  • The connection string is read from configuration rather than written in code. Keep real connection strings out of source control — use user secrets in development and your platform's configuration store in production.
  • OrderService asks for an AnviContext in its constructor and never creates one. It therefore takes whatever context belongs to the current request, without knowing that requests exist.
  • Note that OrderService is registered as scoped too. A singleton service that depends on a scoped context would capture one context forever, which is the failure described below.

The two lifetimes people choose between, and what each one actually gives you:

 One context per unit of work (scoped)One long-lived context (singleton or static)
Entities trackedOnly those touched by this request, then discardedEverything ever loaded, for the life of the process
Memory over timeFlat — the tracker is released with the contextGrows continuously and does not come back down
Freshness of dataEach request reads current valuesReturns whatever it cached, however old
Concurrent useSafe — nothing shares the instanceUnsafe — two threads using it throws
Failure after an errorContained to one requestA failed SaveChanges leaves bad state behind for every later caller
Cost of creating oneSmall; the expensive model is cached separatelyPaid once, which is the only genuine advantage

Summary

  • A DbContext is a short-lived session: a unit of work plus an identity map
  • DbSet properties are query entry points, not in-memory collections
  • One row maps to one object per context, so edits through any reference are the same edit
  • A DbContext is not thread-safe and must not be shared between requests
  • Register it with AddDbContext, which is scoped by default: one context per request, disposed at the end

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Think about it

Two references, one row

A service loads employee 7, passes it to a method that raises the salary, and separately loads employee 7 again by a different query before calling SaveChanges.

Does the saved salary include the raise? Explain your answer in terms of the identity map, and say what would change if the two loads used two different contexts.

Show solution

Yes, the raise is saved. Both loads returned the same object, because a context resolves employee 7 to one instance. There is one tracked entity with one changed property, so SaveChanges writes one UPDATE.

With two separate contexts you have two objects representing the same row, each tracked independently. Saving the second context writes the salary it loaded, silently discarding the raise — a lost update, and one of the reasons the identity map exists.

The practical takeaway is to keep one context for one unit of work and pass entities around inside it, rather than creating a context wherever it seems convenient.

Try it yourself

Prove that a context is per request

In a small web application, inject AnviContext into two different services that are both used by one request, and log context.GetHashCode() from each.

Compare the values within a single request, then across two requests.

Show solution

Within one request both services log the same value: the scope resolved AnviContext once and handed the same instance to both. That is why the two services can cooperate on one transaction without passing a context between them.

Across two requests the values differ, because each request created its own scope and its own context. The first context was disposed before the second existed.

Seeing this once is worth more than reading about it, because it makes the lifetime concrete. If you ever see the same value across requests, something has captured the context — usually a singleton service holding it.

C#
public class DepartmentReader
{
    private readonly AnviContext _context;

    public DepartmentReader(AnviContext context, ILogger<DepartmentReader> log)
    {
        _context = context;
        log.LogInformation("DepartmentReader context {Id}", context.GetHashCode());
    }
}

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

Why is a scoped lifetime the right default for a DbContext in a web application?
An application registers its DbContext as a singleton. Which symptom is NOT a likely consequence?

Saved in this browser only.