EF Core
Entity Framework Core is the ORM most C# teams ship with. Migrations, change tracking, LINQ-to-SQL, async I/O, and a clean unit-of-work via DbContext. Wire it up in DI, keep DbContext request-scoped, and never share an instance across threads.
DbContext, configuration, migrations, queries
EXAMPLE
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
public class ShopDb : DbContext
{
public DbSet<Customer> Customers => Set<Customer>();
public DbSet<Order> Orders => Set<Order>();
public ShopDb(DbContextOptions<ShopDb> options) : base(options) {}
protected override void OnModelCreating(ModelBuilder b)
{
b.Entity<Customer>(e =>
{
e.HasIndex(c => c.Email).IsUnique();
e.Property(c => c.Email).HasMaxLength(190).IsRequired();
e.Property(c => c.Name).HasMaxLength(120).IsRequired();
});
b.Entity<Order>(e =>
{
e.HasOne(o => o.Customer).WithMany(c => c.Orders)
.HasForeignKey(o => o.CustomerId).OnDelete(DeleteBehavior.Restrict);
e.Property(o => o.Status).HasConversion<string>().HasMaxLength(16);
e.HasIndex(o => new { o.CustomerId, o.Status, o.CreatedAt });
});
}
}
public class Customer
{
public long Id { get; set; }
public string Email { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public List<Order> Orders { get; set; } = new();
}
public class Order
{
public long Id { get; set; }
public long CustomerId { get; set; }
public Customer? Customer { get; set; }
public long TotalCents { get; set; }
public OrderStatus Status { get; set; } = OrderStatus.New;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? PaidAt { get; set; }
}
public enum OrderStatus { New, Paid, Shipped, Cancelled }
// ============================================================
// DI registration in Program.cs (minimal API)
// ============================================================
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddDbContext<ShopDb>(opt =>
opt.UseNpgsql(builder.Configuration.GetConnectionString("Default"))
.EnableSensitiveDataLogging(builder.Environment.IsDevelopment())
.EnableDetailedErrors(builder.Environment.IsDevelopment()));
var app = builder.Build();
// ============================================================
// Migrations (run on the CLI, NOT at startup in prod)
// ============================================================
// dotnet ef migrations add Initial
// dotnet ef database update
// dotnet ef migrations script # generate SQL for review
// dotnet ef migrations remove # drop the last unmapped migration
// In CI, apply with:
// dotnet ef database update --connection $CONN --no-build
// At app start, optionally call db.Database.Migrate() — only in dev or if
// you accept the deploy-coupled-to-app-boot tradeoff.
// ============================================================
// Querying
// ============================================================
public static class OrderQueries
{
// a) Read by id — no tracking for read-only paths
public static Task<Order?> ByIdAsync(ShopDb db, long id, CancellationToken ct)
=> db.Orders.AsNoTracking().FirstOrDefaultAsync(o => o.Id == id, ct);
// b) Paginated list with projection (only fetch fields you need)
public static Task<List<OrderListItem>> RecentAsync(ShopDb db, int limit, CancellationToken ct)
=> db.Orders.AsNoTracking()
.OrderByDescending(o => o.CreatedAt)
.Take(limit)
.Select(o => new OrderListItem(o.Id, o.CustomerId, o.TotalCents, o.Status.ToString()))
.ToListAsync(ct);
// c) Include for related data — avoid N+1
public static Task<List<Customer>> WithOrdersAsync(ShopDb db, CancellationToken ct)
=> db.Customers.AsNoTracking()
.Include(c => c.Orders)
.ToListAsync(ct);
// d) Filtering + grouping
public static Task<List<RevenueByCustomer>> RevenueAsync(ShopDb db, CancellationToken ct)
=> db.Orders.AsNoTracking()
.Where(o => o.Status == OrderStatus.Paid || o.Status == OrderStatus.Shipped)
.GroupBy(o => o.CustomerId)
.Select(g => new RevenueByCustomer(g.Key, g.Sum(o => o.TotalCents)))
.ToListAsync(ct);
}
public record OrderListItem(long Id, long CustomerId, long TotalCents, string Status);
public record RevenueByCustomer(long CustomerId, long TotalCents);
// ============================================================
// Writes — change tracking + SaveChanges
// ============================================================
public static class PlaceOrder
{
public static async Task<long> ExecuteAsync(ShopDb db, long customerId, long totalCents, CancellationToken ct)
{
var customer = await db.Customers.FindAsync(new object[] { customerId }, ct)
?? throw new InvalidOperationException("missing");
var order = new Order { CustomerId = customer.Id, TotalCents = totalCents };
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
return order.Id;
}
}
// ============================================================
// Raw SQL when needed (parameterised)
// ============================================================
// var orders = await db.Orders
// .FromSqlInterpolated($"SELECT * FROM orders WHERE created_at >= {since}")
// .AsNoTracking()
// .ToListAsync(ct);
// ============================================================
// Pitfalls
// ============================================================
// - Singleton DbContext: NEVER. It is request-scoped.
// - Sync calls (.ToList()) on async endpoints: blocks the thread pool.
// - Missing AsNoTracking() on read-only queries: needless change tracking.
// - SELECT * via .Include() pulling huge graphs: project to DTOs instead.
// - Migrations applied at startup in production: race with multiple instances.
// - Forgetting to pass CancellationToken: requests do not honour cancellation.
Why it matters
Default to AsNoTracking + Select(projection) for read paths and only use full entity loading when you mean to mutate. The change tracker is the difference between "fast read endpoint" and "endpoint that scans + dirties 5000 entities per request"; making no-tracking + projection your reflex keeps EF Core feeling like a fast library, not a slow one.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
public class AppDb : DbContext {
public DbSet<User> Users => Set<User>();
}
var adults = db.Users.Where(u => u.Age >= 18).ToList();
Try it Yourself »
Discussion
Loading…