
Entwickler arbeiten früher oder später mit relationalen Datenbanken — unabhängig von der verwendeten Programmiersprache. Allen gemeinsam ist der Einsatz von objektrelationalen Mapper‑Frameworks (ORM), die den Zugriff auf Datenbanken vereinfachen. Obwohl das grundlegende Konzept in allen ORM‑Frameworks ähnlich ist, unterscheiden sie sich erheblich in Implementierung, Nutzung und Optimierungsmöglichkeiten, um effiziente, schnelle SQL‑Abfragen mit minimalen Round‑Trips zu erreichen.
Entity Framework Core (EF Core) ist ein vielseitiges ORM‑Framework für .NET, das Flexibilität, Leistung und Funktionalität verbindet. Dieser Artikel stellt zehn Best Practices für EF Core vor, die häufig offensichtlich erscheinen, jedoch nicht immer intuitiv sind und viele Performance‑Fallstricke vermeiden.
Beispielcode und das zugehörige Repository stehen auf GitHub zur Verfügung.
Die Arbeit mit einer Datenbank in EF Core lässt sich mit dem Bau eines Holzgegenstands mit Hammer und Nägeln vergleichen: Der richtige Einsatz der Werkzeuge führt zu stabilen Ergebnissen; der falsche Weg ist mühsam, langsam und ineffizient.
Ein Index ist ein Datenbankobjekt, das Abfragen erheblich beschleunigt. Ohne Indizes muss jede Abfrage einen vollständigen Tabellenscan durchführen — vergleichbar mit dem Durchlesen jedes Buchkapitels, um ein Wort zu finden. Ein Index fungiert wie ein Buchregister und ermöglicht eine gezielte Suche.
1protected override void OnModelCreating(ModelBuilder modelBuilder)
2{
3 base.OnModelCreating(modelBuilder);
4
5 modelBuilder.Entity<Blog>()
6 .HasIndex(b => b.Url);
Ein zusammengesetzter Index (Composite Index) kann ebenfalls konfiguriert werden:
1modelBuilder.Entity<Blog>()
2 .HasIndex(b => new { b.Url, b.Title });Es ist zu beachten, dass Indizes von Natur aus nicht eindeutig sind und explizit mit der Methode IsUnique() als eindeutig gekennzeichnet werden müssen.
Ein häufiger Fehler besteht darin, komplette Entitäten zu laden und anschließend im Speicher in DTOs umzuwandeln. Dadurch werden unnötige Daten abgerufen, was Speicherverbrauch und Antwortzeiten erhöht.
Ohne Projektion:
1app.MapGet("api/blogs/no-projection", async (CoreDbContext db) => {
2 var blogs = await db.Blogs.ToListAsync();
3
4 // Entities are loaded into memory, and then projected into DTOs
5 return Results.Ok(blogs.Select(x => new BlogDto(x.Id, x.Url, x.Title, x.Content)));
6
7 })
8 .WithTags("Blogs")
9 .WithName("GetBlogsWithoutProjection");
Erzeugt folgende SQL-Abfrage:
SELECT b."Id", b."Content", b."Created", b."Modified", b."Title", b."Url"
FROM "Blogs" AS b
Die Abfrage holt alle verfügbaren Informationen für die gegebene Entität, selbst wenn wir sie nicht benötigen. Da diese Daten im Arbeitsspeicher projiziert werden, tragen ungenutzte Daten lediglich zu unnötigem Speicherverbrauch bei. In kleinem Maßstab mag das irrelevant sein, doch je größer die Entität, desto schlechter wird die Abfrage.
Mit Projektion:
1app.MapGet("api/blogs/projection", async (CoreDbContext db) => {
2 var blogs = await db.Blogs
3 .Select(x => new BlogDto(x.Id, x.Url, x.Title, x.Content))
4 .ToListAsync();
5
6 return Results.Ok(blogs);
7 });
Erzeugt folgende SQL-Abfrage:
SELECT b."Id", b."Url", b."Title", b."Content"
FROM "Blogs" AS b
Es ist gut zu wissen, dass auch anonyme Objekte für Abfrageprojektionen verwendet werden können! Dies ist nützlich, wenn ein Sonderfall auftritt und die Erstellung eines vollständigen Datenobjekts dafür keinen Mehrwert bietet.
Darüber hinaus vereinfacht die Auslagerung der Projektion in eine statische Methode nicht nur den Code, sondern ermöglicht auch die Verwendung von Pattern Matching, da ein Ausdrucksbaum keine Pattern-Matching-Konstrukte wie is not null oder Switch-Ausdrücke enthalten darf.
Beim Abrufen verknüpfter Daten kann unbedachtes Eager Loading zu einer kartesischen Explosion führen — einer unerwartet hohen Datenmenge aufgrund von Joins.
Beispiel ohne Projektion:
1app.MapGet("api/blogs/no-projection/with-author", async (CoreDbContext db) => {
2 var blogs = await db.Blogs
3 .AsNoTracking()
4 .Include(b => b.Author)
5 .ToListAsync();
6
7 return Results.Ok(blogs.Select(x => BlogWithAuthorDto.CreateInstance(x.Id, x.Url, x.Title, x.Content, x.Author.Id, x.Author.Pseudonym)));
8 })
9 .WithTags("Blogs").WithName("BlogsWithAuthor"); Abfrage ohne Projektion:
SELECT b."Id", b."AuthorId", b."Content", b."Created", b."Hidden", b."Modified", b."Title", b."Url", s."Id", s."BirthDate", s."FirstName", s."LastName", s."Biography", s."Pseudonym"
FROM "Blogs" AS b
INNER JOIN (
SELECT p."Id", p."BirthDate", p."FirstName", p."LastName", a."Biography", a."Pseudonym"
FROM "People" AS p
INNER JOIN "Authors" AS a ON p."Id" = a."Id)
AS s ON b."AuthorId" = s."Id"
Mit Projektion:
1app.MapGet("api/blogs/projection/with-author", async (CoreDbContext db) => {
2 var blogs = await db.Blogs
3 .AsNoTracking()
4 .Select(x => BlogWithAuthorDto.CreateInstance(x.Id, x.Url, x.Title, x.Content, x.Author.Id, x.Author.Pseudonym))
5 .ToListAsync();
6
7 return Results.Ok(blogs);
8 })
9 .WithTags("Blogs")
10 .WithName("GetBlogsWithAuthor-Projection"); Abfrage mit Projektion:
SELECT b."Id", b."Url", b."Title", b."Content", s."Id", s."Pseudonym"
FROM "Blogs" AS b
INNER JOIN (
SELECT p."Id", a."Pseudonym"
FROM "People" AS p
INNER JOIN "Authors" AS a ON p."Id" = a."Id")
AS s ON b."AuthorId" = s."Id"
AsNoTracking() reduziert den Speicher‑ und CPU‑Verbrauch, da EF Core die geladenen Entitäten nicht nachverfolgt — ideal für reine Leseszenarien:
1var blogs = await db.Blogs
2 .AsNoTracking()
3 .Select(x => new BlogDto(x.Id, x.Url, x.Title, x.Content))
4 .ToListAsync();
Angenommen, alle Blogs sollen ausgeblendet werden. Eine übliche EF Core-Operation würde wie folgt aussehen:
app.MapPatch("api/blogs/hide", async (CoreDbContext db) => {
foreach (var blog in db.Blogs)
blog.Hidden = true;
await db.SaveChangesAsync();
return Results.NoContent();
})
.WithTags("blogs")
.WithName("HideAllBlogs");
Obwohl dies gültiger Code ist, werden dadurch alle irrelevanten Informationen in den Speicher geladen, die wir nicht benötigen, und insgesamt zwei Datenbank-Roundtrips erzeugt. Die Änderungsverfolgung von EF Core erstellt beim Laden der Entitäten Snapshots und vergleicht diese dann mit den Instanzen, um herauszufinden, welche Eigenschaften sich geändert haben. Darüber hinaus wächst die Abfrage exponentiell mit der Anzahl der vorhandenen Datensätze.
Stattdessen können wir die Methode ExecuteUpdateAsync verwenden:
1app.MapPatch("api/blogs/show", async (CoreDbContext db) => {
2 await db.Blogs.ExecuteUpdateAsync(s => s
3 .SetProperty(blog => blog.Hidden, false)
4 );
5
6 await db.SaveChangesAsync();
7 return Results.NoContent();
8 })
9 .WithTags("blogs")
10 .WithName("ShowBlogs");
Dadurch wird der gesamte Vorgang in einem einzigen Roundtrip ausgeführt, ohne dass tatsächliche Daten in die Datenbank geladen oder an diese gesendet werden und ohne dass die Änderungsverfolgung von EF Core verwendet wird:
UPDATE "Blogs" AS b
SET "Hidden" = FALSE
Eine weitere Option ist das Löschen. Dies wird mit der Methode ExecuteDeleteAsync() erreicht.
Massenupdates oder -löschungen mit ExecuteUpdateAsync oder ExecuteDeleteAsync nutzen standardmäßig keine impliziten Transaktionen. Füge notwendige Transaktionslogik hinzu:
1await using var transaction = await db.Database.BeginTransactionAsync();
2try
3{
4 await db.Blogs.ExecuteUpdateAsync(s => s
5 .SetProperty(blog => blog.Hidden, false)
6 );
7 db.Blogs.Add(new Blog
8 {
9 AuthorId = Guid.CreateVersion7()// dummy placeholder
10 });
11 await db.SaveChangesAsync();
12 await transaction.CommitAsync();
13}
14catch (Exception)
15{
16 await transaction.RollbackAsync();
17}
EF Core‑Methoden wie ToListAsync() sind asynchron — sie werden jedoch sequenziell ausgeführt und unterstützen keine parallelen Datenbankzugriffe auf demselben Kontext.
Dieses Verhalten unterstützt ACID‑Prinzipien und vermeidet unerwartete Fehler im hochskalierten Betrieb.
Navigationseigenschaften sollten nicht mit Standardwerten instanziiert werden (z. B. = new()), da EF Core sonst neue Objekte annehmen könnte, die eingefügt werden sollen.
Falsch:
public Author Author { get; set; } = new();
Richtig:
public Author Author { get; set; } = null!;oder Nullable declaration, wenn Beziehung optional ist.
Das manuelle Setzen von Primärschlüsseln kann zu Kollisionen bei großen Datenmengen führen. EF Core kann Schlüssel automatisch generieren — z. B. GUID v7 in PostgreSQL.
Nutze Skip() und Take(), um zu große Datenmengen zu vermeiden:
1var blogs = await db.Blogs
2 .AsNoTracking()
3 .Skip(1)
4 .Take(10)
5 .ToListAsync();
Asynchrone EF Core‑Methoden unterstützen Abbruchtokens — wichtig für API‑Szenarien, bei denen laufende Abfragen vorzeitig beendet werden müssen, um Ressourcen freizugeben.
Vorteil Split Queries:
Nachteile:
Nutze AsSplitQuery() dort, wo viele Includes geladen werden.
Effiziente EF Core‑Abfragen basieren nicht auf Tricks, sondern auf fundiertem Verständnis von Datenbankoperationen und deren Kosten. Die konsequente Anwendung der oben genannten Best Practices verbessert Performance, reduziert Speicher‑ und CPU‑Last und führt zu wartbaren, skalierbaren Anwendungen.
Schnelle Abfragen bedeuten weniger Wartezeit für Endnutzer — und beeinflussen damit direkt die Benutzererfahrung (UX).
Beachte: Nicht alle Best Practices sind in jedem Kontext relevant — sie sollten stets kontextbezogen bewertet werden.

Unser Geschäftsführer Tibor Csizmadia und unser Kundenbetreuer Jens Walter stehen Ihnen persönlich zur Verfügung. Profitieren Sie von unserer langjährigen Erfahrung und erhalten Sie eine kompetente Erstberatung in einem unverbindlichen Austausch.