Skip to main content
Published / updated

ORM Concepts and DbContext

Before you start

You need: LINQ (Track 03 Article 06), SQL (Track 06) and ideally Dapper (Track 07) — knowing what SQL you would have written is what makes EF Core safe to use.

In Visual Studio: install Microsoft.EntityFrameworkCore.SqlServer and Microsoft.EntityFrameworkCore.Tools from NuGet. Tools is what gives you the Package Manager Console commands.

Time: about 50 minutes, plus the practice.

Learning objective

Explain what EF Core does that Dapper does not, and configure a DbContext with the correct lifetime.

Topics

  • What an ORM is
  • EF Core versus Dapper — an honest comparison
  • Installing EF Core
  • DbContext and DbSet<T>
  • Registering with dependency injection
  • DbContext lifetime and thread safety
  • Seeing the SQL EF Core generates

Where this track sits

This is an optional track. The core path is SQL Server, then ADO.NET, then Dapper — so that you understand database operations before meeting an abstraction over them.

That order matters. EF Core generates SQL on your behalf, and when a page is slow or a query returns the wrong shape, the only way forward is to read the SQL it produced. Someone who learned EF Core first has no basis for judging whether that SQL is reasonable.

You should arrive here able to write joins, read an execution plan, and explain what a SqlDataReader does.

What an ORM is

An object-relational mapper bridges two models that do not naturally fit:

RelationalObject
Tables and rowsClasses and instances
Foreign key columnsObject references
Join to fetch related dataNavigate a property
Set-based operationsLoops and collections
No inheritanceInheritance

That gap is real and is why ORMs exist. It is also why they leak: student.FeeAccount.Payments.Sum(p => p.Amount) reads like memory access and is actually one, two, or four hundred database queries depending on how it was loaded.

EF Core is a full ORM: it generates SQL, tracks changes, manages a model, and can create the schema. Dapper is a micro-ORM: it maps results and nothing else.

EF Core versus Dapper

DapperEF Core
Who writes the SQLYouEF Core, usually
MappingAutomaticAutomatic
Change trackingNoYes
Schema managementNoMigrations
Relationship navigationManualInclude, lazy loading
Learning curveSmallLarge
Performance ceilingRaw ADO.NETSlower, tunable
Debugging a slow queryRead your SQLRead generated SQL, then work out which LINQ caused it

When EF Core earns its place

  • CRUD-heavy applications where most operations are "load an object, change a field, save it"
  • Rich object graphs — an order with lines with products, saved as a unit
  • Schema evolution by migration, with the model as the source of truth
  • Teams already using it — consistency beats individual preference

When Dapper is better

  • Reporting and complex queries where the SQL is the hard part
  • Performance-critical paths
  • An existing database you do not control
  • Stored-procedure-based access

They coexist

A common and sensible shape: EF Core for CRUD, Dapper for reports.

// EF Core — load, modify, save
Student student = await _context.Students
.FirstAsync(s => s.SchoolId == schoolId && s.PublicId == publicId, ct);

student.ClassName = "11th";
await _context.SaveChangesAsync(ct);
// Dapper — a report with a computed column and an aggregate
List<StudentFeeSummaryDto> summary = (await connection.QueryAsync<StudentFeeSummaryDto>(
complexReportSql, new { SchoolId = schoolId })).ToList();

Using both is not indecision. It is choosing the right tool per operation.

Installing

Right-click the project → Manage NuGet Packages → Browse, search for the package, and click Install.

The Package Manager Console (Tools → NuGet Package Manager → Package Manager Console) does the same thing typed:

Install-Package Microsoft.EntityFrameworkCore.SqlServer
Install-Package Microsoft.EntityFrameworkCore.Tools

Tools is the package you want in Visual Studio. It provides Add-Migration, Update-Database and the rest in the Package Manager Console, and it pulls in Design as a dependency — so you do not install Design separately.

PackagePurpose
Microsoft.EntityFrameworkCoreCore runtime (pulled in as a dependency)
Microsoft.EntityFrameworkCore.SqlServerSQL Server provider
Microsoft.EntityFrameworkCore.DesignDesign-time services; pulled in by Tools
Microsoft.EntityFrameworkCore.ToolsThe one to install — Package Manager Console commands

Design belongs in the project holding the DbContext. Without it, the migration commands report that they cannot find the design-time services — a confusing message pointing away from the actual cause.

DbContext

using Microsoft.EntityFrameworkCore;

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

public DbSet<School> Schools => Set<School>();
public DbSet<Student> Students => Set<Student>();
public DbSet<Teacher> Teachers => Set<Teacher>();
public DbSet<Subject> Subjects => Set<Subject>();
public DbSet<Exam> Exams => Set<Exam>();
public DbSet<ExamResult> ExamResults => Set<ExamResult>();
public DbSet<FeeAccount> FeeAccounts => Set<FeeAccount>();
public DbSet<FeePayment> FeePayments => Set<FeePayment>();
public DbSet<Attendance> Attendances => Set<Attendance>();

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(SchoolDbContext).Assembly);
}
}

The DbContext is three things at once:

  • A model — which classes map to which tables
  • A change tracker — what you loaded and what you altered
  • A unit of workSaveChanges writes everything as one transaction

A DbSet<T> is the queryable entry point for one entity type. The => Set<T>() form avoids nullable-reference warnings that the auto-property form produces.

Registering with DI

// Program.cs
builder.Services.AddDbContext<SchoolDbContext>(options =>
{
options.UseSqlServer(
builder.Configuration.GetConnectionString("SchoolDb"),
sqlOptions =>
{
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 3,
maxRetryDelay: TimeSpan.FromSeconds(5),
errorNumbersToAdd: null);

sqlOptions.CommandTimeout(30);
});

if (builder.Environment.IsDevelopment())
{
options.EnableSensitiveDataLogging();
options.EnableDetailedErrors();
}
});

AddDbContext registers it as scoped — one instance per web request, disposed at the end. That default is correct and is what the rest of this section explains.

EnableRetryOnFailure retries transient failures (deadlocks, timeouts, Azure SQL throttling) automatically. Note that with it enabled you cannot start a user-initiated transaction without using an execution strategy explicitly — the framework tells you so if you try.

EnableSensitiveDataLogging puts parameter values into the log. Invaluable in development and a data leak in production, which is why it is guarded by the environment check.

Lifetime and thread safety

A DbContext is not thread-safe. Two operations on one instance concurrently produce:

A second operation was started on this context instance before a previous operation completed.

Almost always this is a missing await:

// Wrong — both start on the same context
Task<List<Student>> studentsTask = _context.Students.ToListAsync();
Task<List<Teacher>> teachersTask = _context.Teachers.ToListAsync();
await Task.WhenAll(studentsTask, teachersTask);
// Right — sequential on one context
List<Student> students = await _context.Students.ToListAsync(ct);
List<Teacher> teachers = await _context.Teachers.ToListAsync(ct);

For genuinely parallel work, use a context factory so each task gets its own:

builder.Services.AddDbContextFactory<SchoolDbContext>(options =>
options.UseSqlServer(connectionString));
public async Task<DashboardData> LoadAsync(int schoolId, CancellationToken ct)
{
Task<List<Student>> studentsTask = LoadStudentsAsync(schoolId, ct);
Task<List<Teacher>> teachersTask = LoadTeachersAsync(schoolId, ct);

await Task.WhenAll(studentsTask, teachersTask);

return new DashboardData
{
Students = await studentsTask,
Teachers = await teachersTask
};
}

private async Task<List<Student>> LoadStudentsAsync(int schoolId, CancellationToken ct)
{
using (SchoolDbContext context = await _contextFactory.CreateDbContextAsync(ct))
{
return await context.Students
.Where(s => s.SchoolId == schoolId && s.Status != StudentStatus.Inactive)
.ToListAsync(ct);
}
}

Why scoped is right

A DbContext accumulates every entity it loads in its change tracker. Over a long life that means:

  • Growing memory — nothing is released until disposal
  • Stale data — a tracked entity is returned from the tracker rather than re-read
  • Slower SaveChanges — change detection walks every tracked entity

A short-lived context avoids all three. Register it scoped, let it die with the request, and never make it a singleton or a static field.

Seeing the generated SQL

You cannot review what you cannot see. Configure logging before writing any queries.

builder.Services.AddDbContext<SchoolDbContext>(options =>
{
options.UseSqlServer(connectionString);

if (builder.Environment.IsDevelopment())
{
options.LogTo(Console.WriteLine, LogLevel.Information);
options.EnableSensitiveDataLogging();
}
});

Filter to just the SQL:

options.LogTo(
Console.WriteLine,
new[] { DbLoggerCategory.Database.Command.Name },
LogLevel.Information);

For one query, without configuring anything:

IQueryable<Student> query = _context.Students
.Where(s => s.SchoolId == schoolId && s.ClassName == "10th");

string sql = query.ToQueryString();

ToQueryString() returns the SQL without executing it. Use it whenever a query behaves unexpectedly — the answer is almost always visible in the generated statement.

Reading the generated SQL is not an advanced technique. It is the basic skill of using EF Core competently, and it is why this track comes after the SQL Server one.

Configuring the connection string

{
"ConnectionStrings": {
"SchoolDb": "Server=.;Database=NexCodingSchool;Integrated Security=True;TrustServerCertificate=True;"
}
}

Same rules as the Dapper track: no production credentials in appsettings.json, which is committed and stays in Git history. Use user secrets in development and environment variables or a key vault in production.

Right-click the project → Manage User Secrets, and put the connection string in the secrets.json that opens:

{
"ConnectionStrings": {
"SchoolDb": "Server=.;Database=NexCodingSchool;Integrated Security=True;TrustServerCertificate=True;"
}
}

A design-time factory

The migration commands need to construct your context outside the application host. Usually it can, by finding Program.cs. When it cannot — a class library, or a non-standard startup — supply a factory:

public class SchoolDbContextFactory : IDesignTimeDbContextFactory<SchoolDbContext>
{
public SchoolDbContext CreateDbContext(string[] args)
{
DbContextOptionsBuilder<SchoolDbContext> builder =
new DbContextOptionsBuilder<SchoolDbContext>();

builder.UseSqlServer(
"Server=.;Database=NexCodingSchool;Integrated Security=True;TrustServerCertificate=True;");

return new SchoolDbContext(builder.Options);
}
}

This is used only by the CLI, never at run time. It is the standard fix for "Unable to create a 'DbContext' of type ...".

Errors you will hit

MessageCauseFix
No DbContext was found in assemblyPackage Manager Console Default project is wrongSet it to the project holding the DbContext
Unable to create an object of type 'SchoolDbContext'The tools cannot construct it outside the hostAdd an IDesignTimeDbContextFactory
Your startup project doesn't reference Microsoft.EntityFrameworkCore.DesignTools/Design missing from the startup projectInstall Tools there
A network-related or instance-specific errorConnection string wrongCheck it in User Secrets
The term 'Add-Migration' is not recognizedTools package not installedInstall it, then reopen the console

The Default project dropdown causes most of these. It is at the top of the Package Manager Console, and it is not the same as the startup project.

Common mistakes

  • Learning EF Core without SQL, then being unable to debug generated queries
  • A singleton or static DbContext
  • Concurrent operations on one context instance
  • Assuming DbContext is thread-safe
  • EnableSensitiveDataLogging in production
  • Missing the Design package, then misreading the CLI error
  • Never looking at the generated SQL
  • Production credentials in appsettings.json
  • Treating EF Core as a replacement for understanding databases

Practice

  1. Create a console project, add the EF Core packages, and write SchoolDbContext with DbSets for the SMS entities.
  2. Register it with AddDbContext and confirm it resolves from DI.
  3. Enable LogTo and run one query. Read the SQL EF Core produced.
  4. Use ToQueryString() on a query with a Where and an OrderBy. Compare it with what you would have written by hand.
  5. Start two ToListAsync calls on the same context without awaiting. Record the exact exception.
  6. Fix it two ways: sequentially, and with AddDbContextFactory plus Task.WhenAll.
  7. Register the context as a singleton deliberately, load 10,000 students in a loop, and observe memory growth.
  8. Move the connection string to user secrets and confirm nothing sensitive remains in appsettings.json.
  9. Add EnableSensitiveDataLogging and compare the logged SQL with and without it.
  10. Write an IDesignTimeDbContextFactory and confirm Get-DbContext works in the Package Manager Console.

Exercise 3 is the habit that matters most. Do it for every query you write in this track.

You can now

  • Say what EF Core does beyond Dapper, and what it costs
  • Configure a DbContext with DbSet properties
  • Keep the connection string in User Secrets
  • Run Package Manager Console commands against the right project
  • Write an IDesignTimeDbContextFactory when the tools cannot construct your context

Review questions

  1. What does EF Core provide that Dapper does not?
  2. Why is DbContext registered as scoped rather than singleton?
  3. What causes "A second operation was started on this context instance"?
  4. Why is reading the generated SQL a basic rather than an advanced skill?

Next: Entities and configuration