Entity Framework Core: Der moderne Weg für Datenbankoperationen
# Entity Framework Core: Der moderne Weg der Datenbankarbeit
Entity Framework Core (EF Core) ist das Standard-ORM in vielen .NET-Backends. Es ermöglicht schnelle Entwicklung bei gleichzeitig starker Kontrolle über Schemaevolution und Abfrageverhalten. Aber effektiver Einsatz geht weit über einen einfachen `SaveChanges()`-Aufruf hinaus. In diesem Artikel zeige ich, wie ich EF Core in produktiven Systemen aufsetze, optimiere und pflege.
DbContext einrichten
Der `DbContext` ist das Herzstück von EF Core. Eine saubere Konfiguration von Anfang an erspart schmerzhafte Refactorings im Nachhinein.
public class AppDbContext : DbContext
{
public DbSet<Product> Products => Set<Product>();
public DbSet<Order> Orders => Set<Order>();
public DbSet<Customer> Customers => Set<Customer>();
public AppDbContext(DbContextOptions<AppDbContext> options)
: base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
class=class="code-string">"code-comment">// Alle Konfigurationen aus der aktuellen Assembly anwenden
modelBuilder.ApplyConfigurationsFromAssembly(
Assembly.GetExecutingAssembly());
}
}Registrierung in `Program.cs` mit Connection Pooling und passenden Einstellungen:
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(
builder.Configuration.GetConnectionString(class="code-string">"Default"),
sqlOptions =>
{
sqlOptions.EnableRetryOnFailure(
maxRetryCount: class="code-number">3,
maxRetryDelay: TimeSpan.FromSeconds(class="code-number">10),
errorNumbersToAdd: null);
sqlOptions.CommandTimeout(class="code-number">30);
})
.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));In hochfrequentierten APIs, an denen ich gearbeitet habe, hat das globale Setzen von `NoTracking` als Standard -- mit gezieltem Opt-in nur bei Mutationen -- die unnötigen Speicherallokationen erheblich reduziert.
Entity-Konfiguration mit Fluent API
Ich bevorzuge die Fluent API gegenüber Data Annotations. Sie hält die Domain-Entitäten sauber und verlagert die gesamte Persistenzlogik in dedizierte Konfigurationsklassen.
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.ToTable(class="code-string">"Orders");
builder.HasKey(o => o.Id);
builder.Property(o => o.OrderNumber)
.IsRequired()
.HasMaxLength(class="code-number">50);
builder.Property(o => o.TotalAmount)
.HasPrecision(class="code-number">18, class="code-number">2);
builder.Property(o => o.Status)
.HasConversion<string>()
.HasMaxLength(class="code-number">20);
builder.HasOne(o => o.Customer)
.WithMany(c => c.Orders)
.HasForeignKey(o => o.CustomerId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasMany(o => o.OrderItems)
.WithOne(oi => oi.Order)
.HasForeignKey(oi => oi.OrderId)
.OnDelete(DeleteBehavior.Cascade);
class=class="code-string">"code-comment">// Indizes basierend auf tatsaechlichen Abfragemustern
builder.HasIndex(o => o.OrderNumber).IsUnique();
builder.HasIndex(o => o.CustomerId);
builder.HasIndex(o => new { o.Status, o.CreatedAt });
}
}Owned Types fuer Value Objects
builder.OwnsOne(c => c.Address, address =>
{
address.Property(a => a.Street).HasMaxLength(class="code-number">200);
address.Property(a => a.City).HasMaxLength(class="code-number">100);
address.Property(a => a.PostalCode).HasMaxLength(class="code-number">10);
address.Property(a => a.Country).HasMaxLength(class="code-number">60);
});Das mappt das `Address`-Value-Object direkt in die `Customer`-Tabelle. Die Domain bleibt sauber, ohne unnoetige JOINs einzufuehren.
Effektive LINQ-Abfragen schreiben
Projektion auf DTOs
Entitaeten niemals direkt aus API-Endpoints zurueckgeben. Stattdessen immer projizieren:
public async Task<List<OrderSummaryDto>> GetRecentOrdersAsync(
int customerId, CancellationToken ct)
{
return await _context.Orders
.Where(o => o.CustomerId == customerId)
.OrderByDescending(o => o.CreatedAt)
.Take(class="code-number">20)
.Select(o => new OrderSummaryDto
{
OrderNumber = o.OrderNumber,
Total = o.TotalAmount,
Status = o.Status,
ItemCount = o.OrderItems.Count,
CreatedAt = o.CreatedAt
})
.ToListAsync(ct);
}Projektionen mit `Select` lassen EF Core eine gezielte SQL-Abfrage generieren, die nur die benoetigten Spalten abruft. Das ist eine der einfachsten und wirkungsvollsten Performance-Verbesserungen.
AsNoTracking fuer Lesezugriffe
class=class="code-string">"code-comment">// Wenn der DbContext standardmaessig trackt, explizit deaktivieren
var products = await _context.Products
.AsNoTracking()
.Where(p => p.IsActive)
.ToListAsync(ct);Der Change Tracker haelt Referenzen auf jede geladene Entitaet. Bei Endpoints, die nur Daten lesen, ist dieser Overhead voellig unnoetig.
Compiled Queries fuer Hot Paths
Bei Abfragen, die tausende Male pro Minute laufen, ueberspringen Compiled Queries die Expression-Tree-Uebersetzung bei jedem Aufruf:
private static readonly Func<AppDbContext, int, CancellationToken, Task<Product?>>
GetProductById = EF.CompileAsyncQuery(
(AppDbContext ctx, int id, CancellationToken ct) =>
ctx.Products.FirstOrDefault(p => p.Id == id));
class=class="code-string">"code-comment">// Verwendung
var product = await GetProductById(_context, productId, ct);In hochfrequentierten APIs, an denen ich gearbeitet habe, haben Compiled Queries auf den meistgenutzten Endpoints 2-3ms pro Request eingespart -- einzeln betrachtet wenig, aber in der Summe enorm.
Das N+1-Problem und Loesungen
Das N+1-Problem ist vermutlich der haeufigste Performance-Killer in ORM-basierten Anwendungen. Es entsteht, wenn das Laden einer Liste fuer jede verknuepfte Entitaet eine separate Abfrage ausloest.
Das Problem
class=class="code-string">"code-comment">// SCHLECHT: class="code-number">1 Abfrage fuer Bestellungen + N Abfragen fuer Kunden
var orders = await _context.Orders.ToListAsync();
foreach (var order in orders)
{
class=class="code-string">"code-comment">// Jeder Zugriff auf order.Customer loest eine separate SQL-Abfrage aus
Console.WriteLine($class="code-string">"Bestellung {order.OrderNumber} von {order.Customer.Name}");
}Bei 100 Bestellungen erzeugt das 101 SQL-Abfragen. Mit aktiviertem Lazy Loading passiert das voellig unbemerkt.
Loesung 1: Eager Loading mit Include
class=class="code-string">"code-comment">// GUT: class="code-number">1 Abfrage (oder class="code-number">2 mit Split Query), die alles laedt
var orders = await _context.Orders
.Include(o => o.Customer)
.Include(o => o.OrderItems)
.ToListAsync();Loesung 2: Split Queries bei breiten Includes
Wenn mehrere Collections included werden, kann eine einzelne Abfrage zu einer kartesischen Explosion fuehren. Split Queries loesen das:
var orders = await _context.Orders
.Include(o => o.Customer)
.Include(o => o.OrderItems)
.ThenInclude(oi => oi.Product)
.AsSplitQuery()
.ToListAsync();Das generiert mehrere SQL-Statements, vermeidet aber die Datenduplizierung.
Loesung 3: Projektion (optimal fuer Read-Only)
class=class="code-string">"code-comment">// OPTIMAL fuer API-Responses: nur abrufen, was benoetigt wird
var orders = await _context.Orders
.Select(o => new OrderDetailDto
{
OrderNumber = o.OrderNumber,
CustomerName = o.Customer.Name,
Items = o.OrderItems.Select(oi => new OrderItemDto
{
ProductName = oi.Product.Name,
Quantity = oi.Quantity,
Price = oi.UnitPrice
}).ToList()
})
.ToListAsync();Projektion umgeht das N+1-Problem vollstaendig, da EF Core auf Datenbankebene eine einzelne SQL-Abfrage mit JOINs erzeugt.
Best Practices fuer Migrationen
Migrationen sind die Versionskontrolle fuer das Datenbankschema. Falsche Handhabung fuehrt zu Deployment-Fehlern, Datenverlust und naechtlichen Notfalleinsaetzen.
Migrationen erstellen und pruefen
# Migrationen immer aussagekraeftig benennen
dotnet ef migrations add AddOrderStatusIndex
# Den generierten Code vor dem Anwenden pruefen
dotnet ef migrations script --idempotentPruefen Sie die generierte Migrationsdatei immer. Der Diff-Algorithmus von EF Core funktioniert gut, aber nicht perfekt -- gelegentlich interpretiert er Umbenennungen als Loeschen-und-Neuerstellen.
Sichere Migrationsmuster
public partial class AddOrderStatusIndex : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
class=class="code-string">"code-comment">// Index ohne Tabellensperre erstellen (PostgreSQL)
migrationBuilder.Sql(
class="code-string">"CREATE INDEX CONCURRENTLY IF NOT EXISTS " +
class="code-string">"\"IX_Orders_Status\" ON \"Orders\" (\"Status\");");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: class="code-string">"IX_Orders_Status",
table: class="code-string">"Orders");
}
}Migrationsregeln, die ich befolge
Performance-Optimierung
Batching von Operationen
EF Core 7+ gruppiert `SaveChanges`-Aufrufe automatisch, aber die Batch-Groesse laesst sich steuern:
options.UseSqlServer(connectionString, sqlOptions =>
{
sqlOptions.MaxBatchSize(class="code-number">100);
});Bulk-Operationen
Beim Einfuegen tausender Zeilen ist `SaveChanges` zu langsam. Nutzen Sie `ExecuteUpdate` und `ExecuteDelete` (EF Core 7+):
class=class="code-string">"code-comment">// Alle inaktiven Produkte mit einem einzigen SQL-Statement aktualisieren
await _context.Products
.Where(p => !p.IsActive && p.LastModified < cutoffDate)
.ExecuteUpdateAsync(s =>
s.SetProperty(p => p.IsArchived, true)
.SetProperty(p => p.ArchivedAt, DateTime.UtcNow));
class=class="code-string">"code-comment">// Massenloeschung ohne Laden der Entitaeten
await _context.Products
.Where(p => p.IsArchived && p.ArchivedAt < retentionDate)
.ExecuteDeleteAsync();Diese werden direkt in SQL-`UPDATE`- und `DELETE`-Statements uebersetzt. Keine Entitaeten werden in den Speicher geladen.
Query Filter fuer Soft Delete
class=class="code-string">"code-comment">// In der Entity-Konfiguration
builder.HasQueryFilter(p => !p.IsDeleted);
class=class="code-string">"code-comment">// Dieser Filter wird automatisch auf jede Abfrage angewendet
var activeProducts = await _context.Products.ToListAsync();
class=class="code-string">"code-comment">// Bei Bedarf den Filter umgehen
var allProducts = await _context.Products
.IgnoreQueryFilters()
.ToListAsync();Verbindungsstabilitaet (Connection Resiliency)
In Cloud-Umgebungen sind transiente Fehler unvermeidlich:
options.UseSqlServer(connectionString, sqlOptions =>
{
sqlOptions.EnableRetryOnFailure(
maxRetryCount: class="code-number">5,
maxRetryDelay: TimeSpan.FromSeconds(class="code-number">30),
errorNumbersToAdd: null);
});Monitoring mit Query Tags
var orders = await _context.Orders
.TagWith(class="code-string">"GetRecentOrders - OrdersController")
.Where(o => o.CreatedAt > cutoff)
.ToListAsync();Das bettet einen Kommentar in das generierte SQL ein, sodass langsame Abfragen leicht zum auslösenden C#-Code zurueckverfolgt werden koennen.
Haeufige EF-Core-Fehler
1. Entitaeten direkt aus API-Endpoints zurueckgeben
class=class="code-string">"code-comment">// SCHLECHT: Legt interne Struktur offen, loest Lazy Loading aus,
class=class="code-string">"code-comment">// verursacht Serialisierungsfehler durch zirkulaere Referenzen
[HttpGet]
public async Task<List<Order>> GetOrders()
=> await _context.Orders.Include(o => o.Customer).ToListAsync();
class=class="code-string">"code-comment">// GUT: Auf ein DTO projizieren
[HttpGet]
public async Task<List<OrderDto>> GetOrders()
=> await _context.Orders
.Select(o => new OrderDto { class=class="code-string">"code-comment">/* ... */ })
.ToListAsync();2. Kein CancellationToken verwenden
class=class="code-string">"code-comment">// SCHLECHT: Trennt der Client die Verbindung, laeuft die Abfrage weiter
await _context.Products.ToListAsync();
class=class="code-string">"code-comment">// GUT: Abfrage wird abgebrochen, wenn der HTTP-Request beendet wird
await _context.Products.ToListAsync(cancellationToken);3. Ganze Tabellen in den Speicher laden
class=class="code-string">"code-comment">// SCHLECHT: Alles laden, dann im Speicher filtern
var expensive = (await _context.Products.ToListAsync())
.Where(p => p.Price > class="code-number">100);
class=class="code-string">"code-comment">// GUT: Filterung auf Datenbankebene
var expensive = await _context.Products
.Where(p => p.Price > class="code-number">100)
.ToListAsync();4. Langlebige DbContext-Instanzen
Der `DbContext` ist fuer eine kurze Lebensdauer konzipiert. In Webanwendungen sollte er auf einen einzelnen Request beschraenkt sein. Wird ein DbContext ueber mehrere Requests hinweg gehalten, blaehen sich der Change Tracker auf, veraltete Daten bleiben bestehen und Concurrency-Probleme haeufen sich.
5. Das generierte SQL ignorieren
class=class="code-string">"code-comment">// In der Entwicklung aktivieren, um zu sehen, was EF Core tatsaechlich sendet
options.LogTo(Console.WriteLine, LogLevel.Information)
.EnableSensitiveDataLogging()
.EnableDetailedErrors();In hochfrequentierten APIs, an denen ich gearbeitet habe, hat das Aktivieren des SQL-Loggings waehrend der Entwicklung in jeder Codebasis, die ich geprueft habe, mindestens ein verstecktes N+1-Problem aufgedeckt. Machen Sie es zur Gewohnheit.
Fazit
EF Core ist ein maaechtiges Werkzeug, wenn es mit Disziplin eingesetzt wird. Der Unterschied zwischen einer problematischen Datenschicht und einer performanten liegt fast immer darin: Projektion statt Entitaeten laden, die Loading-Strategie bewusst waehlen, das generierte SQL pruefen und Migrationen als erstklassige Deployment-Artefakte behandeln.
Die hier vorgestellten Muster stammen aus realen Produktivsystemen, die Millionen von Anfragen verarbeiten. Sie sind nicht theoretisch -- sie sind praxiserprobt.
Gerne unterstuetze ich bei einem EF-Core-Review mit Fokus auf Korrektheit und Performance.
Verwandte Artikel
Was ist .NET? Ein moderner Backend-Entwicklungsleitfaden
Erfahren Sie, was .NET ist, wie es funktioniert und warum es für Enterprise-Backend gewählt wird.
RESTful APIs mit ASP.NET Core entwickeln
Lernen Sie die Grundlagen für produktionsreife REST-APIs mit ASP.NET Core. Controller, Routing und Best Practices.
Clean Architecture in .NET: Skalierbare Projektstruktur
Wenden Sie Clean Architecture in .NET-Projekten an. Schichten, Abhängigkeiten und testbarer Code.
Haben Sie ein Flutter-Projekt?
Ich entwickle hochleistungsfähige Flutter-Anwendungen für iOS, Android und Web.
Kontakt aufnehmen