Entity Framework Core Patterns
Wann dieses Skill verwenden?
Verwenden Sie dieses Skill, wenn:
- Sie EF Core in einem neuen Projekt einrichten
- Sie die Abfrageleistung optimieren möchten
- Sie Datenbankmigrationen verwalten
- Sie EF Core in .NET Aspire integrieren
- Sie Probleme mit Change Tracking debuggen
- Sie mehrere Navigationssammlungen effizient laden möchten (Query Splitting)
Kernprinzipien
- NoTracking als Standard – Die meisten Abfragen sind schreibgeschützt; wählen Sie explizit Tracking aus.
- Niemals Migrationen manuell bearbeiten – Verwenden Sie immer CLI-Befehle.
- Dedizierter Migrationsdienst – Trennen Sie die Migrationsausführung vom Anwendungsstart.
- ExecutionStrategy für Wiederholungen – Behandeln Sie transiente Datenbankfehler.
- Explizite Updates – Bei NoTracking markieren Sie Entitäten explizit für das Update.
Pattern 1: NoTracking als Standard
Konfigurieren Sie Ihren DbContext so, dass Change Tracking standardmäßig deaktiviert ist. Dies verbessert die Leistung bei leselastigen Workloads.
public class ApplicationDbContext : DbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
// Change Tracking standardmäßig deaktivieren für bessere Leistung bei schreibgeschützten Abfragen
// Verwenden Sie explizit .AsTracking() für Abfragen, die Änderungen verfolgen müssen
ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
}
public DbSet<Order> Orders => Set<Order>();
public DbSet<Customer> Customers => Set<Customer>();
}
Wenn NoTracking aktiv ist
Schreibgeschützte Abfragen funktionieren normal:
// ✅ Schnelles Lesen – kein Tracking-Overhead
var orders = await dbContext.Orders
.Where(o => o.Status == OrderStatus.Pending)
.ToListAsync();
Schreiboperationen erfordern explizite Behandlung:
// ❌ FALSCH – Entität wird nicht verfolgt, SaveChanges tut nichts
var order = await dbContext.Orders.FirstOrDefaultAsync(o => o.Id == orderId);
order.Status = OrderStatus.Shipped;
await dbContext.SaveChangesAsync(); // Es passiert nichts!
// ✅ RICHTIG – Entität explizit für Update markieren
var order = await dbContext.Orders.FirstOrDefaultAsync(o => o.Id == orderId);
order.Status = OrderStatus.Shipped;
dbContext.Orders.Update(order); // Markiert gesamte Entität als geändert
await dbContext.SaveChangesAsync();
// ✅ AUCH RICHTIG – AsTracking() für die Abfrage verwenden
var order = await dbContext.Orders
.AsTracking()
.FirstOrDefaultAsync(o => o.Id == orderId);
order.Status = OrderStatus.Shipped;
await dbContext.SaveChangesAsync(); // Funktioniert!
Wann Tracking verwenden
| Szenario | Tracking verwenden? | Grund |
|---|---|---|
| Daten in der Benutzeroberfläche anzeigen | Nein | Schreibgeschützt, keine Updates |
| API GET-Endpunkte | Nein | Daten zurückgeben, keine Mutationen |
| Einzelne Entität aktualisieren | Ja oder explizites Update() | Änderungen speichern müssen |
| Komplexe Updates mit Navigationen | Ja | Tracking behandelt Beziehungen |
| Batch-Operationen | Nein + ExecuteUpdate | Effizienter |
Explizites Add/Update-Muster
public class OrderService
{
private readonly ApplicationDbContext _db;
// CREATE – Immer Add verwenden (funktioniert unabhängig vom Tracking)
public async Task<Order> CreateOrderAsync(Order order)
{
_db.Orders.Add(order);
await _db.SaveChangesAsync();
return order;
}
// UPDATE – Explizit als geändert markieren
public async Task UpdateOrderStatusAsync(Guid orderId, OrderStatus newStatus)
{
var order = await _db.Orders.FirstOrDefaultAsync(o => o.Id == orderId)
?? throw new NotFoundException($"Bestellung {orderId} nicht gefunden");
order.Status = newStatus;
order.UpdatedAt = DateTimeOffset.UtcNow;
// Explizit als geändert markieren, da DbContext standardmäßig NoTracking verwendet
_db.Orders.Update(order);
await _db.SaveChangesAsync();
}
// DELETE – Anhängen und entfernen
public async Task DeleteOrderAsync(Guid orderId)
{
var order = new Order { Id = orderId };
_db.Orders.Remove(order);
await _db.SaveChangesAsync();
}
}
Pattern 2: Niemals Migrationen manuell bearbeiten
KRITISCH: Verwenden Sie immer EF Core CLI-Befehle, um Migrationen zu verwalten. Niemals:
- Migrationsdateien manuell bearbeiten (außer für benutzerdefiniertes SQL in
Up()/Down()) - Migrationsdateien direkt löschen
- Migrationsdateien umbenennen
- Migrationen zwischen Projekten kopieren
Migrationen erstellen
# Neue Migration erstellen
dotnet ef migrations add AddCustomerTable \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api
# Mit einem bestimmten DbContext (falls mehrere vorhanden)
dotnet ef migrations add AddCustomerTable \
--context ApplicationDbContext \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api
Migrationen entfernen
# Letzte Migration entfernen (falls noch nicht angewendet)
dotnet ef migrations remove \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api
# NIEMALS so machen:
# rm Migrations/20240101_AddCustomerTable.cs # ❌ SCHLECHT!
Migrationen anwenden
# Alle ausstehenden Migrationen anwenden
dotnet ef database update \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api
# Bis zu einer bestimmten Migration anwenden
dotnet ef database update AddCustomerTable \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api
# Auf eine vorherige Migration zurücksetzen
dotnet ef database update PreviousMigrationName \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api
SQL-Skripte generieren
# SQL-Skript für alle Migrationen generieren
dotnet ef migrations script \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api \
--output migrations.sql
# Idempotentes Skript generieren (sicher mehrfach auszuführen)
dotnet ef migrations script \
--idempotent \
--project src/MyApp.Infrastructure \
--startup-project src/MyApp.Api
Pattern 3: Dedizierter Migrationsdienst mit Aspire
Trennen Sie die Migrationsausführung von Ihrer Hauptanwendung mit einem dedizierten Migrationsdienst. Dies stellt sicher:
- Migrationen werden abgeschlossen, bevor die App startet
- Saubere Trennung der Verantwortlichkeiten
- Kontrolliertes Seeding in Testumgebungen
Projektstruktur
src/
├── MyApp.AppHost/ # Aspire-Orchestrierung
├── MyApp.Api/ # Hauptanwendung
├── MyApp.Infrastructure/ # DbContext und Migrationen
└── MyApp.MigrationService/ # Dedizierter Migrationsausführer
Program.cs des Migrationsdienstes
using MyApp.Infrastructure.Data;
using MyApp.MigrationService;
using Microsoft.EntityFrameworkCore;
var builder = Host.CreateApplicationBuilder(args);
// Aspire-Service-Standards hinzufügen
builder.AddServiceDefaults();
// PostgreSQL DbContext hinzufügen
var connectionString = builder.Configuration.GetConnectionString("appdb")
?? throw new InvalidOperationException("Verbindungszeichenfolge 'appdb' nicht gefunden.");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseNpgsql(connectionString, npgsqlOptions =>
npgsqlOptions.MigrationsAssembly("MyApp.Infrastructure")));
// Migrationsworker hinzufügen
builder.Services.AddHostedService<MigrationWorker>();
var host = builder.Build();
host.Run();
MigrationWorker.cs
public class MigrationWorker : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly IHostApplicationLifetime _hostApplicationLifetime;
private readonly ILogger<MigrationWorker> _logger;
public MigrationWorker(
IServiceProvider serviceProvider,
IHostApplicationLifetime hostApplicationLifetime,
ILogger<MigrationWorker> logger)
{
_serviceProvider = serviceProvider;
_hostApplicationLifetime = hostApplicationLifetime;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Migrationsdienst wird gestartet...");
try
{
using var scope = _serviceProvider.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
await RunMigrationsAsync(dbContext, stoppingToken);
_logger.LogInformation("Migrationsdienst erfolgreich abgeschlossen.");
}
catch (Exception ex)
{
_logger.LogError(ex, "Migrationsdienst fehlgeschlagen: {Error}", ex.Message);
throw;
}
finally
{
// Anwendung nach Abschluss der Migrationen beenden
_hostApplicationLifetime.StopApplication();
}
}
private async Task RunMigrationsAsync(ApplicationDbContext dbContext, CancellationToken ct)
{
// Ausführungsstrategie für die Behandlung transienter Fehler verwenden
var strategy = dbContext.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
var pendingMigrations = await dbContext.Database.GetPendingMigrationsAsync(ct);
if (pendingMigrations.Any())
{
_logger.LogInformation("Wende {Count} ausstehende Migrationen an...",
pendingMigrations.Count());
await dbContext.Database.MigrateAsync(ct);
_logger.LogInformation("Migrationen erfolgreich angewendet.");
}
else
{
_logger.LogInformation("Keine ausstehenden Migrationen. Die Datenbank ist auf dem neuesten Stand.");
}
});
}
}
AppHost-Konfiguration
var builder = DistributedApplication.CreateBuilder(args);
var postgres = builder.AddPostgres("postgres");
var db = postgres.AddDatabase("appdb");
// Migrationen werden zuerst ausgeführt, dann beendet
var migrations = builder.AddProject<Projects.MyApp_MigrationService>("migrations")
.WaitFor(db)
.WithReference(db);
// API wartet auf Abschluss der Migrationen
var api = builder.AddProject<Projects.MyApp_Api>("api")
.WaitForCompletion(migrations) // Entscheidend: wartet auf Abschluss der Migrationen
.WithReference(db);
Pattern 4: ExecutionStrategy für transiente Fehler
Verwenden Sie immer CreateExecutionStrategy() für Operationen, die vorübergehend fehlschlagen könnten:
public async Task UpdateWithRetryAsync(Guid id, Action<Order> update)
{
var strategy = _dbContext.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
var order = await _dbContext.Orders
.AsTracking()
.FirstOrDefaultAsync(o => o.Id == id);
if (order is null) return;
update(order);
await _dbContext.SaveChangesAsync();
});
}
Wichtig: Sie können CreateExecutionStrategy() nicht mit benutzerinitiierten Transaktionen verwenden. Wenn Sie Transaktionen mit Wiederholungen benötigen:
var strategy = _dbContext.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
// Transaktion muss INNERHALB des Strategy-Rückrufs sein
await using var transaction = await _dbContext.Database.BeginTransactionAsync();
try
{
// ... Ihre Operationen ...
await _dbContext.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
});
Pattern 5: Massenoperationen mit ExecuteUpdate/ExecuteDelete
Verwenden Sie für Massenoperationen ExecuteUpdateAsync und ExecuteDeleteAsync ab EF Core 7+, anstatt Entitäten zu laden:
// ❌ LANGSAM – Lädt alle Entitäten in den Speicher
var expiredOrders = await _db.Orders
.Where(o => o.ExpiresAt < DateTimeOffset.UtcNow)
.ToListAsync();
foreach (var order in expiredOrders)
{
order.Status = OrderStatus.Expired;
}
await _db.SaveChangesAsync();
// ✅ SCHNELL – Einzelne SQL UPDATE-Anweisung
await _db.Orders
.Where(o => o.ExpiresAt < DateTimeOffset.UtcNow)
.ExecuteUpdateAsync(setters => setters
.SetProperty(o => o.Status, OrderStatus.Expired)
.SetProperty(o => o.UpdatedAt, DateTimeOffset.UtcNow));
// ✅ SCHNELL – Einzelne SQL DELETE-Anweisung
await _db.Orders
.Where(o => o.Status == OrderStatus.Cancelled && o.CreatedAt < cutoffDate)
.ExecuteDeleteAsync();
Häufige Fallstricke
1. Vergessen zu aktualisieren, wenn NoTracking aktiv ist
// ❌ Stiller Fehler – Entität wird nicht verfolgt
var customer = await _db.Customers.FindAsync(id);
customer.Name = "Neuer Name";
await _db.SaveChangesAsync(); // Tut nichts!
// ✅ Explizites Update
var customer = await _db.Customers.FindAsync(id);
customer.Name = "Neuer Name";
_db.Customers.Update(customer);
await _db.SaveChangesAsync();
2. N+1-Abfrageproblem
// ❌ N+1 Abfragen – eine Abfrage pro Bestellung
var customers = await _db.Customers.ToListAsync();
foreach (var customer in customers)
{
var orders = customer.Orders; // Lazy Loading löst Abfrage aus
}
// ✅ Eager Loading – einzelne Abfrage
var customers = await _db.Customers
.Include(c => c.Orders)
.ToListAsync();
3. Tracking-Konflikte mit mehreren DbContext-Instanzen
// ❌ Tracking-Konflikt – Entität wird von einem anderen Kontext verfolgt
var order1 = await _db1.Orders.AsTracking().FindAsync(id);
var order2 = await _db2.Orders.AsTracking().FindAsync(id);
order2.Status = OrderStatus.Shipped;
await _db2.SaveChangesAsync(); // Kann werfen oder sich unerwartet verhalten
// ✅ Einzelnen Kontext verwenden oder zuerst trennen
_db1.Entry(order1).State = EntityState.Detached;
4. Async nicht konsistent verwenden
// ❌ Blockierender Aufruf im asynchronen Kontext
var orders = _db.Orders.ToList(); // Blockiert Thread
// ✅ Durchgängig asynchron
var orders = await _db.Orders.ToListAsync();
5. Abfragen innerhalb von Schleifen
// ❌ Abfrage pro Iteration
foreach (var orderId in orderIds)
{
var order = await _db.Orders.FindAsync(orderId);
// Bestellung verarbeiten
}
// ✅ Einzelne Abfrage
var orders = await _db.Orders
.Where(o => orderIds.Contains(o.Id))
.ToListAsync();
DbContext-Lebensdauer in DI
ASP.NET Core (Standardmäßig Scoped)
// Scoped = eine Instanz pro HTTP-Anfrage
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseNpgsql(connectionString));
...
[gekürzt für die Übersetzung]