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
- What an ORM is and what problem it solves compared to manual ADO.NET
- Installing Entity Framework Core and the SQLite provider
DbContextandDbSet<T>: mapping BiblioTech's domain- Configuring the model without touching the domain classes: Fluent API in
OnModelCreating - Migrations:
dotnet ef migrations addanddotnet ef database update - LINQ queries against a
DbSet<T> - Saving changes:
SaveChanges()andSaveChangesAsync()
- 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.
- 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-efMicrosoft.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.
DbContext and DbSet<T>: mapping BiblioTech's domain
DbContext and DbSet<T>: mapping BiblioTech's domainEF 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.
- Configuring the model without touching the domain classes: Fluent API in
OnModelCreating
OnModelCreatingEF 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.
- Migrations:
dotnet ef migrations add and dotnet ef database update
dotnet ef migrations add and dotnet ef database updateA 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:
| 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.
- LINQ queries against a
DbSet<T>
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.
- Saving changes:
SaveChanges() and SaveChangesAsync()
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 withcontext.Books.Add(...)doesn't persist it by itself; without callingSaveChanges(), the change only exists in memory and is lost when theDbContextcloses. - Not generating or applying a migration after changing the model: if a new property is
added to
Bookwithout runningdotnet ef migrations addanddotnet 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
HasKeyfor 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 ofLoan), you have to define a shadow property explicitly withProperty<T>()+HasKey()inOnModelCreating. - Creating a new
DbContextfor every small operation unnecessarily, or reusing a single one for too long: the usual practice in EF Core is a short lifetime perDbContext(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 aDbSet<T>is more readable and less error-prone than hand-written SQL.
Exercises
-
Define
LibraryDbContextwithDbSet<Book> BooksandDbSet<Member> Members, configuringHasKeyfor both inOnModelCreatingas shown in this lesson. Generate the initial migration withdotnet ef migrations addand apply it withdotnet ef database update. -
Using the
LibraryDbContextfrom the previous exercise, add two newBooks withcontext.Books.Add(...)and save the changes withSaveChangesAsync(). Then, in a newLibraryDbContextinstance, query with LINQ (Where+OrderBy) the available books ordered by title. -
Retrieve an existing
BookwithFirstOrDefaultby itsIsbn, callLend()on it, and save the changes withSaveChangesAsync()without calling any explicitUpdatemethod. Query it again in a new instance of the context and check thatAvailableis nowfalse.
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.
C# Programming Course
Module 1: Introduction to C#
- Introduction to C#
- Setting Up the Development Environment
- Hello World Program
- Basic Syntax and Structure
- Variables and Data Types
- Arrays and Strings
Module 2: Control Structures
Module 3: Object-Oriented Programming
- Classes and Objects
- Methods
- Constructors and Destructors
- Inheritance
- Polymorphism
- Encapsulation
- Abstraction
- Structs and Records: Value Types and Reference Types
Module 4: Advanced C# Concepts
- Interfaces
- Delegates and Events
- Pattern Matching and Modern C# Features
- Generics
- Collections
- LINQ (Language Integrated Query)
- Asynchronous Programming
Module 5: Working with Data
- File I/O
- Serialization
- Database Connectivity
- Entity Framework
- Working with JSON and Consuming REST APIs
Module 6: Advanced Topics
- Reflection
- Attributes
- Dynamic Programming
- Memory Management and Garbage Collection
- Multithreading and Parallel Programming
Module 7: Building Applications
Module 8: Best Practices and Design Patterns
- Coding Standards and Best Practices
- Design Patterns
- Dependency Injection and Inversion of Control
- Unit Testing
- Code Review and Refactoring
