.NET Core

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 (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)
    {
        // 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("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.

csharp
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

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

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(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
// 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));

// 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
// 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

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

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
// 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)
    {
        // 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

  1. Ein Thema pro Migration. Schemaaenderungen und Datenmigrationen nicht vermischen.
  2. Bereits angewendete Migrationen niemals bearbeiten. Stattdessen eine neue erstellen.
  3. Immer die `Down`-Methode schreiben. Frueher oder spaeter muss ein Rollback durchgefuehrt werden.
  4. Idempotente Skripte fuer die Produktion. Mit --idempotent generierte Skripte sind bei erneutem Ausfuehren sicher.
  5. 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(100);
});

Bulk-Operationen

Beim Einfuegen tausender Zeilen ist SaveChanges zu langsam. Nutzen Sie ExecuteUpdate und ExecuteDelete (EF Core 7+):

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

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

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

Monitoring mit Query Tags

csharp
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

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

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

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

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