Chapter 01 · EF Core Foundations, .NET Integration, Providers, and the First Lab

Create the First DbContext, Entity, Database Connection, and End-to-End Query

Build the first ServiceHub DbContext and entity, create a migration-driven SQLite schema, persist a tracked work order, execute a LINQ query, and make generated SQL, tracker state, and connection behavior observable.

Intermediate105–125 minutesDbContext + migration + end-to-end query labEF Core 10.0.11 · .NET 10SQLite 3.46.1+ baselineLast reviewed: August 2026

Learning outcomes

The ServiceHub team now has a verified .NET/EF toolchain, but no useful persistence path. This lesson connects all layers end to end: a CLR entity becomes EF model metadata, a DbContext uses a provider to reach SQLite, migrations create the relational schema, SaveChangesAsync persists a tracked entity, and a LINQ query becomes parameterized SQL at enumeration time.

01

Build a minimal DbContext and entity while identifying the responsibilities of DbSet, model metadata, provider services, and the database connection.

02

Create the first schema with an EF migration rather than treating EnsureCreated as the normal course migration lifecycle.

03

Insert and query data asynchronously, then correlate ChangeTracker state with the SQL emitted by the SQLite provider.

04

Explain deferred execution, materialization, and why constructing an IQueryable is different from executing a database command.

05

Diagnose a disposed-context/deferred-query failure and repair the lifetime boundary deliberately.

Prerequisite connection

Lessons 1–3 established the ORM boundary and a repository-local EF Core 10 toolchain. The course now keeps one coherent domain: ServiceHub manages field-service work orders. SQLite remains the mandatory Chapter 01 provider; later chapters compare provider-specific SQL, types, DDL, concurrency, and performance instead of assuming identical behavior.

1. The smallest useful model has four distinct layers

A CLR entity is an ordinary C# object whose state the application understands. An EF model describes how entity types, properties, keys, relationships, and relational facets map to a store. A DbContext is a short-lived unit-of-work object that owns model access, query services, and a change tracker. A provider supplies database-specific translation, type mapping, commands, migrations, and ADO.NET integration.

Those layers are related but not interchangeable. Adding a DbSet<WorkOrder> does not create a table. Creating a migration does not update a database until that migration is applied. Constructing a LINQ query does not normally send SQL until the query is executed. And a successful SQL command does not mean EF can ignore database constraints or transaction semantics.

Layer ServiceHub example Observable evidence
Entity WorkOrder C# object Property values before/after materialization
EF model WorkOrder key/table/property mapping context.Model metadata
Provider Microsoft.EntityFrameworkCore.Sqlite context.Database.ProviderName + generated SQLite SQL
Database servicehub-lab.db migration history, table rows, constraints, sqlite_version()

2. Define the first entity and context

Keep the first model intentionally small. Later chapters add relationships, value objects, concurrency, query filters, and provider-specific mappings. Starting small makes it possible to see exactly which behavior comes from conventions and which behavior comes from explicit configuration.

csharp · Domain/WorkOrder.cs
namespace ServiceHub.EfLab.Domain;public enum WorkOrderPriority{    Low = 1,    Normal = 2,    High = 3}public sealed class WorkOrder{    public int Id { get; set; }    public string CustomerName { get; set; } = string.Empty;    public string Summary { get; set; } = string.Empty;    public WorkOrderPriority Priority { get; set; } = WorkOrderPriority.Normal;    public DateTimeOffset OpenedUtc { get; set; }}
csharp · Data/ServiceHubContext.cs
using Microsoft.EntityFrameworkCore;using ServiceHub.EfLab.Domain;namespace ServiceHub.EfLab.Data;public sealed class ServiceHubContext(DbContextOptions<ServiceHubContext> options)    : DbContext(options){    public DbSet<WorkOrder> WorkOrders => Set<WorkOrder>();    protected override void OnModelCreating(ModelBuilder modelBuilder)    {        var workOrder = modelBuilder.Entity<WorkOrder>();        workOrder.ToTable("WorkOrders");        workOrder.HasKey(x => x.Id);        workOrder.Property(x => x.CustomerName)            .HasMaxLength(120)            .IsRequired();        workOrder.Property(x => x.Summary)            .HasMaxLength(400)            .IsRequired();    }}

By convention, Id is the primary key. Explicit configuration gives the important strings database facets instead of relying on unlimited text by accident. The enum is currently represented through its underlying integer mapping; later model-building lessons make conversions and provider types explicit.

3. Give both runtime code and design-time tooling a construction path

The application creates DbContextOptions<ServiceHubContext> with UseSqlite. Migrations tooling also needs to construct the context. A small IDesignTimeDbContextFactory<TContext> keeps this first console lab deterministic. Chapter 02 later compares dependency injection, factories, OnConfiguring, background-worker lifetimes, and pooling in depth.

csharp · Data/ServiceHubContextFactory.cs
using Microsoft.EntityFrameworkCore;using Microsoft.EntityFrameworkCore.Design;namespace ServiceHub.EfLab.Data;public sealed class ServiceHubContextFactory    : IDesignTimeDbContextFactory<ServiceHubContext>{    public ServiceHubContext CreateDbContext(string[] args)    {        var options = new DbContextOptionsBuilder<ServiceHubContext>()            .UseSqlite("Data Source=servicehub-lab.db")            .Options;        return new ServiceHubContext(options);    }}

This factory contains a non-secret local SQLite path. Do not copy this pattern with a production password embedded in source. Lesson 5 moves connection configuration outside code and establishes the course secret-handling rule.

4. Create and apply the first migration

A migration is source-controlled schema intent generated from the EF model and its snapshot. It must be reviewed before application. The first migration establishes the baseline rather than asking EF to create an untracked schema with EnsureCreated.

text · create and inspect the first migration
dotnet tool restoredotnet ef migrations add InitialCreate --project src/ServiceHub.EfLab --output-dir Data/Migrationsdotnet ef migrations list --project src/ServiceHub.EfLabdotnet ef migrations script 0 InitialCreate --project src/ServiceHub.EfLabdotnet ef database update --project src/ServiceHub.EfLab

Paths above assume commands run at the course solution root. If you are already inside src/ServiceHub.EfLab, omit the --project argument. The generated C# migration and model snapshot are review artifacts; do not edit the database manually and then assume the snapshot learned about the change.

sql · representative SQLite schema emitted by the provider
CREATE TABLE "WorkOrders" (    "Id" INTEGER NOT NULL CONSTRAINT "PK_WorkOrders" PRIMARY KEY AUTOINCREMENT,    "CustomerName" TEXT NOT NULL,    "Summary" TEXT NOT NULL,    "Priority" INTEGER NOT NULL,    "OpenedUtc" TEXT NOT NULL);

The exact migration SQL is provider/version sensitive. The important mechanism is that EF migration operations are translated by the SQLite provider into SQLite DDL. A SQL Server provider would not emit this same SQL or type surface.

5. Insert a tracked entity and inspect its state transition

When Add is called, the work order becomes Added in the context's state manager. SaveChangesAsync detects pending changes, generates a provider command, executes it in the database, obtains any store-generated key, and normally leaves the entity Unchanged after a successful save.

csharp · insert and observe ChangeTracker state
using Microsoft.EntityFrameworkCore;using Microsoft.Extensions.Logging;using ServiceHub.EfLab.Data;using ServiceHub.EfLab.Domain;var options = new DbContextOptionsBuilder<ServiceHubContext>()    .UseSqlite("Data Source=servicehub-lab.db")    .LogTo(Console.WriteLine, LogLevel.Information)    .Options;await using var db = new ServiceHubContext(options);var workOrder = new WorkOrder{    CustomerName = "Northwind Workshop",    Summary = "Replace vibration sensor on compressor C-14",    Priority = WorkOrderPriority.High,    OpenedUtc = DateTimeOffset.Parse("2026-08-27T00:00:00Z")};db.WorkOrders.Add(workOrder);Console.WriteLine(db.ChangeTracker.DebugView.ShortView);await db.SaveChangesAsync();Console.WriteLine($"Stored WorkOrder Id = {workOrder.Id}");Console.WriteLine(db.ChangeTracker.DebugView.ShortView);

Before saving, expect the debug view to identify the entity as Added. After the successful save, expect Unchanged and a generated positive key. Provider command logs should show a parameterized INSERT. Log formatting and parameter names are diagnostics, not a stable application API.

6. A LINQ query is a recipe until execution

DbSet<T> implements the queryable surface EF uses to build expression trees. The following Where, OrderByDescending, and Select calls create a query description. ToQueryString asks EF for diagnostic command text. ToListAsync is the terminal operation that actually executes the database query and materializes results.

csharp · compose, inspect, then execute
var highPriority = db.WorkOrders    .Where(x => x.Priority == WorkOrderPriority.High)    .OrderByDescending(x => x.OpenedUtc)    .Select(x => new    {        x.Id,        x.CustomerName,        x.Summary    });Console.WriteLine(highPriority.ToQueryString());var rows = await highPriority.ToListAsync();foreach (var row in rows){    Console.WriteLine($"{row.Id}: {row.CustomerName} - {row.Summary}");}
sql · representative generated query shape
SELECT "w"."Id", "w"."CustomerName", "w"."Summary"FROM "WorkOrders" AS "w"WHERE "w"."Priority" = 3ORDER BY "w"."OpenedUtc" DESC

Whether a value appears as a literal or parameter in ToQueryString depends on expression shape and provider behavior. Runtime command logs are the evidence for the executed command. Neither ToQueryString nor the SQL text is a database execution plan.

7. Materialization and identity tracking happen after rows return

For entity queries, EF reads provider result rows and materializes CLR objects. A tracking query then associates entity instances with the context's state manager. A projection to an anonymous type, as above, avoids constructing full tracked WorkOrder entities when the caller only needs three columns. This is an early example of query shape affecting both database work and EF materialization work.

Do not conclude that “projection is always faster” without workload evidence. It is appropriate when the application genuinely needs a smaller shape; later performance chapters measure translation, network payload, database plans, tracking, and allocation costs separately.

8. Database connections are not the same lifetime as DbContext

A context uses the provider's ADO.NET connection abstraction, but EF normally opens a closed connection immediately before an operation and closes it afterward. The context can remain alive across several commands without holding the physical connection open continuously. ADO.NET connection pooling, where supported, is another layer again and is not DbContext pooling.

csharp · observe connection state around an operation
Console.WriteLine(db.Database.GetDbConnection().State);var count = await db.WorkOrders.CountAsync();Console.WriteLine($"Rows = {count}");Console.WriteLine(db.Database.GetDbConnection().State);

With default EF connection management, the state is commonly Closed before and after the command. Provider logs show opening/execution/closing activity. Do not hard-code a correctness rule around those incidental log lines; explicitly opened connections and transactions change the lifetime.

9. Deliberately wrong approach: defer the query past the context lifetime

The following code returns an IQueryable from inside a disposed context. No database work has occurred when the method returns. Enumeration later needs the context's query services/connection and fails.

csharp · wrong: IQueryable escapes its DbContext lifetime
static IQueryable<WorkOrder> BuildQuery(){    using var db = new ServiceHubContext(CreateOptions());    return db.WorkOrders.Where(x => x.Priority == WorkOrderPriority.High);}var query = BuildQuery();var rows = await query.ToListAsync(); // context has already been disposed

Expect a context-disposed failure rather than useful data. The repair is not to keep one context globally forever. Keep query construction and execution inside a deliberate unit-of-work lifetime, or materialize the required data before leaving that lifetime.

csharp · repair: materialize inside the unit of work
static async Task<List<WorkOrder>> LoadHighPriorityAsync(){    await using var db = new ServiceHubContext(CreateOptions());    return await db.WorkOrders        .Where(x => x.Priority == WorkOrderPriority.High)        .OrderByDescending(x => x.OpenedUtc)        .ToListAsync();}

Chapter 02 will replace manual construction with dependency-injection and factory patterns for different application lifetimes.

10. Hands-on lab: migrate, insert, query, and verify the database

Use the repository-local toolchain from Lesson 3. If a previous experiment created servicehub-lab.db with EnsureCreated, remove that disposable file before beginning the migration lifecycle.

text · migration-driven lab sequence
dotnet tool restoredotnet restoredotnet builddotnet ef migrations add InitialCreate --project src/ServiceHub.EfLab --output-dir Data/Migrationsdotnet ef database update --project src/ServiceHub.EfLabdotnet run --project src/ServiceHub.EfLab

Verification checklist

  • The migration directory contains InitialCreate plus a model snapshot.
  • The SQLite file exists only in the disposable lab location you intended.
  • __EFMigrationsHistory records the applied migration.
  • A newly added WorkOrder transitions from Added to Unchanged after a successful save.
  • Generated/logged SQL identifies the SQLite provider's quoting/type conventions.
  • The query executes only when a terminal async operation such as ToListAsync is reached.
  • No production-like credentials, database paths, or unrelated data were touched.

Check your understanding

  1. Why does adding a DbSet property not create a database table?
  2. What does ToQueryString prove, and what does it not prove?
  3. Why is a migration preferable to EnsureCreated for the course’s normal schema evolution?
  4. What state should a successfully inserted tracked entity normally have after SaveChangesAsync?
  5. Why can returning IQueryable from a method that disposes its context fail later?
Review the answers

DbSet exposes an entity query/set surface in the EF model; schema creation requires a separate database-management mechanism such as migrations.

It exposes provider-generated diagnostic SQL for the query shape. It does not prove that the query was executed, how long it took, or which database execution plan ran.

Migrations record model evolution, generate reviewable operations/scripts, and maintain migration history/snapshots; EnsureCreated bypasses that normal lifecycle and is intended for transient prototype/test scenarios.

Normally Unchanged, because EF accepted the successful database write and updated any store-generated values such as the key.

IQueryable is deferred. Enumeration later still needs the provider/context services that were disposed when the method returned.

11. Production judgment and next bridge

A production DbContext should have a deliberate short lifetime, externalized configuration, observable provider behavior, and migrations owned by a reviewed deployment process. Do not hold a context forever, execute concurrent operations on it, infer database performance from ToQueryString, or give a runtime application schema-owner permissions merely because migrations need DDL.

Lesson 5 turns this one-file demonstration into a repeatable course environment: stable project layout, deterministic sample data, external configuration, safe secrets, structured EF logging, reset/seed workflows, and a recorded version manifest. That shared lab becomes the foundation for later chapters instead of silently changing assumptions from lesson to lesson.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.

\n