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

EF Core'da Data Annotation mı Fluent API mı? OnModelCreating ve IEntityTypeConfiguration

Mert Özen 5 Kas 2026 9 dk 3 görüntülenme
EF Core'da Data Annotation mı Fluent API mı? OnModelCreating ve IEntityTypeConfiguration

Convention'ı değiştirmenin iki yolunu yan yana koyuyoruz: data annotation'lar ve OnModelCreating'de Fluent API. IEntityTypeConfiguration ve ApplyConfigurationsFromAssembly ile yapılandırmayı dağıtıyor, unique index, check constraint ve default değeri gerçek SQLite hatalarıyla test ediyor, öncelik sırasını ve bool default tuzağını 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

Bir property'ye iki farklı yerden iki farklı kural verirsek ne olur? Author.Name için class'ta şu attribute var:

[Required]
[MaxLength(100)]
public string Name { get; set; } = "";

Context'in OnModelCreating method'unda da şu satır:

modelBuilder.Entity<Author>()
    .Property(a => a.Name)
    .HasMaxLength(150);

Modelde Name'in uzunluğu 100 mü olur, 150 mi? Yoksa EF Core çelişkiyi fark edip hata mı verir? Bir de şunu düşün: SQLite'ta bu kolona 300 karakterlik bir isim yazmaya çalışırsan kim seni durdurur?

Data Annotation ile Yapılandırmak

Bir önceki yazıda convention'ların kurduğu modeli okuduk. Convention'ın söylediğini değiştirmenin iki yolu var. Birincisi data annotation: kuralı attribute olarak doğrudan class'ın üstüne yazmak.

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;

namespace Bookstore;

[Table("Authors")]
[Index(nameof(Email), IsUnique = true)]
public class Author
{
    public int Id { get; set; }

    [Required]
    [MaxLength(100)]
    public string Name { get; set; } = "";

    [MaxLength(200)]
    [Column("EmailAddress")]
    public string Email { get; set; } = "";

    [NotMapped]
    public string Initials => string.Concat(Name.Split(' ').Select(p => p[0]));
}

[Table] tablo adını, [Column] kolon adını değiştirir. [MaxLength] uzunluğu verir, [NotMapped] bir property'yi modelden çıkarır. [Required] burada aslında fazladan; NRT açıkken string Name zaten zorunlu. Ama NRT kapalı bir projede ya da kuralı okuyana açıkça göstermek istediğinde işe yarar.

Attribute'ların çoğu System.ComponentModel.DataAnnotations namespace'inden gelir. Bu namespace yalnızca EF Core'a ait değil; ASP.NET Core da aynı [Required] ve [MaxLength] attribute'larını model validation için okur. [Index] ise EF Core'a özgü ve Microsoft.EntityFrameworkCore namespace'inde; EF Core 5 ile geldi.

Fluent API ve OnModelCreating

İkinci yol Fluent API: kuralları entity class'ına dokunmadan, context'in OnModelCreating method'unda zincirleme method çağrılarıyla yazmak.

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace Bookstore;

public class BookstoreContext : DbContext
{
    public DbSet<Author> Authors => Set<Author>();
    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);
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(BookstoreContext).Assembly);

        modelBuilder.Entity<Author>()
            .Property(a => a.Name)
            .HasMaxLength(150);
    }
}

OnModelCreating, EF Core'un modeli kurarken convention'ları uyguladıktan sonra çağırdığı method. modelBuilder.Entity<Author>() bir entity'nin builder'ını verir, ondan sonraki her çağrı modelin bir parçasını değiştirir. Fluent API, data annotation'ın yapabildiği her şeyi yapabilir; tersi doğru değil. İlişkilerin silme davranışı, composite key, default değer ve check constraint gibi ayarların attribute karşılığı EF Core 6'da yok.

Fluent API'nin bir artısı daha var: entity class'ını veritabanı detaylarından temiz tutar. Domain class'larını ayrı bir projede tutuyorsan ve o projenin EF Core'a referans vermesini istemiyorsan tek seçeneğin Fluent API'dir.

IEntityTypeConfiguration ile Dağıtmak

Her entity için onlarca satır Fluent API yazınca OnModelCreating hızla yüzlerce satıra çıkar. Çözüm, her entity'nin yapılandırmasını kendi class'ına taşımak:

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace Bookstore;

public class BookConfiguration : IEntityTypeConfiguration<Book>
{
    public void Configure(EntityTypeBuilder<Book> builder)
    {
        builder.ToTable("Books");

        builder.Property(b => b.Title)
            .IsRequired()
            .HasMaxLength(200);

        builder.Property(b => b.Isbn)
            .HasMaxLength(13)
            .IsFixedLength();

        builder.HasIndex(b => b.Isbn)
            .IsUnique();

        builder.Property(b => b.Price)
            .HasPrecision(10, 2);

        builder.Property(b => b.CreatedAt)
            .HasDefaultValueSql("CURRENT_TIMESTAMP");

        builder.HasCheckConstraint("CK_Books_Stock", "\"Stock\" >= 0");
    }
}

public class TagConfiguration : IEntityTypeConfiguration<Tag>
{
    public void Configure(EntityTypeBuilder<Tag> builder)
    {
        builder.Property(t => t.Name).HasMaxLength(50);
        builder.HasIndex(t => t.Name).IsUnique();
    }
}

Book class'ının kendisi ise tamamen sade, tek bir attribute yok:

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public string Isbn { get; set; } = "";
    public decimal Price { get; set; }
    public int Stock { get; set; }
    public DateTime CreatedAt { get; set; }
}

Context'teki ApplyConfigurationsFromAssembly satırı, verdiğin assembly'yi tarar ve IEntityTypeConfiguration<T> implement eden her class'ı bulup uygular. Yeni bir entity eklediğinde context'e dokunmana gerek kalmaz; configuration class'ını yazman yeter. Taramayı istemiyorsan modelBuilder.ApplyConfiguration(new BookConfiguration()) ile tek tek de ekleyebilirsin.

EF Core 6 bir alternatif daha getirdi: configuration class'ını entity'nin üstünde attribute ile göstermek.

[EntityTypeConfiguration(typeof(TagConfiguration))]
public class Tag
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
}

Bu durumda Tag context'e eklendiği anda EF Core TagConfiguration'ı kendisi uygular. Bizim projede ApplyConfigurationsFromAssembly zaten aynı class'ı bulduğu için attribute şart değil; bir projede ikisinden birini seçip onunla devam etmek okumayı kolaylaştırır.

Ortaya Çıkan Şema

EnsureCreated sonrası log'daki SQL şu (EF Core 6 ve 10 aynı):

CREATE TABLE "Authors" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Authors" PRIMARY KEY AUTOINCREMENT,
    "Name" TEXT NOT NULL,
    "EmailAddress" TEXT NOT NULL
);
CREATE TABLE "Books" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Books" PRIMARY KEY AUTOINCREMENT,
    "Title" TEXT NOT NULL,
    "Isbn" TEXT NOT NULL,
    "Price" TEXT NOT NULL,
    "Stock" INTEGER NOT NULL,
    "CreatedAt" TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP),
    CONSTRAINT "CK_Books_Stock" CHECK ("Stock" >= 0)
);
CREATE TABLE "Tags" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Tags" PRIMARY KEY AUTOINCREMENT,
    "Name" TEXT NOT NULL
);
CREATE UNIQUE INDEX "IX_Authors_EmailAddress" ON "Authors" ("EmailAddress");
CREATE UNIQUE INDEX "IX_Books_Isbn" ON "Books" ("Isbn");
CREATE UNIQUE INDEX "IX_Tags_Name" ON "Tags" ("Name");

Tablo ve kolon adları, unique index'ler, check constraint ve default değer geldi. Uzunluklar ve HasPrecision(10, 2) ise görünmüyor, çünkü SQLite bunları tutmuyor. Aynı modelin SQL Server'daki hâlini GenerateCreateScript ile ürettiğimizde farkı görüyoruz:

CREATE TABLE [Books] (
    [Id] int NOT NULL IDENTITY,
    [Title] nvarchar(200) NOT NULL,
    [Isbn] nchar(13) NOT NULL,
    [Price] decimal(10,2) NOT NULL,
    [Stock] int NOT NULL,
    [CreatedAt] datetime2 NOT NULL DEFAULT (CURRENT_TIMESTAMP),
    CONSTRAINT [PK_Books] PRIMARY KEY ([Id]),
    CONSTRAINT [CK_Books_Stock] CHECK ("Stock" >= 0)
);

IsFixedLength ile Isbn sabit uzunluklu nchar(13) oldu, Price istediğimiz hassasiyeti aldı. Bir önceki yazıdaki decimal uyarısı da artık çıkmıyor.

Index ve Unique Constraint'i Test Etmek

Aynı ISBN ile ikinci bir kitap eklemeyi deneyelim:

db.Books.Add(new Book { Title = "Refactoring (copy)", Isbn = "9780134757599", Price = 40m });
try
{
    db.SaveChanges();
}
catch (DbUpdateException ex)
{
    Console.WriteLine(ex.Message);
    Console.WriteLine(ex.InnerException?.Message);
}
An error occurred while saving the entity changes. See the inner exception for details.
SQLite Error 19: 'UNIQUE constraint failed: Books.Isbn'.

EF Core kaydetmeden önce unique kontrolü yapmaz; INSERT'i gönderir, veritabanı reddeder, EF Core da hatayı DbUpdateException içine sarar. Asıl bilgi her zaman InnerException'da. API'de bu hatayı yakalayıp 409 Conflict gibi anlamlı bir cevaba çevirmek senin işin.

Unique olmayan, sadece sorguyu hızlandırmak için bir index istiyorsan IsUnique()'i yazmazsın. Birden fazla kolonlu index için builder.HasIndex(b => new { b.Title, b.Isbn }) yazılır. Hangi kolonlara index koyacağına performans yazısında döneceğiz.

Check Constraint ve Default Değer

Check constraint, kuralı veritabanının kendisine yazar. Stoğu -1 olan bir kitap:

SQLite Error 19: 'CHECK constraint failed: CK_Books_Stock'.

HasCheckConstraint'in ikinci parametresi düz SQL'dir ve EF Core onu olduğu gibi şemaya koyar. Kolon adını tırnak içinde yazdık, çünkü provider'ın tırnaklama kuralları bu ifadeye uygulanmaz. Bu satır EF Core 10'da derlenir ve çalışır ama bir uyarı verir; EF Core 7 ile API ToTable içine taşındı:

warning CS0618: 'RelationalEntityTypeBuilderExtensions.HasCheckConstraint<TEntity>(EntityTypeBuilder<TEntity>, string, string?)' is obsolete: 'Configure this using ToTable(t => t.HasCheckConstraint()) instead.'

Yeni yazımı EF Core 7 yazısında göreceğiz; EF Core 6'da ToTable(t => t.HasCheckConstraint(...)) yazarsan derlenmez.

HasDefaultValueSql("CURRENT_TIMESTAMP"), değeri veritabanında üretir. EF Core bu property'nin değerini sen vermediğin sürece INSERT'e koymaz ve kayıttan sonra veritabanından geri okur. İlk kitabı kaydedip tekrar okuduğumuzda:

Stock=0 CreatedAt=2026-11-03 21:52:07

Programın çalıştığı makinede saat 00:52'ydi. SQLite'ın CURRENT_TIMESTAMP'i UTC döner; uygulama tarafında yerel saate çevirmek senin sorumluluğunda. Sabit bir değer için HasDefaultValue(...) kullanılır; onun bir tuzağına birazdan bakacağız.

Hangisini Ne Zaman?

Tahmin sorusunun cevabı: Fluent API kazanır.

Name max length: 150

EF Core modeli katman katman kurar: önce convention'lar, sonra data annotation'lar, en son Fluent API. Her katman bir öncekinin üstüne yazar ve çelişki hata sayılmaz. Bu, kendi başına bir tuzaktır: class'ta [MaxLength(100)] gören biri modelin 100 dediğini sanar.

Pratikte iyi çalışan bir ayrım şöyle: entity'nin kendisi hakkında, uygulamanın başka katmanlarının da bilmesi gereken basit kurallar (zorunluluk, uzunluk) annotation olarak kalabilir. Veritabanına özgü her şey (tablo adı, index, precision, default değer, ilişkilerin davranışı) Fluent API'ye, tercihen IEntityTypeConfiguration class'larına gider. En önemlisi aynı kuralı iki yere birden yazmamak.

Sık Düşülen Tuzaklar

MaxLength'in uygulandığını sanmak. EF Core uzunluğu modelde tutar ve şemaya yazar ama kaydederken kontrol etmez. SQLite da kolon uzunluğunu uygulamaz. 300 karakterlik başlık hiçbir hata vermeden kaydedildi:

Saved title length: 300

SQL Server'da aynı INSERT veritabanından hata alır. Girdinin doğrulanması EF Core'un değil, uygulamanın işi; ASP.NET Core Web API serisindeki model validation yazısı tam olarak bunun için.

bool property'ye HasDefaultValue(true) vermek. IsAvailable için bunu yapıp IsAvailable = false olan bir kitap kaydettik. EF Core 6 modeli kurarken önce şu uyarıyı verdi:

warn: 11/4/2026 00:51:55.223 RelationalEventId.BoolWithDefaultWarning[20601] (Microsoft.EntityFrameworkCore.Model.Validation)
      The 'bool' property 'IsAvailable' on entity type 'Book' is configured with a database-generated default. This default will always be used for inserts when the property has the value 'false', since this is the CLR default for the 'bool' type. Consider using the nullable 'bool?' type instead, so that the default will only be used for inserts when the property value is 'null'.

Sonra kitap veritabanına True olarak yazıldı. Sebep: EF Core, property'nin değeri CLR varsayılanıysa (bool için false) "kullanıcı değer vermedi" sayar ve kolonu INSERT'e koymaz, veritabanı da kendi varsayılanını yazar. Aynı kod EF Core 10'da False kaydetti, çünkü EF Core 8 ile birlikte bool'larda bu karar veritabanı varsayılanına göre veriliyor. Bu bir davranış farkı; EF Core 6 kullanıyorsan bool? kullan ya da varsayılanı C# tarafında ver. Ayrıntısını EF Core 8 yazısında göreceğiz.

Check constraint ifadesinde provider farkını unutmak. İfade düz SQL olduğu için SQLite'ta çalışan bir fonksiyon SQL Server'da olmayabilir. Birden fazla provider hedefliyorsan ifadeyi iki tarafta da çalışan en basit hâlde tut.

Küçük Bir Deneme

★☆☆ Author.Name'deki [MaxLength(100)]'ü bırak, OnModelCreating'deki HasMaxLength(150) satırını sil. Model artık ne diyor? Sonra annotation'ı da silip convention'ın verdiği değere bak.

★★☆ Author'a yapılandırmayı tamamen bir AuthorConfiguration class'ına taşı: tablo adı, kolon adı, uzunluklar ve unique e-posta index'i. Annotation'ları sil ve GenerateCreateScript çıktısının değişmediğini doğrula.

★★★ Book'a Title ve Isbn üzerinde birleşik bir index ekle, bir de Price > 0 kuralı için check constraint yaz. Fiyatı 0 olan bir kitabı SQLite'a kaydetmeyi dene. Sonuç beklediğin gibi mi? Değilse sebebini bir önceki yazıda gördüğümüz decimal'ın SQLite'ta saklanma biçiminde ara.

Şimdiye kadar entity'leri tek başına yapılandırdık. Gerçek bir kitapçıda ise kitabın bir yazarı, siparişin kalemleri var. Bir sonraki yazıda ilişkilerin ilkine bakacağız: one-to-many, foreign key, navigation property'ler, zorunlu ve opsiyonel ilişki ve bir yazarı sildiğinde kitaplarına ne olduğunu belirleyen cascade delete davranışları.