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
DbContextandDbSet<T>- Registering with dependency injection
DbContextlifetime 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:
| Relational | Object |
|---|---|
| Tables and rows | Classes and instances |
| Foreign key columns | Object references |
| Join to fetch related data | Navigate a property |
| Set-based operations | Loops and collections |
| No inheritance | Inheritance |
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
| Dapper | EF Core | |
|---|---|---|
| Who writes the SQL | You | EF Core, usually |
| Mapping | Automatic | Automatic |
| Change tracking | No | Yes |
| Schema management | No | Migrations |
| Relationship navigation | Manual | Include, lazy loading |
| Learning curve | Small | Large |
| Performance ceiling | Raw ADO.NET | Slower, tunable |
| Debugging a slow query | Read your SQL | Read 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.
| Package | Purpose |
|---|---|
Microsoft.EntityFrameworkCore | Core runtime (pulled in as a dependency) |
Microsoft.EntityFrameworkCore.SqlServer | SQL Server provider |
Microsoft.EntityFrameworkCore.Design | Design-time services; pulled in by Tools |
Microsoft.EntityFrameworkCore.Tools | The 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 work —
SaveChangeswrites 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
| Message | Cause | Fix |
|---|---|---|
No DbContext was found in assembly | Package Manager Console Default project is wrong | Set it to the project holding the DbContext |
Unable to create an object of type 'SchoolDbContext' | The tools cannot construct it outside the host | Add an IDesignTimeDbContextFactory |
Your startup project doesn't reference Microsoft.EntityFrameworkCore.Design | Tools/Design missing from the startup project | Install Tools there |
A network-related or instance-specific error | Connection string wrong | Check it in User Secrets |
The term 'Add-Migration' is not recognized | Tools package not installed | Install 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
DbContextis thread-safe EnableSensitiveDataLoggingin production- Missing the
Designpackage, 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
- Create a console project, add the EF Core packages, and write
SchoolDbContextwithDbSets for the SMS entities. - Register it with
AddDbContextand confirm it resolves from DI. - Enable
LogToand run one query. Read the SQL EF Core produced. - Use
ToQueryString()on a query with aWhereand anOrderBy. Compare it with what you would have written by hand. - Start two
ToListAsynccalls on the same context without awaiting. Record the exact exception. - Fix it two ways: sequentially, and with
AddDbContextFactoryplusTask.WhenAll. - Register the context as a singleton deliberately, load 10,000 students in a loop, and observe memory growth.
- Move the connection string to user secrets and confirm nothing sensitive remains in
appsettings.json. - Add
EnableSensitiveDataLoggingand compare the logged SQL with and without it. - Write an
IDesignTimeDbContextFactoryand confirmGet-DbContextworks 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
DbContextwithDbSetproperties - Keep the connection string in User Secrets
- Run Package Manager Console commands against the right project
- Write an
IDesignTimeDbContextFactorywhen the tools cannot construct your context
Review questions
- What does EF Core provide that Dapper does not?
- Why is
DbContextregistered as scoped rather than singleton? - What causes "A second operation was started on this context instance"?
- Why is reading the generated SQL a basic rather than an advanced skill?