The previous lesson made classic ADO.NET's main annoyance clear: every query demands writing SQL by hand, managing parameters one by one, and manually mapping each column to each object property. An ORM (Object-Relational Mapper) automates exactly that repetitive work: it translates operations on C# objects and collections into the equivalent SQL, and rebuilds objects from the results with no manual mapping code. This lesson introduces Entity Framework Core (EF Core), .NET's official ORM, and applies it to BiblioTech's domain model: Book, Member, and Loan now live in a real database, queryable with the same LINQ syntax you already know from Module 4, and saved with the same async/await from the Asynchronous Programming lesson.

Contents

  1. What an ORM is and what problem it solves compared to manual ADO.NET
  2. Installing Entity Framework Core and the SQLite provider
  3. DbContext and DbSet<T>: mapping BiblioTech's domain
  4. Configuring the model without touching the domain classes: Fluent API in OnModelCreating
  5. Migrations: dotnet ef migrations add and dotnet ef database update
  6. LINQ queries against a DbSet<T>
  7. Saving changes: SaveChanges() and SaveChangesAsync()

  1. What an ORM is and what problem it solves compared to manual ADO.NET

An ORM solves the so-called "impedance mismatch" between two worlds organized very differently: C# objects (with properties, inheritance, nested collections) and relational database tables (rows, columns, foreign keys). In the previous lesson, that mismatch was resolved by hand, line by line:

Manual ADO.NET (previous lesson) Entity Framework Core (this lesson)
Writing each query's SQL By hand, as text Generated automatically from LINQ
Mapping rows to objects By hand, column by column Automatic, based on the configured model
Tracking what changed to save it The programmer decides which UPDATE/INSERT to run EF Core detects the changes and generates the necessary SQL
Control over the exact SQL Total High level, with the option to drop to manual SQL if needed

An ORM doesn't fully replace ADO.NET: underneath, EF Core still uses ADO.NET to communicate with the database; what it adds is a layer of abstraction that saves you writing that repetitive SQL by hand day to day.

  1. Installing Entity Framework Core and the SQLite provider

EF Core is installed as a set of NuGet packages, plus a command-line tool for managing migrations (section 5):

dotnet add package Microsoft.EntityFrameworkCore.Sqlite
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet tool install --global dotnet-ef

Microsoft.EntityFrameworkCore.Sqlite includes the EF Core engine plus the SQLite-specific provider (each database engine has its own provider package, following the same "provider" idea already seen with ADO.NET). Microsoft.EntityFrameworkCore.Design and the global dotnet-ef tool are needed to generate and apply migrations from the terminal.

  1. DbContext and DbSet<T>: mapping BiblioTech's domain

EF Core's entry point is a class that inherits from DbContext, with one DbSet<T> property per type you want to persist. Each DbSet<T> conceptually represents "the table for T in the database":

using Microsoft.EntityFrameworkCore;

class LibraryDbContext : DbContext
{
    public DbSet<Book> Books { get; set; } = null!;
    public DbSet<Member> Members { get; set; } = null!;
    public DbSet<Loan> Loans { get; set; } = null!;

    protected override void OnConfiguring(DbContextOptionsBuilder options)
    {
        options.UseSqlite("Data Source=bibliotech_ef.db");
    }
}

OnConfiguring tells EF Core which database to connect to (here, SQLite, with the same connection string as the previous lesson); in larger applications this configuration is usually injected from the outside via DbContextOptions, but for a self-contained example OnConfiguring is enough. The = null! operator (the ! is C#'s null-forgiving operator, recalling the Pattern Matching and Modern Language Features lesson from Module 4) tells the compiler "trust that this property won't be null at runtime," since EF Core assigns it automatically when the DbContext is constructed, even though the compiler can't see that on its own.

Book and Member are already perfectly valid for EF Core exactly as they've been defined since earlier modules: there's no need to modify them. Loan, with its read-only properties (Book, Member, LoanDate) and its Loan(Book book, Member member) constructor, is a bit more particular; the next section explains how EF Core maps it without touching that class.

  1. Configuring the model without touching the domain classes: Fluent API in OnModelCreating

EF Core offers two ways to specify mapping details it can't infer by convention (like each table's primary key): Data Annotations (attributes like [Key] placed directly on the model's properties) or Fluent API (centralized configuration code, inside the DbContext itself, without touching the domain classes). This lesson uses Fluent API precisely so it doesn't have to add persistence attributes to Book, Member, and Loan — classes that, until now, knew nothing about databases or EF Core, and still don't:

class LibraryDbContext : DbContext
{
    public DbSet<Book> Books { get; set; } = null!;
    public DbSet<Member> Members { get; set; } = null!;
    public DbSet<Loan> Loans { get; set; } = null!;

    protected override void OnConfiguring(DbContextOptionsBuilder options)
    {
        options.UseSqlite("Data Source=bibliotech_ef.db");
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Book>().HasKey(book => book.Isbn);
        modelBuilder.Entity<Member>().HasKey(member => member.Id);

        // Loan has no property designed as its own identifier;
        // a "shadow" key is added, which exists in the database
        // but not as a visible property on the C# class.
        modelBuilder.Entity<Loan>().Property<int>("LoanId");
        modelBuilder.Entity<Loan>().HasKey("LoanId");
    }
}

HasKey(book => book.Isbn) indicates that Isbn is the Books table's primary key (just like in the previous lesson's SQLite table); HasKey(member => member.Id) does the same with Member.Id, which was also already read-only by design (Encapsulation lesson) — a natural candidate for a primary key. For Loan, which has no property designed as a unique identifier, a shadow property is defined ("LoanId"): it exists as a column in the database and as EF Core's internal primary key, but it doesn't appear as a property on the C# Loan class. This is one of a mature ORM's practical advantages, like EF Core: the domain model can stay clean, focused on business rules, without mixing in persistence details.

EF Core also needs to know how Loan relates to Book and Member; by convention, on detecting the Book and Member properties inside Loan, it automatically generates foreign key columns (two more shadow properties, typically BookIsbn and MemberId) with no extra explicit configuration needed for this example.

  1. Migrations: dotnet ef migrations add and dotnet ef database update

A migration is an automatically generated C# code file describing how to transform the database schema to match the current model (which tables to create, which columns to add...). They're generated and applied with the dotnet ef tool from the terminal, in the project folder:

dotnet ef migrations add InitialBiblioTech
dotnet ef database update
Command What it does
dotnet ef migrations add <Name> Compares the current model (DbSet<T> + OnModelCreating configuration) against the migration history, and generates a new migration with the detected changes
dotnet ef database update Applies every pending migration against the real database, creating or modifying tables as needed

dotnet ef migrations add InitialBiblioTech creates a Migrations/ folder with two files per migration: one with the code that applies the change (Up()) and another that undoes it (Down()), plus a "snapshot" file of the complete model as it stood after that migration. Every time the model changes (a new property on Book, for example), a new migration is generated with migrations add and applied with database update; EF Core automatically works out only the difference relative to the previous migration, with no need to write the ALTER TABLE by hand.

  1. LINQ queries against a DbSet<T>

This is where EF Core connects directly with something you already master: DbSet<T> implements IQueryable<T> (an extension of IEnumerable<T>, the interface that closed the Collections lesson), so you query it with exactly the same LINQ syntax from the LINQ lesson — except that, underneath, EF Core translates those operations into SQL and runs it against the database, instead of walking a collection already loaded in memory:

using LibraryDbContext context = new LibraryDbContext();

List<Book> availableBooks = context.Books
    .Where(book => book.Available)
    .OrderBy(book => book.Title)
    .ToList();

foreach (Book book in availableBooks)
{
    Console.WriteLine(book.Title);
}

Book? hopscotch = context.Books.FirstOrDefault(book => book.Isbn == "978-84-376-0495-4");

Where, OrderBy, FirstOrDefault... are the same operators from the LINQ lesson, written exactly the same way; the difference is that here, context.Books.Where(...) gets translated internally into a SELECT ... WHERE ... statement in SQLite, instead of filtering a list already sitting in memory. LibraryDbContext, just like SqliteConnection in the previous lesson, implements IDisposable: it's declared with using for the same reason.

  1. Saving changes: SaveChanges() and SaveChangesAsync()

Adding, modifying, or removing entities through a DbContext doesn't immediately affect the database: EF Core tracks changes in memory, and only translates them into SQL (INSERT, UPDATE, DELETE) when SaveChanges() is explicitly called (or its asynchronous equivalent SaveChangesAsync(), recalling async/await from the Asynchronous Programming lesson):

using LibraryDbContext context = new LibraryDbContext();

Book newBook = new Book("The Aleph", "Jorge Luis Borges", "978-84-376-0497-8");
context.Books.Add(newBook);

await context.SaveChangesAsync(); // here, and only here, is the real INSERT run against SQLite

Console.WriteLine("Book saved successfully.");
using LibraryDbContext context = new LibraryDbContext();

Book? book = context.Books.FirstOrDefault(b => b.Isbn == "978-84-376-0497-8");
if (book is not null)
{
    book.Lend(); // changes Available to false; EF Core detects this change automatically
    await context.SaveChangesAsync(); // generates and runs the necessary UPDATE
}

In the second example there's no need for any explicit context.Books.Update(...): since book was obtained through context itself, EF Core is already tracking it (change tracking) and detects, when SaveChangesAsync() is called, that its Available property changed, generating the corresponding UPDATE. This is one of the most visible differences from manual ADO.NET, where every UPDATE had to be written and run explicitly.

classDiagram
    class LibraryDbContext {
        +DbSet~Book~ Books
        +DbSet~Member~ Members
        +DbSet~Loan~ Loans
        #OnConfiguring(DbContextOptionsBuilder)
        #OnModelCreating(ModelBuilder)
    }
    LibraryDbContext --|> DbContext
    LibraryDbContext --> "*" Book
    LibraryDbContext --> "*" Member
    LibraryDbContext --> "*" Loan

Common Mistakes and Tips

  • Forgetting SaveChanges()/SaveChangesAsync(): adding an entity with context.Books.Add(...) doesn't persist it by itself; without calling SaveChanges(), the change only exists in memory and is lost when the DbContext closes.
  • Not generating or applying a migration after changing the model: if a new property is added to Book without running dotnet ef migrations add and dotnet ef database update, the real database ends up out of sync with the model, and queries will fail at runtime when the expected column isn't found.
  • Forgetting HasKey for a type with no natural identifier property: EF Core requires every entity to have a primary key; if no property naturally serves as one (the case of Loan), you have to define a shadow property explicitly with Property<T>() + HasKey() in OnModelCreating.
  • Creating a new DbContext for every small operation unnecessarily, or reusing a single one for too long: the usual practice in EF Core is a short lifetime per DbContext (typically one per operation or per HTTP request in a web API, a topic for Module 7), neither one shared across the whole application nor a different one for every line of code.
  • Tip: always use LINQ queries to read data, and ration out manual SQL (also possible from EF Core, with FromSqlRaw, outside this lesson's scope) for the specific cases where LINQ doesn't express what you need well; for the vast majority of everyday operations, LINQ against a DbSet<T> is more readable and less error-prone than hand-written SQL.

Exercises

  1. Define LibraryDbContext with DbSet<Book> Books and DbSet<Member> Members, configuring HasKey for both in OnModelCreating as shown in this lesson. Generate the initial migration with dotnet ef migrations add and apply it with dotnet ef database update.

  2. Using the LibraryDbContext from the previous exercise, add two new Books with context.Books.Add(...) and save the changes with SaveChangesAsync(). Then, in a new LibraryDbContext instance, query with LINQ (Where + OrderBy) the available books ordered by title.

  3. Retrieve an existing Book with FirstOrDefault by its Isbn, call Lend() on it, and save the changes with SaveChangesAsync() without calling any explicit Update method. Query it again in a new instance of the context and check that Available is now false.

Solutions

class LibraryDbContext : DbContext
{
    public DbSet<Book> Books { get; set; } = null!;
    public DbSet<Member> Members { get; set; } = null!;

    protected override void OnConfiguring(DbContextOptionsBuilder options)
    {
        options.UseSqlite("Data Source=bibliotech_ef.db");
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Book>().HasKey(book => book.Isbn);
        modelBuilder.Entity<Member>().HasKey(member => member.Id);
    }
}
dotnet ef migrations add InitialBiblioTech
dotnet ef database update
using (LibraryDbContext context = new LibraryDbContext())
{
    context.Books.Add(new Book("Hopscotch", "Julio Cortazar", "978-84-376-0495-4"));
    context.Books.Add(new Book("Ficciones", "Jorge Luis Borges", "978-84-376-0496-1"));
    await context.SaveChangesAsync();
}

using (LibraryDbContext otherContext = new LibraryDbContext())
{
    List<Book> available = otherContext.Books
        .Where(b => b.Available)
        .OrderBy(b => b.Title)
        .ToList();

    foreach (Book book in available)
    {
        Console.WriteLine(book.Title);
    }
}
using (LibraryDbContext context = new LibraryDbContext())
{
    Book? book = context.Books.FirstOrDefault(b => b.Isbn == "978-84-376-0495-4");
    if (book is not null)
    {
        book.Lend();
        await context.SaveChangesAsync();
    }
}

using (LibraryDbContext otherContext = new LibraryDbContext())
{
    Book? updatedBook = otherContext.Books.FirstOrDefault(b => b.Isbn == "978-84-376-0495-4");
    Console.WriteLine(updatedBook?.Available); // False
}

Conclusion

In this lesson you've met Entity Framework Core: what problem an ORM solves compared to manual ADO.NET, how to define a DbContext with a DbSet<T> for each type in BiblioTech's domain, how to configure the model with Fluent API without cluttering the domain classes with persistence attributes, how to generate and apply migrations, and how to query and save data by reusing LINQ and async/await, two tools you already mastered from Module 4. Book, Member, and Loan now live in a real relational database, managed by an ORM instead of hand-written SQL.

Everything seen so far in Module 5 assumes BiblioTech is the only application reading and writing this data. The module's last lesson, Working with JSON and Consuming REST APIs, takes it a step further: it goes deeper into System.Text.Json for more complex scenarios, and uses HttpClient so BiblioTech can query additional information from an external service — the usual scenario for any modern application that doesn't live in isolation, but communicates with other systems through a REST API.

© Copyright 2026. All rights reserved