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

EF Core'da Model ve Convention'lar: Key, Nullable, Kolon Tipleri ve Shadow Property

Mert Özen 5 Kas 2026 9 dk 2 görüntülenme
EF Core'da Model ve Convention'lar: Key, Nullable, Kolon Tipleri ve Shadow Property

EF Core'un modeli nasıl kurduğunu gerçek şema çıktılarıyla okuyoruz: key convention'ı, NRT ile NOT NULL ve NULL kolonlar, SQLite ve SQL Server kolon tipleri, decimal uyarısı, geçici key ve Guid üretimi, tablo adlarının nereden geldiği ve kendiliğinden oluşan shadow foreign key.

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

Elimizde üç class ve iki DbSet'i olan bir context var. Hiçbir attribute yok, OnModelCreating yok:

public class Author
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
}

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public string? Subtitle { get; set; }
    public decimal Price { get; set; }
    public int? PageCount { get; set; }
    public DateTime PublishedOn { get; set; }
    public bool IsAvailable { get; set; }
    public Author? Author { get; set; }

    public string DisplayTitle => Subtitle is null ? Title : $"{Title}: {Subtitle}";
}

public class Tag
{
    public Guid Id { get; set; }
    public string Name { get; set; } = "";
}

public class BookstoreContext : DbContext
{
    public DbSet<Book> Books => Set<Book>();
    public DbSet<Tag> Tags => Set<Tag>();

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder
            .UseSqlite("Data Source=bookstore.db")
            .LogTo(Console.WriteLine, new[] { DbLoggerCategory.Database.Command.Name }, LogLevel.Information);
    }
}

EnsureCreated çağırınca kaç tablo oluşur? Author için bir DbSet yok, yine de tablosu olacak mı, olacaksa adı ne olacak? Books tablosunda hangi kolonlar NULL kabul eder? DisplayTitle bir kolon olur mu? Ve Book'ta hiç yazmadığımız bir kolon tabloda belirir mi?

Model Nereden Geliyor?

EF Core bir context'i ilk kullandığında modeli kurar: entity'lerin, property'lerin, key'lerin ve ilişkilerin tam haritası. İşe DbSet property'lerinden başlar, oradan navigation'ları takip ederek ulaşabildiği her class'ı keşfeder. Sonra her birine convention'ları uygular. Convention, "sen aksini söylemedikçe böyle kabul ediyorum" diyen bir kuraldır. Bu yazıda yalnızca convention'lara güveniyoruz; aksini söylemenin yollarını bir sonraki yazıda göreceğiz. Kurulan model, aynı context tipi için bir kez oluşturulup önbellekte tutulur.

Modelin tamamını görmek için db.Model.ToDebugString() yeterli. EF Core 10 çıktısı şöyle:

Model:
  EntityType: Author
    Properties:
      Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      Name (string) Required
    Keys:
      Id PK
  EntityType: Book
    Properties:
      Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      AuthorId (no field, int?) Shadow FK Index
      IsAvailable (bool) Required
      PageCount (int?)
      Price (decimal) Required
      PublishedOn (DateTime) Required
      Subtitle (string)
      Title (string) Required
    Navigations:
      Author (Author) Optional ToPrincipal Author
    Keys:
      Id PK
    Foreign keys:
      Book {'AuthorId'} -> Author {'Id'} ClientSetNull ToPrincipal: Author
    Indexes:
      AuthorId
  EntityType: Tag
    Properties:
      Id (Guid) Required PK AfterSave:Throw ValueGenerated.OnAdd
      Name (string) Required
    Keys:
      Id PK

EF Core 6 çıktısı neredeyse aynı; tek fark navigation satırında Optional kelimesinin yazılmaması ve foreign key satırındaki sıralama. Sorulardan birinin cevabı burada bile görünüyor: Author modele girdi, çünkü Book.Author navigation'ı onu işaret ediyor. DisplayTitle ise listede yok.

Veritabanı tarafında oluşan şema da şu:

CREATE TABLE "Author" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Author" PRIMARY KEY AUTOINCREMENT,
    "Name" TEXT NOT NULL
);
CREATE TABLE "Tags" (
    "Id" TEXT NOT NULL CONSTRAINT "PK_Tags" PRIMARY KEY,
    "Name" TEXT NOT NULL
);
CREATE TABLE "Books" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Books" PRIMARY KEY AUTOINCREMENT,
    "Title" TEXT NOT NULL,
    "Subtitle" TEXT NULL,
    "Price" TEXT NOT NULL,
    "PageCount" INTEGER NULL,
    "PublishedOn" TEXT NOT NULL,
    "IsAvailable" INTEGER NOT NULL,
    "AuthorId" INTEGER NULL,
    CONSTRAINT "FK_Books_Author_AuthorId" FOREIGN KEY ("AuthorId") REFERENCES "Author" ("Id")
);
CREATE INDEX "IX_Books_AuthorId" ON "Books" ("AuthorId");

Bunlar log'daki komutların SQL kısmı; EF Core 6 ve 10 aynı şemayı üretti. Şimdi bu çıktıyı convention convention okuyalım.

Key Convention'ı

Adı Id ya da <EntityName>Id olan property primary key olur. Book için Id da BookId da işe yarar. Key int ya da long ise EF Core onu "eklenirken üretilir" (ValueGenerated.OnAdd) olarak işaretler ve SQLite'ta AUTOINCREMENT kolonu olur. SQL Server'da karşılığı IDENTITY.

Key bulamazsa EF Core tahmin yürütmez, modeli kurmayı reddeder. ReviewNumber adında bir property'si olan Review class'ını DbSet olarak eklediğimizde şu hatayı aldık:

InvalidOperationException: The entity type 'Review' 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.

Ya property'nin adını convention'a uydurursun ya da key'i açıkça söylersin. Key'siz entity'ler (view ve rapor sonuçları için) raw SQL yazısının konusu.

NRT ile Required ve Optional

.NET 6 template'leri <Nullable>enable</Nullable> ile geliyor ve EF Core bu bilgiyi kullanıyor. string Title NOT NULL oldu, string? Subtitle NULL. Value type'larda kural zaten C#'ın kendisinden geliyor: int, decimal, bool, DateTime NOT NULL; int? NULL.

Nullable reference types kapalı olsaydı ne olurdu? Aynı class'ı #nullable disable bölgesine aldığımızda şunu gördük:

#nullable disable
public class LegacyBook
{
    public int LegacyBookId { get; set; }
    public string Title { get; set; }
}
#nullable enable
CREATE TABLE "LegacyBooks" (
    "LegacyBookId" INTEGER NOT NULL CONSTRAINT "PK_LegacyBooks" PRIMARY KEY AUTOINCREMENT,
    "Title" TEXT NULL
);

Title NULL kabul eden bir kolona dönüştü, çünkü NRT kapalıyken string her zaman null olabilir. Eski bir projeyi .NET 6'ya taşıyıp Nullable'ı açtığında bunun tersi yaşanır: bir sonraki migration bir sürü kolonu NOT NULL'a çevirmeye çalışır. LegacyBookId da <EntityName>Id kuralıyla key oldu.

Not: NOT NULL bir veritabanı kuralıdır. Title'a null! atayıp kaydetmeye çalıştığında EF Core seni SaveChanges öncesinde durdurmaz; hatayı veritabanından alırsın: SQLite Error 19: 'NOT NULL constraint failed: Books.Title'.

Kolon Tipleri ve String Uzunluğu

SQLite'ın tip sistemi çok küçük: INTEGER, REAL, TEXT, BLOB. O yüzden yukarıdaki şemada int ve bool INTEGER, string, decimal, DateTime ve Guid TEXT oldu. Uzunluk bilgisi de yok; SQLite'ta TEXT'in sınırı yok.

Aynı modelin SQL Server'da neye dönüştüğünü görmek için bağlantı açmadan şemayı üreten GenerateCreateScript'i kullanabiliriz. Bunun için UseSqlServer ile ayarlanmış ikinci bir context yazdık ve Title'a bir sonraki yazının konusu olan [MaxLength(200)] attribute'unu ekledik:

using SqlServerBookstoreContext db = new();
Console.WriteLine(db.Database.GenerateCreateScript());
warn: 11/4/2026 00:50:20.296 SqlServerEventId.DecimalTypeDefaultWarning[30000] (Microsoft.EntityFrameworkCore.Model.Validation)
      No store type was specified for the decimal property 'Price' on entity type 'Book'. This will cause values to be silently truncated if they do not fit in the default precision and scale. Explicitly specify the SQL server column type that can accommodate all the values in 'OnModelCreating' using 'HasColumnType', specify precision and scale using 'HasPrecision', or configure a value converter using 'HasConversion'.
CREATE TABLE [Books] (
    [Id] int NOT NULL IDENTITY,
    [Title] nvarchar(200) NOT NULL,
    [Subtitle] nvarchar(max) NULL,
    [Price] decimal(18,2) NOT NULL,
    [PageCount] int NULL,
    [PublishedOn] datetime2 NOT NULL,
    [IsAvailable] bit NOT NULL,
    [AuthorId] int NULL,
    CONSTRAINT [PK_Books] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_Books_Author_AuthorId] FOREIGN KEY ([AuthorId]) REFERENCES [Author] ([Id])
);

Uzunluk verilmeyen string'ler nvarchar(max) oldu, [MaxLength(200)] verdiğimiz Title ise nvarchar(200). decimal için varsayılan decimal(18,2) seçildi ve EF Core bunun için bir uyarı bastı: değer bu hassasiyete sığmazsa sessizce kırpılır. SQLite'ta aynı model için hiçbir uyarı yok, çünkü orada decimal zaten TEXT olarak saklanıyor. Hangi provider'ı kullanırsan kullan, para tutan her decimal'a hassasiyetini açıkça vermeyi alışkanlık edin; nasıl yapılacağını bir sonraki yazıda göreceğiz.

Generated Values ve Geçici Key

Key'i veritabanı üretiyorsa, entity eklendiği anda ama henüz kaydedilmeden önce key'in değeri nedir?

Author author = new() { Name = "Martin Fowler" };
Book book = new() { Title = "Refactoring", Price = 42.50m, Author = author };
db.Books.Add(book);

Console.WriteLine($"Before SaveChanges: Id = {book.Id}");
Console.WriteLine($"Temporary: {db.Entry(book).Property(b => b.Id).IsTemporary}");
Console.WriteLine($"Tracked value: {db.Entry(book).Property(b => b.Id).CurrentValue}");

db.SaveChanges();
Console.WriteLine($"After SaveChanges: Id = {book.Id}");

Tag tag = new() { Name = "classic" };
db.Tags.Add(tag);
Console.WriteLine($"Guid before SaveChanges: {tag.Id}");
db.SaveChanges();
Before SaveChanges: Id = 0
Temporary: True
Tracked value: -2147482647
After SaveChanges: Id = 1
Guid before SaveChanges: 4eb01e7a-9398-4fd0-97fb-844553ae1773

Entity'nin kendisinde Id hâlâ 0. Change tracker ise içeride geçici, negatif bir değer tutuyor; ilişkileri kayıttan önce birbirine bağlayabilmek için buna ihtiyacı var. SaveChanges'te INSERT'ten dönen gerçek değer geçici değerin yerini alıp entity'ye yazılıyor. Guid key'de durum farklı: EF Core değeri veritabanına gitmeden, Add anında kendisi üretiyor. INSERT'te de Id kolonu parametre olarak gönderiliyor. EF Core 6 ve 10 bu adımda aynı davranıyor.

Tablo ve Kolon Adları

Tablo adı, entity'yi gösteren DbSet property'sinin adından gelir: Books ve Tags. DbSet'i olmayan Author ise class adını aldı, yani tekil Author. Aynı veritabanında hem çoğul hem tekil tablo adı görürsen sebebi büyük ihtimalle budur. Kolon adları property adlarının aynısı. Primary key, foreign key ve index adları da kalıp izliyor: PK_Books, FK_Books_Author_AuthorId, IX_Books_AuthorId.

DisplayTitle kolon olmadı, çünkü yalnızca getter'ı var. EF Core convention gereği setter'ı olmayan property'leri eşlemez. Ama yazılabilir bir property'yi eşlenmesin diye işaretlemek istiyorsan bunu açıkça yapman gerekir. Tablo ve kolon adlarını değiştirmenin yolları da bir sonraki yazıda.

Shadow Property'ye İlk Bakış

Şemada Books.AuthorId adında bir kolon var, ama Book class'ında böyle bir property yok. Navigation var, foreign key property'si yok; ilişkiyi veritabanında tutabilmek için EF Core bu kolonu kendisi ekledi. Debug çıktısında AuthorId (no field, int?) Shadow FK Index satırı bunu söylüyor. Class'ta yaşamayan, yalnızca modelde ve change tracker'da var olan bu tür property'lere shadow property denir.

Shadow property'ye entity üzerinden ulaşamazsın ama change tracker ve sorgular üzerinden ulaşabilirsin:

Book book = db.Books.Single(b => b.Title == "Refactoring");
object? authorId = db.Entry(book).Property("AuthorId").CurrentValue;
Console.WriteLine($"AuthorId (shadow): {authorId}");

List<string> titles = db.Books
    .Where(b => EF.Property<int?>(b, "AuthorId") == 1)
    .Select(b => b.Title)
    .ToList();
AuthorId (shadow): 1
      SELECT "b"."Title"
      FROM "Books" AS "b"
      WHERE "b"."AuthorId" = 1

EF.Property yalnızca LINQ içinde anlam taşır; EF Core onu doğrudan kolon adına çevirir. Shadow property'ler audit alanları (CreatedAt, UpdatedBy) gibi domain class'ını kirletmesini istemediğin veriler için de kullanışlı. Foreign key'ler için ise çoğu zaman property'yi açıkça yazmak daha rahattır; ilişkiler yazısında bunun sebebini göreceğiz.

Sık Düşülen Tuzaklar

SQLite'ın sessizliğine güvenmek. SQLite uzunluk sınırını uygulamaz ve decimal'ı TEXT olarak saklar. Geliştirmede SQLite, production'da SQL Server kullanıyorsan şemanın gerçek hâlini GenerateCreateScript ya da migration script'iyle diğer provider'da da kontrol et.

Tekil ve çoğul tablo adlarını karıştırmak. Bir entity'ye sonradan DbSet eklemek, tablo adını Author'dan Authors'a çevirir. Migration kullanıyorsan bu bir rename değil, şema değişikliği demektir.

NRT'yi şema etkisini düşünmeden açmak. Nullable'ı açmak sadece derleyici uyarılarını değiştirmez, EF Core'un modelini de değiştirir. Açtıktan sonraki ilk migration'ı dikkatle oku.

Geçici key'i gerçek sanmak. Add'den sonra change tracker'dan okuduğun negatif değeri bir yere kaydedip URL'de ya da log'da kullanma. Gerçek değer SaveChanges'ten sonra gelir.

Küçük Bir Deneme

★☆☆ Context'e public DbSet<Author> Authors => Set<Author>(); ekle, veritabanını sil ve yeniden oluştur. Tablo adı ve foreign key constraint'inin adı nasıl değişti?

★★☆ Book'a public int AuthorId { get; set; } ekle (nullable değil). Debug çıktısında AuthorId satırı nasıl değişti? Şemadaki AuthorId kolonu hâlâ NULL kabul ediyor mu? Debug çıktısındaki foreign key satırındaki silme davranışına da bak.

★★★ Tag'in key'ini Guid yerine string yap ve değer vermeden bir tag eklemeyi dene. Aldığın hatayı key convention'ının ve generated value'ların söyledikleriyle açıkla.

Convention'lar bizi buraya kadar getirdi, ama gerçek projelerde her zaman yetmez: decimal'ın hassasiyeti, string'in uzunluğu, tekil tablo adı, unique ISBN. Bir sonraki yazıda convention'ın söylediğini nasıl değiştireceğimize bakacağız: data annotation'lar ve Fluent API, OnModelCreating, IEntityTypeConfiguration<T>, index, unique, check constraint ve default değer.