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

EF Core'da İlişkiler 2: One-to-One, Many-to-Many ve UsingEntity

Mert Özen 5 Kas 2026 10 dk 2 görüntülenme
EF Core'da İlişkiler 2: One-to-One, Many-to-Many ve UsingEntity

Author ve AuthorProfile ile one-to-one'ı, Book ve Tag ile many-to-many'yi kuruyoruz: unique foreign key index'i, dependent tarafın belirlenememesi hatası ve HasForeignKey<T>, skip navigation'lar ve convention'la oluşan BookTag tablosu, UsingEntity ile join tablosunu adlandırmak ve TaggedAt payload'lı join entity.

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 kitabın birden çok etiketi, bir etiketin de birden çok kitabı olabilir. Modeli olabilecek en sade hâliyle yazıyoruz; iki class, iki liste, başka hiçbir şey:

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public List<Tag> Tags { get; set; } = new();
}

public class Tag
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public List<Book> Books { get; set; } = new();
}

Ortada bir foreign key yok, bir ara class yok, OnModelCreating boş. EnsureCreated kaç tablo oluşturur? Bir ara tablo oluşacaksa adı ne olur, kolonları neler olur? Bir kitaptan bir etiketi çıkardığında veritabanında hangi satır silinir: etiketin kendisi mi, başka bir şey mi?

One-to-One: Author ve AuthorProfile

Önce daha basit olanla başlayalım. Her yazarın en fazla bir profil sayfası var; biyografi ve web sitesi gibi, yazar listesinde her seferinde taşımak istemediğimiz bilgiler:

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

public class AuthorProfile
{
    public int Id { get; set; }
    public string Bio { get; set; } = "";
    public string? Website { get; set; }

    public int AuthorId { get; set; }
    public Author Author { get; set; } = null!;
}

Şema:

CREATE TABLE "AuthorProfile" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_AuthorProfile" PRIMARY KEY AUTOINCREMENT,
    "Bio" TEXT NOT NULL,
    "Website" TEXT NULL,
    "AuthorId" INTEGER NOT NULL,
    CONSTRAINT "FK_AuthorProfile_Authors_AuthorId" FOREIGN KEY ("AuthorId") REFERENCES "Authors" ("Id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX "IX_AuthorProfile_AuthorId" ON "AuthorProfile" ("AuthorId");

One-to-many'deki şemaya çok benziyor, tek fark son satırda: foreign key üzerindeki index bu sefer UNIQUE. "Bir yazarın en fazla bir profili olur" kuralını veritabanında uygulayan şey bu. Aynı yazara ikinci bir profil eklemeye çalıştığımızda:

db.Set<AuthorProfile>().Add(new AuthorProfile { Bio = "Duplicate", AuthorId = 1 });
db.SaveChanges();
SQLite Error 19: 'UNIQUE constraint failed: AuthorProfile.AuthorId'.

Profili yazarla birlikte eklemek ise bildiğimiz graf ekleme: new Author { Name = "Martin Fowler", Profile = new AuthorProfile { ... } } iki INSERT üretir, önce yazar, sonra profil. Kayıttan sonra Profile.AuthorId 1 olarak geldi.

Dependent Taraf Kim?

One-to-many'de EF Core'un işi kolaydı: koleksiyonun karşı tarafı her zaman dependent'tır. One-to-one'da iki taraf da tek bir reference navigation taşıyor. Foreign key'i hangi tabloya koyacağına nasıl karar veriyor? Yukarıdaki örnekte cevabı biz verdik: AuthorId property'si AuthorProfile'da duruyor, demek ki dependent o.

Foreign key property'sini kaldırırsak ne olur? AuthorProfile'dan AuthorId'yi silip yalnızca navigation'ları bıraktığımızda model hiç kurulmadı:

The dependent side could not be determined for the one-to-one relationship between 'Author.Profile' and 'AuthorProfile.Author'. To identify the dependent side of the relationship, configure the foreign key property. If these navigations should not be part of the same relationship, configure them independently via separate method chains in 'OnModelCreating'. See http://go.microsoft.com/fwlink/?LinkId=724062 for more details.

Mesaj EF Core 6 ve 10'da aynı; yalnızca EF Core 10 bağlantıyı https ile veriyor. EF Core tahmin yürütmüyor, çünkü iki seçenek de geçerli. Profil yazara bağlı olabilir, yazar da profile. Hangisinin hangisine bağlı olduğu bir tasarım kararı ve onu senin vermen gerekiyor.

One-to-One'ı Fluent API ile Yazmak

Kararı açıkça yazmak için:

modelBuilder.Entity<Author>()
    .HasOne(a => a.Profile)
    .WithOne(p => p.Author)
    .HasForeignKey<AuthorProfile>(p => p.AuthorId);

HasOne ile WithOne ilişkinin iki ucunu bağlar. Asıl dikkat edilmesi gereken HasForeignKey<AuthorProfile>'daki generic parametre. One-to-many'de foreign key'in hangi entity'de olduğu belliydi; burada değil, o yüzden entity tipini açıkça söylüyorsun. Dependent'ı belirleyen tam olarak bu parametre.

İlişkinin zorunlu olup olmadığına da bakalım. Author.Profile nullable: profili olmayan yazar olabilir. AuthorProfile.AuthorId ise int: yazarı olmayan profil olamaz. Bu yüzden şemada ON DELETE CASCADE var; yazar silinince profili de gider. Bir alternatif de profilin primary key'ini aynı zamanda foreign key yapmak: AuthorProfile.Id doğrudan yazarın Id'si olur, ayrı bir AuthorId kolonuna ve unique index'e gerek kalmaz. Bunun için HasForeignKey<AuthorProfile>(p => p.Id) yazman yeterli.

Many-to-Many: Skip Navigation

Şimdi tahmin sorusuna dönelim. Book ve Tag modeliyle EnsureCreated şunu üretti:

CREATE TABLE "BookTag" (
    "BooksId" INTEGER NOT NULL,
    "TagsId" INTEGER NOT NULL,
    CONSTRAINT "PK_BookTag" PRIMARY KEY ("BooksId", "TagsId"),
    CONSTRAINT "FK_BookTag_Books_BooksId" FOREIGN KEY ("BooksId") REFERENCES "Books" ("Id") ON DELETE CASCADE,
    CONSTRAINT "FK_BookTag_Tags_TagsId" FOREIGN KEY ("TagsId") REFERENCES "Tags" ("Id") ON DELETE CASCADE
);
CREATE INDEX "IX_BookTag_TagsId" ON "BookTag" ("TagsId");

Books ve Tags'in yanında üçüncü bir tablo var. İlişkisel veritabanında many-to-many her zaman bir ara tabloyla kurulur; EF Core 5'ten beri o tabloyu temsil eden class'ı yazmak zorunda değilsin. Tablonun adı iki entity adının birleşimi (BookTag), kolon adları navigation adı artı key adı (BooksId, TagsId). Primary key iki kolonun birleşimi, yani aynı kitap-etiket çifti iki kez eklenemez. Her iki foreign key de cascade: kitap ya da etiket silinince bağlantı satırları da silinir.

Book.Tags ve Tag.Books'a skip navigation denir, çünkü arada duran join entity'yi "atlayarak" doğrudan karşı tarafa gider. EF Core arka planda bu join entity'yi kendisi tanımlıyor; tipi Dictionary<string, object> olan, bir property bag gibi davranan bir shared-type entity.

Ekleme yine graf ekleme:

Tag architecture = new() { Name = "architecture" };
Tag refactoringTag = new() { Name = "refactoring" };

db.Books.Add(new Book { Title = "Refactoring", Tags = { refactoringTag, architecture } });
db.Books.Add(new Book { Title = "Patterns of Enterprise Application Architecture", Tags = { architecture } });
db.SaveChanges();

Log'da iki kitap, iki etiket ve üç bağlantı satırı için INSERT var. EF Core 10 çıktısının join tablosu kısmı:

      INSERT INTO "BookTag" ("BooksId", "TagsId")
      VALUES (@p1, @p2);
      INSERT INTO "BookTag" ("BooksId", "TagsId")
      VALUES (@p0, @p1);
      INSERT INTO "BookTag" ("BooksId", "TagsId")
      VALUES (@p0, @p1);

Skip Navigation ile Sorgulamak

Sorguda join tablosunu hiç anmıyorsun:

List<string> titles = db.Books
    .Where(b => b.Tags.Any(t => t.Name == "architecture"))
    .OrderBy(b => b.Title)
    .Select(b => b.Title)
    .ToList();
SELECT "b"."Title"
FROM "Books" AS "b"
WHERE EXISTS (
    SELECT 1
    FROM "BookTag" AS "b0"
    INNER JOIN "Tags" AS "t" ON "b0"."TagsId" = "t"."Id"
    WHERE ("b"."Id" = "b0"."BooksId") AND ("t"."Name" = 'architecture'))
ORDER BY "b"."Title"

Join'i EF Core yazdı. Sonuç: Patterns of Enterprise Application Architecture | Refactoring. EF Core 10 aynı SQL'i, WHERE satırındaki gereksiz parantezler olmadan üretiyor.

Tahmin sorusunun son kısmı: bir kitaptan etiket çıkarmak.

Book book = db.Books.Include(b => b.Tags).Single(b => b.Title == "Refactoring");
Tag tag = book.Tags.Single(t => t.Name == "architecture");
book.Tags.Remove(tag);
db.SaveChanges();
Console.WriteLine($"Tags left in database: {db.Tags.Count()}");
      DELETE FROM "BookTag"
      WHERE "BooksId" = @p0 AND "TagsId" = @p1;
      SELECT changes();
Tags left in database: 2

Silinen etiket değil, bağlantı satırı. architecture etiketi yerinde duruyor ve diğer kitaba bağlı olmaya devam ediyor. Koleksiyondan çıkarmak "bu ikisi artık ilişkili değil" demek; etiketi gerçekten silmek istersen db.Tags.Remove(tag) yazarsın.

Join Tablosunu UsingEntity ile Adlandırmak

Convention'ın verdiği BookTag adı ekibinin isimlendirme kuralına uymayabilir. Join entity'yi yapılandırmanın kapısı UsingEntity:

modelBuilder.Entity<Book>()
    .HasMany(b => b.Tags)
    .WithMany(t => t.Books)
    .UsingEntity(j => j.ToTable("BookTags"));
CREATE TABLE "BookTags" (
    "BooksId" INTEGER NOT NULL,
    "TagsId" INTEGER NOT NULL,
    CONSTRAINT "PK_BookTags" PRIMARY KEY ("BooksId", "TagsId"),
    CONSTRAINT "FK_BookTags_Books_BooksId" FOREIGN KEY ("BooksId") REFERENCES "Books" ("Id") ON DELETE CASCADE,
    CONSTRAINT "FK_BookTags_Tags_TagsId" FOREIGN KEY ("TagsId") REFERENCES "Tags" ("Id") ON DELETE CASCADE
);

HasMany ile WithMany ilişkinin iki ucu, UsingEntity'ye verdiğin lambda ise join entity'nin builder'ı. Tablo adı değişti, constraint adları da onu izledi. Kodda hiçbir şey değişmedi; sorgular ve ekleme aynen çalışıyor.

Payload'lı Join Entity

Bir gün "bu etiket kitaba ne zaman eklendi?" sorusu gelir. Artık join tablosunda iki foreign key'den fazlası var, yani bir payload. O zaman join entity'yi bir class olarak yazarsın:

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public List<Tag> Tags { get; set; } = new();
    public List<BookTag> BookTags { get; set; } = new();
}

public class Tag
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public List<Book> Books { get; set; } = new();
    public List<BookTag> BookTags { get; set; } = new();
}

public class BookTag
{
    public int BookId { get; set; }
    public Book Book { get; set; } = null!;

    public int TagId { get; set; }
    public Tag Tag { get; set; } = null!;

    public DateTime TaggedAt { get; set; }
}

Skip navigation'ları koruyup join entity'yi EF Core 6'nın UsingEntity<TJoin> overload'u ile tanıtıyoruz:

modelBuilder.Entity<Book>()
    .HasMany(b => b.Tags)
    .WithMany(t => t.Books)
    .UsingEntity<BookTag>(
        j => j.HasOne(bt => bt.Tag).WithMany(t => t.BookTags).HasForeignKey(bt => bt.TagId),
        j => j.HasOne(bt => bt.Book).WithMany(b => b.BookTags).HasForeignKey(bt => bt.BookId),
        j =>
        {
            j.ToTable("BookTags");
            j.HasKey(bt => new { bt.BookId, bt.TagId });
            j.Property(bt => bt.TaggedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
        });

İlk lambda join entity'den sağ tarafa (Tag), ikincisi sol tarafa (Book) giden one-to-many ilişkiyi kurar. Üçüncüsü join entity'nin kendisini yapılandırır: tablo adı, composite key ve payload kolonu. Bir many-to-many, aslında iki tane one-to-many'nin üst üste binmiş hâli; bu overload bunu açıkça gösteriyor.

CREATE TABLE "BookTags" (
    "BookId" INTEGER NOT NULL,
    "TagId" INTEGER NOT NULL,
    "TaggedAt" TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP),
    CONSTRAINT "PK_BookTags" PRIMARY KEY ("BookId", "TagId"),
    CONSTRAINT "FK_BookTags_Books_BookId" FOREIGN KEY ("BookId") REFERENCES "Books" ("Id") ON DELETE CASCADE,
    CONSTRAINT "FK_BookTags_Tags_TagId" FOREIGN KEY ("TagId") REFERENCES "Tags" ("Id") ON DELETE CASCADE
);

Artık iki yoldan da bağlantı kurabilirsin. Skip navigation ile (Tags = { classic }) eklediğinde payload'a değer veremezsin; EF Core TaggedAt'i INSERT'e koymaz ve veritabanının varsayılanı yazılır. Join entity üzerinden eklediğinde payload senin elinde:

Book book = db.Books.Single(b => b.Title == "Refactoring");
Tag bestseller = new() { Name = "bestseller" };
book.BookTags.Add(new BookTag { Tag = bestseller, TaggedAt = new DateTime(2026, 11, 1) });
db.SaveChanges();

Join entity'yi doğrudan da sorgulayabilirsin:

var rows = db.Set<BookTag>()
    .OrderBy(bt => bt.TaggedAt)
    .Select(bt => new { bt.Book.Title, Tag = bt.Tag.Name, bt.TaggedAt })
    .ToList();
Refactoring - bestseller - 2026-11-01
Refactoring - classic - 2026-11-03

classic'in tarihini veritabanı verdi, bestseller'ınkini biz. b.Tags üzerinden yapılan sorgular da çalışmaya devam ediyor; skip navigation'lar ile join entity yan yana yaşıyor.

Join entity'nin kendi kimliği ve iş anlamı büyüdükçe onu many-to-many'nin bir detayı olarak görmeyi bırakmak gerekir. Bir önceki yazıdaki OrderItem tam olarak böyle: Order ile Book arasında duruyor ama kendi Id'si, adedi ve sipariş anındaki fiyatıyla başlı başına bir entity. Onu skip navigation'larla değil, iki one-to-many ile modellemek doğru tercih.

Sık Düşülen Tuzaklar

Koleksiyondan çıkarmayı silmek sanmak. book.Tags.Remove(tag) yalnızca join tablosundaki satırı siler. Etiketin kendisi ve diğer kitaplarla bağlantıları yerinde kalır.

Navigation adlarını değiştirmenin şemaya etkisini unutmak. Convention'la oluşan join tablosunun kolon adları navigation adlarından geliyor (BooksId, TagsId). Tags'ı Labels diye yeniden adlandırmak, migration'da kolonun yeniden adlandırılması demek. Join tablosunu baştan UsingEntity ile adlandırmak bu sürprizi azaltır.

One-to-one'da dependent'ı ters kurmak. Foreign key'i yanlış tarafa koyarsan ilişki yine çalışır ama anlamı değişir. Opsiyonel olan taraf principal, ona bağlı olan taraf dependent olmalı. Burada yazar profilsiz var olabilir, profil yazarsız olamaz.

Payload'ın varsayılanını unutmak. Skip navigation ile eklenen bağlantılarda payload'a değer veremezsin. Veritabanında bir varsayılan tanımlamadıysan EF Core kolonu CLR varsayılanıyla doldurur. HasDefaultValueSql satırını silip bir etiketi skip navigation üzerinden eklediğimizde hata almadık; TaggedAt sessizce 0001-01-01T00:00:00 olarak kaydedildi.

Küçük Bir Deneme

★☆☆ Book-Tag modelinde aynı etiketi aynı kitaba iki kez eklemeyi dene. Hata nereden geliyor: EF Core'dan mı, veritabanından mı? Şemadaki hangi satır buna sebep oluyor?

★★☆ One-to-one'ı shared primary key ile yeniden kur: AuthorProfile'dan AuthorId'yi sil ve HasForeignKey<AuthorProfile>(p => p.Id) kullan. Oluşan şemayı öncekiyle karşılaştır; unique index nereye gitti?

★★★ Payload'lı modele ikinci bir payload ekle: public string? AddedBy { get; set; }. Bir bağlantıyı skip navigation ile, bir başkasını join entity üzerinden AddedBy vererek ekle. Sonra yalnızca AddedBy'ı dolu olan bağlantıların kitap adlarını getiren sorguyu iki şekilde yaz: db.Set<BookTag>() üzerinden ve db.Books ile b.BookTags üzerinden. İki sorgunun SQL'ini karşılaştır.

Modelimiz artık hazır: entity'ler, convention'lar, yapılandırma ve üç ilişki türü. Ama şimdiye kadar veritabanını hep EnsureCreated ile sıfırdan kurduk. Gerçek bir uygulamada model değiştikçe veritabanını veri kaybetmeden güncellemek gerekir. Bir sonraki yazının konusu migration'lar: migrations add ve remove, database update, migration dosyasının anatomisi, SQL script üretmek ve migration bundle'ları.