$npx -y skills add Aaronontheweb/dotnet-skills --skill efcore-patternsEntity Framework Core best practices including NoTracking by default, query splitting for navigation collections, migration management, dedicated migration services, and common pitfalls to avoid.
| 1 | # Entity Framework Core Patterns |
| 2 | |
| 3 | ## When to Use This Skill |
| 4 | |
| 5 | Use this skill when: |
| 6 | - Setting up EF Core in a new project |
| 7 | - Optimizing query performance |
| 8 | - Managing database migrations |
| 9 | - Integrating EF Core with .NET Aspire |
| 10 | - Debugging change tracking issues |
| 11 | - Loading multiple navigation collections efficiently (query splitting) |
| 12 | |
| 13 | ## Core Principles |
| 14 | |
| 15 | 1. **NoTracking by Default** - Most queries are read-only; opt-in to tracking |
| 16 | 2. **Never Edit Migrations Manually** - Always use CLI commands |
| 17 | 3. **Dedicated Migration Service** - Separate migration execution from application startup |
| 18 | 4. **ExecutionStrategy for Retries** - Handle transient database failures |
| 19 | 5. **Explicit Updates** - When NoTracking, explicitly mark entities for update |
| 20 | |
| 21 | --- |
| 22 | |
| 23 | ## Pattern 1: NoTracking by Default |
| 24 | |
| 25 | Configure your DbContext to disable change tracking by default. This improves performance for read-heavy workloads. |
| 26 | |
| 27 | ```csharp |
| 28 | public class ApplicationDbContext : DbContext |
| 29 | { |
| 30 | public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) |
| 31 | : base(options) |
| 32 | { |
| 33 | // Disable change tracking by default for better performance on read-only queries |
| 34 | // Use .AsTracking() explicitly for queries that need to track changes |
| 35 | ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; |
| 36 | } |
| 37 | |
| 38 | public DbSet<Order> Orders => Set<Order>(); |
| 39 | public DbSet<Customer> Customers => Set<Customer>(); |
| 40 | } |
| 41 | ``` |
| 42 | |
| 43 | ### When NoTracking is Active |
| 44 | |
| 45 | **Read-only queries work normally:** |
| 46 | ```csharp |
| 47 | // ✅ Fast read - no tracking overhead |
| 48 | var orders = await dbContext.Orders |
| 49 | .Where(o => o.Status == OrderStatus.Pending) |
| 50 | .ToListAsync(); |
| 51 | ``` |
| 52 | |
| 53 | **Writes require explicit handling:** |
| 54 | ```csharp |
| 55 | // ❌ WRONG - Entity not tracked, SaveChanges does nothing |
| 56 | var order = await dbContext.Orders.FirstOrDefaultAsync(o => o.Id == orderId); |
| 57 | order.Status = OrderStatus.Shipped; |
| 58 | await dbContext.SaveChangesAsync(); // Nothing happens! |
| 59 | |
| 60 | // ✅ CORRECT - Explicitly mark entity for update |
| 61 | var order = await dbContext.Orders.FirstOrDefaultAsync(o => o.Id == orderId); |
| 62 | order.Status = OrderStatus.Shipped; |
| 63 | dbContext.Orders.Update(order); // Marks entire entity as modified |
| 64 | await dbContext.SaveChangesAsync(); |
| 65 | |
| 66 | // ✅ ALSO CORRECT - Use AsTracking() for the query |
| 67 | var order = await dbContext.Orders |
| 68 | .AsTracking() |
| 69 | .FirstOrDefaultAsync(o => o.Id == orderId); |
| 70 | order.Status = OrderStatus.Shipped; |
| 71 | await dbContext.SaveChangesAsync(); // Works! |
| 72 | ``` |
| 73 | |
| 74 | ### When to Use Tracking |
| 75 | |
| 76 | | Scenario | Use Tracking? | Why | |
| 77 | |----------|---------------|-----| |
| 78 | | Display data in UI | No | Read-only, no updates | |
| 79 | | API GET endpoints | No | Returning data, no mutations | |
| 80 | | Update single entity | Yes or explicit Update() | Need to save changes | |
| 81 | | Complex update with navigation | Yes | Tracking handles relationships | |
| 82 | | Batch operations | No + ExecuteUpdate | More efficient | |
| 83 | |
| 84 | ### Explicit Add/Update Pattern |
| 85 | |
| 86 | ```csharp |
| 87 | public class OrderService |
| 88 | { |
| 89 | private readonly ApplicationDbContext _db; |
| 90 | |
| 91 | // CREATE - Always use Add (works regardless of tracking) |
| 92 | public async Task<Order> CreateOrderAsync(Order order) |
| 93 | { |
| 94 | _db.Orders.Add(order); |
| 95 | await _db.SaveChangesAsync(); |
| 96 | return order; |
| 97 | } |
| 98 | |
| 99 | // UPDATE - Explicitly mark as modified |
| 100 | public async Task UpdateOrderStatusAsync(Guid orderId, OrderStatus newStatus) |
| 101 | { |
| 102 | var order = await _db.Orders.FirstOrDefaultAsync(o => o.Id == orderId) |
| 103 | ?? throw new NotFoundException($"Order {orderId} not found"); |
| 104 | |
| 105 | order.Status = newStatus; |
| 106 | order.UpdatedAt = DateTimeOffset.UtcNow; |
| 107 | |
| 108 | // Explicitly mark as modified since DbContext uses NoTracking by default |
| 109 | _db.Orders.Update(order); |
| 110 | await _db.SaveChangesAsync(); |
| 111 | } |
| 112 | |
| 113 | // DELETE - Attach and remove |
| 114 | public async Task DeleteOrderAsync(Guid orderId) |
| 115 | { |
| 116 | var order = new Order { Id = orderId }; |
| 117 | _db.Orders.Remove(order); |
| 118 | await _db.SaveChangesAsync(); |
| 119 | } |
| 120 | } |
| 121 | ``` |
| 122 | |
| 123 | --- |
| 124 | |
| 125 | ## Pattern 2: Never Edit Migrations Manually |
| 126 | |
| 127 | **CRITICAL:** Always use EF Core CLI commands to manage migrations. Never: |
| 128 | - Manually edit migration files (except for custom SQL in `Up()`/`Down()`) |
| 129 | - Delete migration files directly |
| 130 | - Rename migration files |
| 131 | - Copy migrations between projects |
| 132 | |
| 133 | ### Creating Migrations |
| 134 | |
| 135 | ```bash |
| 136 | # Create a new migration |
| 137 | dotnet ef migrations add AddCustomerTable \ |
| 138 | --project src/MyApp.Infrastructure \ |
| 139 | --startup-project src/MyApp.Api |
| 140 | |
| 141 | # With a specific DbContext (if you have multiple) |
| 142 | dotnet ef migrations add AddCustomerTable \ |
| 143 | --context ApplicationDbContext \ |
| 144 | --project src/MyApp.Infrastructure \ |
| 145 | --startup-project src/MyApp.Api |
| 146 | ``` |
| 147 | |
| 148 | ### Removing Migrations |
| 149 | |
| 150 | ```bash |
| 151 | # Remove the last migration (if not yet applied) |
| 152 | dotnet ef migra |