Entity Framework Core: Der moderne Weg für Datenbankoperationen

15 Min. Lesezeit9. Februar 2026Aktualisiert: 9. März 2026
Entity Framework CoreEF Core tutorialORM .NET.NET databaseCode-first EF CoreEF Core migrationsLINQ queriesEF Core performance

# 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.

csharp
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:

csharp
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.

csharp
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

csharp
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:

csharp
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

csharp
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:

csharp
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

csharp
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

csharp
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:

csharp
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)

csharp
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

bash
# Migrationen immer aussagekraeftig benennen
dotnet ef migrations add AddOrderStatusIndex

# Den generierten Code vor dem Anwenden pruefen
dotnet ef migrations script --idempotent

Pruefen 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

csharp
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

  • **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 `--idempotent` generierte 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:

    csharp
    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+):

    csharp
    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

    csharp
    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:

    csharp
    options.UseSqlServer(connectionString, sqlOptions =>
    {
        sqlOptions.EnableRetryOnFailure(
            maxRetryCount: class="code-number">5,
            maxRetryDelay: TimeSpan.FromSeconds(class="code-number">30),
            errorNumbersToAdd: null);
    });

    Monitoring mit Query Tags

    csharp
    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

    csharp
    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

    csharp
    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

    csharp
    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

    csharp
    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

    Haben Sie ein Flutter-Projekt?

    Ich entwickle hochleistungsfähige Flutter-Anwendungen für iOS, Android und Web.

    Kontakt aufnehmen