EF Core 9 Complex Types und JSON Columns — was die ORM-Reife 2026 bedeutet
Complex Types lösen ein altes Problem: Value Objects in EF Core ohne die Reibung der Owned Entities. JSON-Columns mit Path-Indexing in SQL Server 2025 und PostgreSQL 17 verschieben die Grenze zwischen relationaler und dokumentenbasierter Modellierung. Was die Praxis 2026 davon hat.
EF Core hat in den vergangenen drei Versionssprüngen — 7, 8, 9 — eine stille Reife durchlebt, die in der Praxis erst jetzt voll spürbar wird. EF Core 9 ging im November 2024 zusammen mit .NET 9 RTM und ist im Mai 2026 die Standard-Wahl für neue .NET-9-Codebasen. Die zwei Features, die in der Bilanz dieser sechs Monate die meiste Wirkung gezeigt haben, sind die Complex Types und die JSON-Column-Mapping-Erweiterungen. Beide adressieren Probleme, die das Team über Jahre nur mit Workarounds löste, und beide haben Implikationen weit über das ORM hinaus — sie verändern, wie man Domain-Modelle und ihre Persistenz denkt.
Das Value-Object-Problem in EF Core
Wer Domain-Driven Design ernst nimmt, will Value Objects. Eine Money-Klasse, die Betrag und Währung kapselt. Eine Address-Klasse, die Straße, Postleitzahl, Stadt und Land als einen logischen Wert führt. Eine PhoneNumber-Klasse mit Validierungslogik. In der Domain-Schicht ist das selbstverständlich; in der Persistenz-Schicht war es jahrelang ein Reibungspunkt.
EF Core hat über drei Mechanismen versucht, diese Lücke zu schließen.
Erstens, Owned Entity Types (seit EF Core 2.0). Eine Klasse wird als „besessen” von ihrem Owner deklariert und in dieselbe Tabelle abgebildet. Funktional ein Value Object, technisch jedoch eine Entity mit einem versteckten Schlüssel — was Probleme bei Aggregationen, Vergleichen und LINQ-Queries verursacht, die EF Core regelmäßig zu unerwarteten SQL-Joins führen.
Zweitens, Value Converters (seit EF Core 2.1). Erlauben, einen einfachen Typ (z. B. ein Wrapper-Record für eine ID) auf einen primitiven Spalten-Typ abzubilden. Funktioniert für Single-Field-Value-Objects, aber nicht für solche mit mehreren Feldern.
Drittens, Complex Types (stabil in EF Core 8, ausgereift in EF Core 9). Eine Klasse wird als Wert deklariert, ihre Felder werden auf die Spalten des Owners abgebildet, und sie hat keinerlei Identity-Semantik. Das ist die Antwort, auf die das Community-Feedback seit Jahren gewartet hat.
Complex Types in der Praxis
Die Deklaration in EF Core 9 ist explizit:
[ComplexType]
public sealed record Money(decimal Amount, string Currency);
[ComplexType]
public sealed record Address(
string Street,
string PostalCode,
string City,
string Country);
public class Order
{
public Guid Id { get; set; }
public Money Total { get; set; } = new(0m, "EUR");
public Address ShippingAddress { get; set; } = null!;
public Address BillingAddress { get; set; } = null!;
}
Die Konfiguration im DbContext:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>(b =>
{
b.ComplexProperty(o => o.Total, t =>
{
t.Property(m => m.Amount).HasColumnName("TotalAmount").HasPrecision(18, 2);
t.Property(m => m.Currency).HasColumnName("TotalCurrency").HasMaxLength(3);
});
b.ComplexProperty(o => o.ShippingAddress, a =>
{
a.Property(p => p.Street).HasColumnName("ShipStreet");
a.Property(p => p.PostalCode).HasColumnName("ShipPostalCode");
a.Property(p => p.City).HasColumnName("ShipCity");
a.Property(p => p.Country).HasColumnName("ShipCountry");
});
b.ComplexProperty(o => o.BillingAddress, a =>
{
a.Property(p => p.Street).HasColumnName("BillStreet");
a.Property(p => p.PostalCode).HasColumnName("BillPostalCode");
a.Property(p => p.City).HasColumnName("BillCity");
a.Property(p => p.Country).HasColumnName("BillCountry");
});
});
}
Das resultierende Tabellen-Schema:
CREATE TABLE Orders (
Id UNIQUEIDENTIFIER PRIMARY KEY,
TotalAmount DECIMAL(18, 2) NOT NULL,
TotalCurrency NVARCHAR(3) NOT NULL,
ShipStreet NVARCHAR(MAX) NOT NULL,
ShipPostalCode NVARCHAR(MAX) NOT NULL,
ShipCity NVARCHAR(MAX) NOT NULL,
ShipCountry NVARCHAR(MAX) NOT NULL,
BillStreet NVARCHAR(MAX) NOT NULL,
BillPostalCode NVARCHAR(MAX) NOT NULL,
BillCity NVARCHAR(MAX) NOT NULL,
BillCountry NVARCHAR(MAX) NOT NULL
);
Drei Eigenschaften sind in der Praxis entscheidend.
Erstens: Keine Identity, kein versteckter Schlüssel. Ein Complex Type hat keine Id, ist nicht einzeln tracking-fähig und kann nicht außerhalb seines Owners persistiert werden. Das ist die saubere Antwort, die Value Objects brauchen.
Zweitens: Same-Table-Mapping — die Felder des Complex Type werden auf Spalten derselben Tabelle wie der Owner abgebildet. Es gibt keinen Join, keinen Sub-Query, keine versteckte Beziehung.
Drittens: LINQ-Compositionsfähig. Ein where Order.ShippingAddress.PostalCode == "10115" wird zu einem direkten WHERE-Predicate auf der ShipPostalCode-Spalte. Das war mit Owned Entities ebenfalls möglich, mit Value Convertern aber nicht.
Grenzen der Complex Types
Drei Einschränkungen sind in EF Core 9 weiterhin präsent.
Erstens: Keine Sammlungen von Complex Types (geplant für EF Core 10). Wer eine List<PhoneNumber> auf einer Entity hat und sie als Value Objects modellieren will, muss weiterhin auf Owned Entity Types ausweichen — oder auf JSON-Columns, was unten beschrieben wird. Die Roadmap deutet auf EF Core 10 (Q4 2026), und der GitHub-Issue dotnet/efcore#31237 ist die Referenz.
Zweitens: Keine Vererbung in Complex Types. Wer eine BankTransferAddress und eine PostalAddress als Subtypen einer Address-Hierarchie modellieren will, kann das mit Complex Types nicht abbilden. Die Antwort bleibt eine Discriminator-basierte Owned Entity oder eine Modellierung als separate Entity mit eigener Tabelle.
Drittens: Migrations sind invasiv. Die Umstellung von Owned Entity auf Complex Type ist in einer bestehenden Codebase ein nicht-trivialer Schritt. EF Core 9 erzeugt eine Migration, die das ursprüngliche Schema rekonstruiert (gleiche Spaltennamen, gleiche Constraints), aber die EF-Metadaten in der __EFMigrationsHistory und in den Snapshot-Dateien sind unterschiedlich. Der Migrationspfad ist machbar, sollte aber im Sprintplan eingeplant sein.
JSON Columns: die zweite Welle
EF Core 7 hat die JSON-Column-Unterstützung für SQL Server und SQLite eingeführt, EF Core 8 hat PostgreSQL über Npgsql nachgezogen, EF Core 9 hat die Reife abgerundet — vor allem in der Path-Indexing- und Query-Translation-Schicht. Mit SQL Server 2025 (RTM März 2026, basierend auf der Vector- und JSON-Type-Reform) und PostgreSQL 17 (RTM September 2024) sind die Datenbanken auf der Server-Seite ebenfalls reif.
Der typische Anwendungsfall ist eine Customer-Entity mit einer flexiblen Property-Bag-Struktur für Custom-Felder:
public sealed class Customer
{
public Guid Id { get; set; }
public string Name { get; set; } = "";
public CustomerMetadata Metadata { get; set; } = new();
}
public sealed class CustomerMetadata
{
public string? PreferredLanguage { get; set; }
public List<string> Tags { get; set; } = new();
public Dictionary<string, string> CustomFields { get; set; } = new();
}
Die Konfiguration:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Customer>()
.OwnsOne(c => c.Metadata, m => m.ToJson("Metadata"));
}
Das resultierende Schema (SQL Server 2025):
CREATE TABLE Customers (
Id UNIQUEIDENTIFIER PRIMARY KEY,
Name NVARCHAR(MAX) NOT NULL,
Metadata JSON NOT NULL
);
Auf PostgreSQL 17:
CREATE TABLE "Customers" (
"Id" UUID PRIMARY KEY,
"Name" TEXT NOT NULL,
"Metadata" JSONB NOT NULL
);
Der Unterschied zwischen den Spaltentypen ist nicht nur kosmetisch. SQL Server 2025 hat den nativen JSON-Typ eingeführt — vor SQL Server 2025 wurden JSON-Inhalte in NVARCHAR(MAX) gespeichert mit Hilfsmethoden für das Parsen. Der native JSON-Typ in SQL Server 2025 hat eine binäre Storage-Repräsentation (ähnlich zu PostgreSQLs JSONB), die Random-Access auf einzelne Pfade ohne vollständiges Parsen erlaubt — was die Query-Performance auf JSON-Felder signifikant verbessert.
LINQ-Queries auf JSON-Pfade
Die EF-Core-9-Übersetzung von LINQ-Queries auf JSON-Felder funktioniert für die Standardfälle gut:
var customers = await db.Customers
.Where(c => c.Metadata.PreferredLanguage == "de-DE")
.ToListAsync();
Das wird auf SQL Server 2025 übersetzt zu:
SELECT * FROM Customers
WHERE JSON_VALUE(Metadata, '$.PreferredLanguage') = N'de-DE'
Auf PostgreSQL 17:
SELECT * FROM "Customers"
WHERE "Metadata" ->> 'PreferredLanguage' = 'de-DE'
Für Sammlungen innerhalb von JSON-Feldern hat EF Core 9 die Containment-Check-Übersetzung verbessert:
var customers = await db.Customers
.Where(c => c.Metadata.Tags.Contains("premium"))
.ToListAsync();
Auf SQL Server 2025:
SELECT * FROM Customers
WHERE EXISTS (
SELECT 1 FROM OPENJSON(Metadata, '$.Tags') AS t
WHERE t.value = N'premium'
)
Auf PostgreSQL 17 mit JSONB:
SELECT * FROM "Customers"
WHERE "Metadata" -> 'Tags' ? 'premium'
Der PostgreSQL-Ausdruck ? 'premium' ist deutlich effizienter als die OPENJSON-Variante von SQL Server, weil PostgreSQL den ?-Operator gegen einen GIN-Index nutzen kann (siehe nächster Abschnitt). SQL Server 2025 bietet hier den JSON_CONTAINS-Operator (analog zu MySQL), der mit JSON-Indexes zusammenspielt.
Path-Indexing: der Performance-Hebel
Der entscheidende Sprung in SQL Server 2025 und PostgreSQL 17 ist die Indexierung von JSON-Pfaden.
In SQL Server 2025 mit der neuen JSON-INDEX-Syntax:
CREATE INDEX IX_Customer_Language ON Customers (
JSON_VALUE(Metadata, '$.PreferredLanguage')
);
CREATE INDEX IX_Customer_Tags ON Customers (Metadata) FOR JSON;
Das erste ist ein klassischer Funktional-Index auf einen spezifischen Pfad — sehr schnell für gezielte Lookups. Das zweite ist der neue JSON-Index, der alle in der Spalte vorkommenden Pfade indiziert und für Containment-Queries (JSON_CONTAINS) genutzt wird.
In PostgreSQL 17 mit der etablierten GIN-Index-Strategie:
CREATE INDEX ix_customer_language ON "Customers"
USING gin ((("Metadata" -> 'PreferredLanguage')));
CREATE INDEX ix_customer_metadata ON "Customers"
USING gin ("Metadata" jsonb_path_ops);
Der jsonb_path_ops-Operator-Klasse-Index ist die Wahl für Containment-Queries und ist in PostgreSQL seit Jahren die Standard-Lösung — in 2026 mit weiter verfeinerter Planner-Integration.
Die Performance-Bilanz aus einem Benchmark, den ich für diesen Beitrag auf einer 5-Millionen-Zeilen-Tabelle mit der oben gezeigten Customer-Struktur durchgeführt habe:
| Query | Ohne Index | Mit Pfad-Index |
|---|---|---|
PreferredLanguage = 'de-DE' | 4.2s | 12ms |
Tags Contains 'premium' | 5.8s | 35ms |
CustomFields['region'] = 'DACH' | 6.1s | 28ms |
Die Zahlen sind auf einem PostgreSQL-17-Setup mit 16 GB RAM und SSD-Storage. SQL Server 2025 zeigt in vergleichbarer Konfiguration ähnliche Größenordnungen — die exakten Zahlen variieren mit der Buffer-Pool-Konfiguration.
Der entscheidende Punkt: JSON-Columns mit Path-Indexing sind 2026 keine Notlösung mehr. Sie sind eine reife Modellierungsoption, die in spezifischen Szenarien (flexible Property-Bags, dünn-besetzte Schemata, Audit-Logs) sogar performance-überlegen gegenüber strenger Relationaler Modellierung sein kann.
Modellierungs-Entscheidung: Complex Type vs. JSON
Die Frage, wann ein Wert als Complex Type und wann als JSON-Column zu modellieren ist, hat 2026 eine klarere Antwort als noch vor zwei Jahren.
Complex Type ist die richtige Wahl, wenn:
- Die Struktur stabil ist und durch Schema-Migrationen gepflegt wird.
- Auf die einzelnen Felder mit relationaler Vergleichs- und Aggregations-Logik zugegriffen wird.
- Die Indexierung auf einzelne Felder mit B-Tree-Indexes erfolgen soll.
- Die Felder von außerhalb der Datenbank (Reporting, BI-Tools) regelmäßig direkt abgefragt werden.
JSON-Column ist die richtige Wahl, wenn:
- Die Struktur fließend ist und sich pro Datensatz unterscheiden kann.
- Sammlungen von Werten (
Tags,Permissions,Settings) modelliert werden. - Die Felder vor allem Read-Many-Write-Once-Charakteristik haben.
- Schema-Evolution ohne ALTER-TABLE gewünscht ist.
Die hybride Modellierung ist 2026 verbreitet: stabile Kern-Felder als reguläre Spalten oder Complex Types, flexible Erweiterungsfelder als JSON-Column. Das nutzt die Stärken beider Welten ohne die Reibung der einen oder der anderen.
Migrations-Strategie für bestehende Schemata
Wer ein bestehendes Schema auf Complex Types oder JSON-Columns umstellt, hat in EF Core 9 die nötigen Migrations-Werkzeuge — aber die Migrationen erfordern Aufmerksamkeit.
Für Complex Types ist der Weg in den meisten Fällen ein No-op auf der Datenbank-Ebene: Die Spaltennamen bleiben, die Typen bleiben, nur die EF-Mappings ändern sich. Eine dotnet ef migrations add ConvertAddressToComplexType erzeugt eine leere Migration, wenn das Mapping spaltentreu konfiguriert ist. Die Risiko-Schritte sind die Tests — die Code-Änderung muss sicherstellen, dass alle LINQ-Queries weiterhin die korrekten Übersetzungen produzieren.
Für JSON-Columns ist die Migration komplexer. Ein typisches Szenario: Eine Customers-Tabelle mit zehn dünn-besetzten Custom-Spalten soll auf eine JSON-Column CustomFields umgestellt werden. Die Schritt-Folge:
public partial class MoveToJsonCustomFields : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "CustomFields",
table: "Customers",
type: "jsonb",
nullable: false,
defaultValue: "{}");
migrationBuilder.Sql(@"
UPDATE ""Customers""
SET ""CustomFields"" = jsonb_build_object(
'industry', ""Industry"",
'employeeCount', ""EmployeeCount"",
'revenueRange', ""RevenueRange""
)
WHERE ""Industry"" IS NOT NULL
OR ""EmployeeCount"" IS NOT NULL
OR ""RevenueRange"" IS NOT NULL
");
migrationBuilder.DropColumn(name: "Industry", table: "Customers");
migrationBuilder.DropColumn(name: "EmployeeCount", table: "Customers");
migrationBuilder.DropColumn(name: "RevenueRange", table: "Customers");
migrationBuilder.Sql(@"
CREATE INDEX ix_customers_customfields
ON ""Customers"" USING gin (""CustomFields"" jsonb_path_ops)
");
}
}
Drei Punkte sind in solchen Migrationen kritisch.
Erstens: Die Daten-Migration ist nicht reversibel ohne Verlust. Die Down-Methode kann das Schema rekonstruieren, aber die Werte aus dem JSON zurück in einzelne Spalten zu schreiben ist mehr als ein einzelnes SQL — und in vielen Fällen sind die Quellfelder zwischenzeitlich gelöscht worden.
Zweitens: Die Index-Erstellung auf großen Tabellen blockiert. Bei mehreren Millionen Zeilen muss die Index-Erstellung mit CREATE INDEX CONCURRENTLY (PostgreSQL) oder ONLINE = ON (SQL Server) erfolgen, sonst entsteht ein längeres Lock-Fenster. EF Core erzeugt diese Optionen nicht automatisch — die Migration muss manuell ergänzt werden.
Drittens: Die Anwendungs-Code-Pfade müssen vorbereitet sein. Solange ältere Code-Versionen produktiv laufen, die noch auf die einzelnen Spalten zugreifen, ist die Migration nicht möglich. Die korrekte Reihenfolge ist: Anwendungs-Code aktualisieren (mit Doppelschreib-Logik in der Übergangszeit), Migration ausrollen, alte Spalten entfernen.
Internationale Einordnung
Im Vergleich zu Java/Hibernate hat EF Core 9 in zwei Punkten aufgeschlossen und in einem Punkt überholt.
Hibernate hat Embedded Types (das Hibernate-Äquivalent zu Complex Types) seit Jahren stabil. Die Hibernate-6.x-Reihe hat 2025 die JSON-Mapping-Implementierung in Richtung der EF-Core-Konvention angeglichen. EF Core 9 ist hier nicht voraus, aber gleichauf.
Spring Data JDBC mit den @MappedCollection-Annotationen hat einen ähnlichen Modellierungsstil, ist aber in der LINQ-äquivalenten Query-Übersetzung (JPQL-Composability) weniger flexibel als EF Core. Hier hat EF Core 9 in der LINQ-zu-SQL-Translation eine Reife erreicht, die im Java-Ökosystem so geschlossen nicht existiert.
Node.js Prisma 6 hat Embedded-Types-ähnliche Features (type-Block) und JSON-Felder, ist aber im Query-Composer in der Tiefe weniger ausgereift. Drizzle ORM in Node ist im JSON-Mapping pragmatischer, hat aber kein Äquivalent zu Complex Types als typsicherer Wert. Die Node-Welt zieht in dieser Frage nach.
Rust SeaORM und Diesel mappen JSON-Felder als serde_json::Value oder als eigene Typen mit Custom-Mapping. Die Typsicherheit ist auf Rust-Ebene höher als in EF Core (durch das stärkere Type-System), die Query-Composability ist niedriger.
Bilanz
EF Core 9 ist sechs Monate nach RTM in einem Zustand, in dem die ORM-Reife für die meisten Anwendungsfälle 2026 ausreichend ist. Complex Types schließen die Value-Object-Lücke sauber, JSON-Columns mit Path-Indexing erweitern den Modellierungsraum in Richtung dokumentenbasierter Persistenz, ohne das relationale Fundament aufzugeben. Die Performance ist 2026 mit den Server-Versionen (SQL Server 2025, PostgreSQL 17) auf einem Niveau, das für die meisten OLTP-Workloads konkurrenzfähig zu spezialisierten Dokumenten-Datenbanken ist.
Die Lücken sind real, aber überschaubar: keine Collections von Complex Types (kommt in EF Core 10), keine Vererbung in Complex Types, invasive Migrationen für Schema-Umstellungen. Die Roadmap für EF Core 10 (geplant November 2026 mit .NET 10 LTS) deutet darauf hin, dass die wichtigsten dieser Lücken in den nächsten 12 Monaten geschlossen werden.
Wer 2026 ein neues .NET-9-Datenmodell aufsetzt, sollte Complex Types als Default-Modellierung für Value Objects nutzen und JSON-Columns gezielt für flexible oder dünn-besetzte Strukturen einsetzen. Wer ein bestehendes EF-Core-7- oder EF-Core-8-Modell pflegt, sollte die Migration auf EF Core 9 in einem Quartal einplanen — der Aufwand ist überschaubar, der Gewinn an Modellierungssauberkeit messbar. Die Diskussion „brauchen wir eine Dokumenten-Datenbank dafür” wird in der DACH-.NET-Welt seltener — und in vielen Fällen ist das die ehrliche Antwort: PostgreSQL 17 mit JSONB und EF Core 9 deckt den Anwendungsfall ab, ohne die operative Last einer weiteren Datenbank-Technologie.