.NET Core
Entity Framework Core: Der moderne Weg für Datenbankoperationen
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)
{
// 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("Default"),
sqlOptions =>
{
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 3,
maxRetryDelay: TimeSpan.FromSeconds(10),
errorNumbersToAdd: null);
sqlOptions.CommandTimeout(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("Orders");
builder.HasKey(o => o.Id);
builder.Property(o => o.OrderNumber)
.IsRequired()
.HasMaxLength(50);
builder.Property(o => o.TotalAmount)
.HasPrecision(18, 2);
builder.Property(o => o.Status)
.HasConversion<string>()
.HasMaxLength(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);
// 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(200);
address.Property(a => a.City).HasMaxLength(100);
address.Property(a => a.PostalCode).HasMaxLength(10);
address.Property(a => a.Country).HasMaxLength(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(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
// 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));
// 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
// SCHLECHT: 1 Abfrage fuer Bestellungen + N Abfragen fuer Kunden
var orders = await _context.Orders.ToListAsync();
foreach (var order in orders)
{
// Jeder Zugriff auf order.Customer loest eine separate SQL-Abfrage aus
Console.WriteLine($"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
// GUT: 1 Abfrage (oder 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)
// 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)
{
// Index ohne Tabellensperre erstellen (PostgreSQL)
migrationBuilder.Sql(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS " +
"\"IX_Orders_Status\" ON \"Orders\" (\"Status\");");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Orders_Status",
table: "Orders");
}
}Migrationsregeln, die ich befolge
- Ein Thema pro Migration. Schemaaenderungen und Datenmigrationen nicht vermischen.
- Bereits angewendete Migrationen niemals bearbeiten. Stattdessen eine neue erstellen.
- Immer die `Down`-Methode schreiben. Frueher oder spaeter muss ein Rollback durchgefuehrt werden.
- Idempotente Skripte fuer die Produktion. Mit
--idempotentgenerierte Skripte sind bei erneutem Ausfuehren sicher. - Datenmigrationen von Schemamigrationen trennen. Datentransformationen sind schwerer rueckgaengig zu machen und sollten als eigener Deployment-Schritt behandelt werden.
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(100);
});Bulk-Operationen
Beim Einfuegen tausender Zeilen ist SaveChanges zu langsam. Nutzen Sie ExecuteUpdate und ExecuteDelete (EF Core 7+):
// 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));
// 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
// In der Entity-Konfiguration
builder.HasQueryFilter(p => !p.IsDeleted);
// Dieser Filter wird automatisch auf jede Abfrage angewendet
var activeProducts = await _context.Products.ToListAsync();
// 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: 5,
maxRetryDelay: TimeSpan.FromSeconds(30),
errorNumbersToAdd: null);
});Monitoring mit Query Tags
var orders = await _context.Orders
.TagWith("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
// SCHLECHT: Legt interne Struktur offen, loest Lazy Loading aus,
// verursacht Serialisierungsfehler durch zirkulaere Referenzen
[HttpGet]
public async Task<List<Order>> GetOrders()
=> await _context.Orders.Include(o => o.Customer).ToListAsync();
// GUT: Auf ein DTO projizieren
[HttpGet]
public async Task<List<OrderDto>> GetOrders()
=> await _context.Orders
.Select(o => new OrderDto { /* ... */ })
.ToListAsync();2. Kein CancellationToken verwenden
// SCHLECHT: Trennt der Client die Verbindung, laeuft die Abfrage weiter
await _context.Products.ToListAsync();
// GUT: Abfrage wird abgebrochen, wenn der HTTP-Request beendet wird
await _context.Products.ToListAsync(cancellationToken);3. Ganze Tabellen in den Speicher laden
// SCHLECHT: Alles laden, dann im Speicher filtern
var expensive = (await _context.Products.ToListAsync())
.Where(p => p.Price > 100);
// GUT: Filterung auf Datenbankebene
var expensive = await _context.Products
.Where(p => p.Price > 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
// 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