Mert Özen Her Satırda Daha İleri
Veritabanı Entity Framework Core — #20

EF Core'da Owned Type ve Value Converter: Adres, Enum ve Strongly Typed Id

Mert Özen 5 Kas 2026 11 dk 3 görüntülenme
EF Core'da Owned Type ve Value Converter: Adres, Enum ve Strongly Typed Id

Kimliği olmayan değerleri OwnsOne ve OwnsMany ile sahibine bağlıyor, enum'u metin olarak saklıyor, Id'leri kendi tiplerine sarıyoruz. Value comparer olmadan kaybolan değişikliği ve SQLite'a özgü OwnsMany tuzağını gerçek çıktılarla görüyoruz.

Sürüm notu: Bu yazıdaki kodlar EF Core 6 ile yazılmıştır ve EF Core 10'da da değişmeden çalışır.

Önce Tahmin Et: Adresin Id'si Yoksa Ne Olur?

Siparişe bir teslimat adresi ekliyoruz. Adres sokak, şehir ve posta kodundan oluşuyor. Kendi başına bir anlamı yok; "3 numaralı adres" diye bir şey konuşmuyoruz, sadece "bu siparişin adresi" diyoruz. Bu yüzden ona bir Id vermiyoruz:

public class Address
{
    public string Street { get; set; } = "";
    public string City { get; set; } = "";
    public string PostalCode { get; set; } = "";
}

public class Order
{
    public int Id { get; set; }
    public DateTime OrderedAt { get; set; }
    public Address ShippingAddress { get; set; } = new();
}

Context'te sadece DbSet<Order> Orders var, OnModelCreating boş. EnsureCreated çağırınca ne olur? Adres kolonları Orders tablosuna mı eklenir, ayrı bir Addresses tablosu mu açılır? İkisi de değil:

System.InvalidOperationException: The entity type 'Address' requires a primary key to be defined. If you intended to use a keyless entity type, call 'HasNoKey' in 'OnModelCreating'. For more information on keyless entity types, see https://go.microsoft.com/fwlink/?linkid=2141943.

EF, Order'daki navigation'ı gördü ve Address'i ayrı bir entity sandı. Entity'nin de bir key'i olmak zorunda. Oysa biz EF'e "bu bir entity değil, siparişin bir parçası" demek istiyorduk. Bunun adı owned type. Bu yazıda owned type'ları ve onların yakın akrabası value converter'ları göreceğiz: bir değerin veritabanında nasıl duracağını senin belirlemen.

OwnsOne: Sahibinin Tablosunda Yaşayan Değer

Adresi siparişe bağlıyoruz. İkinci bir fatura adresini de opsiyonel olarak ekleyelim:

public class Order
{
    public int Id { get; set; }
    public DateTime OrderedAt { get; set; }
    public Address ShippingAddress { get; set; } = new();
    public Address? BillingAddress { get; set; }
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Order>().OwnsOne(o => o.ShippingAddress, a =>
    {
        a.Property(x => x.Street).HasMaxLength(200);
        a.Property(x => x.City).HasColumnName("ShipCity");
    });
    modelBuilder.Entity<Order>().OwnsOne(o => o.BillingAddress);
}

OwnsOne EF'e "Address siparişe ait, onun yaşam döngüsünü paylaşıyor" diyor. İkinci parametredeki lambda'da owned type'ın property'lerini tıpkı bir entity'ninki gibi yapılandırabiliyorsun. Fluent API yerine Address class'ının üstüne [Owned] attribute'u koymak da aynı işi görür. Oluşan tablo:

CREATE TABLE "Orders" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Orders" PRIMARY KEY AUTOINCREMENT,
    "OrderedAt" TEXT NOT NULL,
    "ShippingAddress_Street" TEXT NOT NULL,
    "ShipCity" TEXT NOT NULL,
    "ShippingAddress_PostalCode" TEXT NOT NULL,
    "BillingAddress_Street" TEXT NULL,
    "BillingAddress_City" TEXT NULL,
    "BillingAddress_PostalCode" TEXT NULL
);

Ayrı tablo yok. Adresin property'leri Orders tablosuna NavigationName_PropertyName kalıbıyla kolon olarak eklendi; City'ye verdiğimiz ShipCity adı da tuttu. BillingAddress nullable olduğu için onun kolonları NULL kabul ediyor. HasMaxLength(200) SQLite'ta DDL'e yansımıyor, çünkü SQLite'ta her metin TEXT; SQL Server'da aynı ayar nvarchar(200) olurdu. Aynı Address class'ını iki farklı navigation için kullanabildiğimize dikkat et. Entity olsaydı bu iki ayrı ilişki demekti; owned type'ta sadece iki ayrı kolon grubu.

Bir apartman dairesinin balkonunu düşün. Balkonun kendi tapusu yoktur, dairenin tapusuna dahildir. Daireyi satarsan balkon da gider; balkonu tek başına satamazsın. Owned type da böyle: kendi kimliği yok, sahibinin kimliğiyle var.

Owned Type ile Çalışmak

Kaydedip okuyalım:

db.Orders.Add(new Order
{
    OrderedAt = new DateTime(2026, 11, 4),
    ShippingAddress = new Address { Street = "221B Baker Street", City = "London", PostalCode = "NW1 6XE" }
});
db.SaveChanges();
db.ChangeTracker.Clear();

var order = db.Orders.Single();
Console.WriteLine($"{order.ShippingAddress.City} / billing: {(order.BillingAddress == null ? "null" : "set")}");

Çıktı London / billing: null. INSERT tek komut ve adres kolonlarını içeriyor: INSERT INTO "Orders" ("OrderedAt", "ShipCity", "ShippingAddress_PostalCode", "ShippingAddress_Street"). Okurken Include yazmadık ama adres geldi. Owned type'lar sahibiyle birlikte her zaman yüklenir; SELECT listesinde bütün adres kolonları var. Fatura adresinin bütün kolonları NULL olduğu için EF BillingAddress'i null bıraktı.

Owned type'ın property'leri üzerinden sorgu da yazabilirsin:

var londonOrders = db.Orders.Where(o => o.ShippingAddress.City == "London").ToList();
SELECT "o"."Id", "o"."OrderedAt", "o"."BillingAddress_City", "o"."BillingAddress_PostalCode", "o"."BillingAddress_Street", "o"."ShipCity", "o"."ShippingAddress_PostalCode", "o"."ShippingAddress_Street"
FROM "Orders" AS "o"
WHERE "o"."ShipCity" = 'London'

Adresi değiştirmenin doğal yolu, value object mantığıyla yeni bir nesne atamak:

order.ShippingAddress = new Address { Street = "10 Downing Street", City = "London", PostalCode = "SW1A 2AA" };
db.SaveChanges();

EF yeni nesneyi eskisinin yerine koyuyor ve sadece değişen kolonları güncelliyor. Şehir aynı kaldığı için SET listesinde yok:

UPDATE "Orders" SET "ShippingAddress_PostalCode" = @p0, "ShippingAddress_Street" = @p1
WHERE "Id" = @p2
RETURNING 1;

EF Core 6 burada RETURNING 1 yerine SELECT changes(); gönderiyor. Owned type'ın kendi DbSet'i olmaz; db.Set<Address>() yazarsan Cannot create a DbSet for 'Address' because it is configured as a shared-type entity type. diye başlayan bir exception alırsın. Adrese her zaman sahibinin üzerinden ulaşırsın.

Bir not: EF Core 8 ile complex type'lar geldi ve value object'ler için owned type'tan daha uygun bir araç oldu. EF Core 10 dokümantasyonu da tablo içine gömülü değerlerde owned yerine complex type'ı öneriyor. Sürüm yazılarında ikisini karşılaştıracağız; EF Core 6'da elimizdeki araç owned type.

OwnsMany: Kendi Tablosu Olan Koleksiyon

Bir yazarın birden fazla sosyal medya bağlantısı olsun. Bunlar da yazara ait, kendi başlarına anlamları yok:

public class Author
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public List<SocialLink> Links { get; set; } = new();
}

public class SocialLink
{
    public string Platform { get; set; } = "";
    public string Url { get; set; } = "";
}

Koleksiyon bir satırın kolonlarına sığmaz, o yüzden OwnsMany ayrı bir tablo açar:

modelBuilder.Entity<Author>().OwnsMany(a => a.Links, l =>
{
    l.ToTable("AuthorLinks");
    l.WithOwner().HasForeignKey("AuthorId");
    l.Property<int>("Id");
    l.HasKey("Id");
});
CREATE TABLE "AuthorLinks" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_AuthorLinks" PRIMARY KEY AUTOINCREMENT,
    "Platform" TEXT NOT NULL,
    "Url" TEXT NOT NULL,
    "AuthorId" INTEGER NOT NULL,
    CONSTRAINT "FK_AuthorLinks_Authors_AuthorId" FOREIGN KEY ("AuthorId") REFERENCES "Authors" ("Id") ON DELETE CASCADE
);

CREATE INDEX "IX_AuthorLinks_AuthorId" ON "AuthorLinks" ("AuthorId");

SocialLink class'ında ne Id ne de foreign key var; ikisi de shadow property. Son iki satır neden var, birazdan tuzaklarda göreceğiz. Yazarı okuduğunda bağlantılar yine kendiliğinden geliyor, bu sefer bir JOIN ile (EF Core 10, db.Authors.Single()):

SELECT "a1"."Id", "a1"."Name", "a0"."Id", "a0"."AuthorId", "a0"."Platform", "a0"."Url"
FROM (
    SELECT "a"."Id", "a"."Name"
    FROM "Authors" AS "a"
    LIMIT 2
) AS "a1"
LEFT JOIN "AuthorLinks" AS "a0" ON "a1"."Id" = "a0"."AuthorId"
ORDER BY "a1"."Id"

EF Core 6 aynı sorguda alt sorgu için "a1" yerine "t" takma adını kullanıyor. Yazarı silersen cascade ile bağlantıları da gider; bu bir one-to-many ilişki ama yaşam döngüsü tamamen sahibine bağlı.

Value Converter: Enum'u Metin Olarak Saklamak

Owned type bir değeri birden fazla kolona yayıyordu. Value converter tersini yapar: tek bir property'nin veritabanında hangi tipte ve nasıl duracağını belirler. En sık örnek sipariş durumu:

public enum OrderStatus
{
    Pending,
    Paid,
    Shipped,
    Cancelled
}

Varsayılan olarak EF enum'u sayı olarak saklar. Tabloda 2 görmek, birinin enum'un ortasına yeni bir değer eklediği gün bütün kayıtların anlam değiştirmesi demek. Metin olarak saklamak için tek property'de HasConversion<string>() yeterli; bunu bir önceki yazıda discriminator için kullanmıştık. Projedeki her OrderStatus için tek tek yazmak istemiyorsan EF Core 6'nın pre-convention configuration'ını kullan:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Properties<OrderStatus>().HaveConversion<string>();
}

Artık bu tipteki her property TEXT kolon. Sorguda enum karşılaştırması da doğrudan metne çevriliyor:

var shipped = db.Orders.Where(o => o.Status == OrderStatus.Shipped).ToList();
SELECT "o"."Id", "o"."FeaturedBookId", "o"."Status"
FROM "Orders" AS "o"
WHERE "o"."Status" = 'Shipped'

Bedeli biraz daha fazla depolama ve bir enum değerinin adını değiştirdiğinde eski kayıtların okunamaz hâle gelmesi. Adı değiştirmek, artık bir veri migration'ı demek.

Strongly Typed Id: Karışmayan Kimlikler

int bookId ile int orderId derleyicinin gözünde aynı şey. Birini diğerinin yerine geçirirsen kod derlenir ve yanlış kaydı getirir. Bunu önlemenin yolu kimliği kendi tipine sarmak:

public readonly record struct BookId(int Value);

public class Book
{
    public BookId Id { get; set; }
    public string Title { get; set; } = "";
    public List<string> Keywords { get; set; } = new();
}

public class Order
{
    public int Id { get; set; }
    public OrderStatus Status { get; set; }
    public BookId FeaturedBookId { get; set; }
}
modelBuilder.Entity<Book>()
    .Property(b => b.Id)
    .HasConversion(id => id.Value, value => new BookId(value));

modelBuilder.Entity<Order>()
    .Property(o => o.FeaturedBookId)
    .HasConversion(id => id.Value, value => new BookId(value));

Converter iki ifade alıyor: C#'tan veritabanına, veritabanından C#'a. Veritabanında INTEGER kolon görüyorsun, kodda BookId. db.Books.Single(b => b.Id == new BookId(1)) sorgusu da WHERE "b"."Id" = 1 olarak çevriliyor. record struct kullanmamızın sebebi eşitliği bedavaya almak; key olacak bir tip değer semantiğiyle karşılaştırılmalı.

Bir farkı göz ardı etme. DDL'e bakarsan Books.Id kolonunda AUTOINCREMENT yok: "Id" INTEGER NOT NULL CONSTRAINT "PK_Books" PRIMARY KEY. Converter'lı bir key, EF Core 6'da convention ile otomatik üretilen key olmuyor; değeri sen vermelisin, örneğin new Book { Id = new BookId(1), ... }. EF Core 7 converter'lı key'lerde ValueGeneratedOnAdd() ile otomatik değer üretimini destekledi; sürüm yazılarında göreceğiz. EF Core 6'da bu tipi Guid tabanlı yapıp değeri uygulamada üretmek pratik bir alternatif.

Value Comparer: EF Değişikliği Nasıl Fark Eder?

Book.Keywords bir List<string>. EF Core 6 bunu doğrudan eşleyemez, o yüzden virgülle ayrılmış tek bir metne çeviriyoruz:

modelBuilder.Entity<Book>()
    .Property(b => b.Keywords)
    .HasConversion(
        list => string.Join(',', list),
        text => text.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList());

Model kurulurken EF bir uyarı yazıyor:

warn: 4.11.2026 00:52:12.357 CoreEventId.CollectionWithoutComparer[10620] (Microsoft.EntityFrameworkCore.Model.Validation)
      The property 'Book.Keywords' is a collection or enumeration type with a value converter but with no value comparer. Set a value comparer to ensure the collection/enumeration elements are compared correctly.

Uyarının ne demek olduğunu görelim. Kitabı okuyup listeye bir kelime ekliyoruz:

var dune = db.Books.Single(b => b.Id == new BookId(1));
dune.Keywords.Add("desert");
Console.WriteLine(db.Entry(dune).State);
db.SaveChanges();

Konsolda Unchanged yazıyor ve SaveChanges hiçbir UPDATE göndermiyor. Değişiklik kayboldu. Sebebi change tracking'in çalışma şekli: EF, okuma anında property'nin bir snapshot'ını alır ve DetectChanges sırasında güncel değerle karşılaştırır. Varsayılan karşılaştırma referans eşitliği. Listeye eleman eklemek referansı değiştirmediği için EF iki değeri eşit sanıyor. Snapshot da aynı listenin kendisi olduğu için onu da değiştirmiş oluyorsun.

Çözüm, EF'e bu tipi nasıl karşılaştıracağını, hash'ini nasıl hesaplayacağını ve snapshot'ını nasıl alacağını anlatan bir value comparer:

using Microsoft.EntityFrameworkCore.ChangeTracking;

modelBuilder.Entity<Book>()
    .Property(b => b.Keywords)
    .HasConversion(
        list => string.Join(',', list),
        text => text.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList(),
        new ValueComparer<List<string>>(
            (a, b) => a!.SequenceEqual(b!),
            list => list.Aggregate(0, (hash, s) => HashCode.Combine(hash, s.GetHashCode())),
            list => list.ToList()));

Üçüncü ifade asıl kahraman: snapshot artık listenin bir kopyası. Aynı kodu tekrar çalıştırınca uyarı kayboluyor, state Modified oluyor ve UPDATE gidiyor:

UPDATE "Books" SET "Keywords" = @p0
WHERE "Id" = @p1
RETURNING 1;

Kitabı tekrar okuduğunda sci-fi|classic|desert geliyor. Kural basit: değiştirilebilir (mutable) bir tipe converter yazıyorsan, comparer'ı da yaz. EF Core 8'deki primitive collection'lar List<string> gibi tipleri converter'sız eşleyebiliyor; o konu da sürüm yazılarında.

Sık Düşülen Tuzaklar

Birinci tuzak SQLite'a özgü ve can sıkıcı. OwnsMany'yi son iki satır olmadan, yani sadece ToTable ve WithOwner ile yazarsan EF, owned tablonun key'ini ("AuthorId", "Id") diye composite olarak kurar. SQLite composite key'in bir parçasını otomatik artıramaz ve ilk SaveChanges şöyle patlar: SQLite Error 19: 'NOT NULL constraint failed: AuthorLinks.Id'. l.Property<int>("Id") ve l.HasKey("Id") ile tek kolonlu bir key verince sorun çözülüyor. EF dokümantasyonu da varsayılan key'in composite olduğunu söylüyor ve OwnsMany örneğinde key'i bu şekilde açıkça tanımlıyor.

İkinci tuzak, aynı owned nesneyi iki sahibe vermek:

var shared = new Address { Street = "1 Main St", City = "Izmir", PostalCode = "35000" };
db.Orders.Add(new Order { OrderedAt = DateTime.Today, ShippingAddress = shared });
db.Orders.Add(new Order { OrderedAt = DateTime.Today, ShippingAddress = shared });
db.SaveChanges();

Owned nesnenin kimliği sahibinden gelir; aynı instance aynı anda iki siparişe ait olamaz. EF nesneyi tek bir sahiple takip ediyor, diğer siparişin adres kolonları boş kalıyor ve kayıt SQLite Error 19: 'NOT NULL constraint failed: Orders.ShippingAddress_Street'. ile reddediliyor. Adres opsiyonel olsaydı hata bile almaz, bir siparişin adresini sessizce kaybederdin. Her sahibe kendi kopyasını ver.

Üçüncü tuzak, opsiyonel owned type'ın bütün kolonlarının NULL olması. EF bunu "adres yok" diye yorumlar. Bütün alanları boş ama var olan bir adres kavramın varsa, bu durumu NULL ile ifade edemezsin. Dördüncüsü de converter'ın sorgulardaki etkisi: converter C# tarafında çalışır, veritabanı yalnız dönüştürülmüş değeri görür. Keywords virgüllü bir metin olduğu için db.Books.Where(b => b.Keywords.Contains("classic")) sorgusu SQL'e çevrilemez; EF Core 6 ve 10'da could not be translated diyen bir InvalidOperationException alırsın. Böyle bir ihtiyacın varsa veri ayrı bir tabloyu hak ediyordur.

Küçük Bir Deneme

★☆☆ Order'a OrderStatus Status ekle, ConfigureConventions ile metne çevir ve tabloya bir Pending sipariş yaz. Sonra converter satırını sil, veritabanını yeniden oluştur ve aynı kaydın kolonda nasıl göründüğüne bak.

★★☆ OwnsMany'yi HasKey("Id") satırı olmadan çalıştır, GenerateCreateScript() çıktısındaki primary key'i ve SaveChanges hatasını kendi gözünle gör. Ardından aynı modeli UseSqlite yerine SQL Server'la denemek istersen bunun için Docker'da bir SQL Server gerekir; orada davranışın farklı olup olmadığını karşılaştır.

★★★ Keywords için yazdığımız comparer'dan snapshot ifadesini çıkarıp list => list yap. State'in yine Unchanged kaldığını gör ve bunun sebebini, snapshot ile güncel değerin aynı nesne olmasıyla açıkla.

Bu yazıda verinin veritabanına giderken nasıl şekil değiştireceğini belirledik. Peki SaveChanges çağrıldığı anda araya girip her kayda otomatik olarak CreatedAt yazmak, yavaş sorguları loglamak ya da giden SQL'e dokunmak istersen? Bir sonraki yazıda interceptor'lara ve EF Core'un event'lerine bakacağız.