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

EF Core ile İlk Uygulama: DbContext, DbSet, SQLite ve Üretilen SQL

Mert Özen 5 Kas 2026 10 dk 1 görüntülenme
EF Core ile İlk Uygulama: DbContext, DbSet, SQLite ve Üretilen SQL

Boş bir console projesinden ilk EF Core uygulamasını kuruyoruz: SQLite ve Design paketleri, local tool olarak dotnet-ef, DbContext ve DbSet, OnConfiguring, EnsureCreated, ilk ekleme ve sorgu, LogTo ve ToQueryString ile gerçek SQL ve EF Core 6 ile 10 arasındaki SQL farkları.

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

Bu yazının sonunda elinde çalışan bir EF Core uygulaması olacak. Program.cs dosyası şu kadar:

using BookstoreApp;
using Microsoft.EntityFrameworkCore;

using BookstoreContext db = new();

bool created = db.Database.EnsureCreated();
Console.WriteLine($"Database created: {created}");

db.Books.Add(new Book { Title = "Domain-Driven Design", Price = 54.90m });
db.Books.Add(new Book { Title = "Refactoring", Price = 42.50m });

int saved = db.SaveChanges();
Console.WriteLine($"Saved rows: {saved}");

List<Book> books = db.Books
    .Where(b => b.Title.Contains("Design"))
    .OrderBy(b => b.Title)
    .ToList();

foreach (Book book in books)
{
    Console.WriteLine($"#{book.Id} {book.Title} ({book.Price})");
}

Klasörde henüz bir veritabanı dosyası yok. Programı ilk kez çalıştırdığında EF Core veritabanına kaç SQL komutu gönderir? Peki ikinci kez çalıştırdığında? Tahminini bir kenara yaz; cevabı bu yazının ortasında, EF Core'un kendi log'unda göreceğiz.

Proje ve Paketler

Boş bir console uygulamasıyla başlıyoruz ve iki paket ekliyoruz:

dotnet new console -n BookstoreApp
cd BookstoreApp
dotnet add package Microsoft.EntityFrameworkCore.Sqlite --version 10.0.14
dotnet add package Microsoft.EntityFrameworkCore.Design --version 10.0.14

Komutlar bittiğinde BookstoreApp.csproj şöyle görünür:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.14">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
    <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.14" />
  </ItemGroup>

</Project>

Microsoft.EntityFrameworkCore.Sqlite provider paketi. Tek başına yeter, çünkü EF Core'un çekirdeğini (Microsoft.EntityFrameworkCore), ilişkisel veritabanı katmanını (Microsoft.EntityFrameworkCore.Relational) ve SQLite'ın native kütüphanesini bağımlılık olarak kendisi getirir. Microsoft.EntityFrameworkCore.Design ise uygulamanın çalışması için değil, birazdan kuracağımız komut satırı aracının projeyi okuyabilmesi için var. PrivateAssets ayarı bu yüzden all: paket build sırasında kullanılır, projeni referans eden başka projelere taşınmaz.

Bu ayarlar .NET 10 SDK ve EF Core 10 içindir. Elinde .NET 6 hedefleyen eski bir proje varsa aynı paketlerin 6.0.36 sürümünü kurarsın; csproj'da değişen tek şey TargetFramework değerinin net6.0 ve paket sürümlerinin 6.0.36 olması. Bugünkü .NET 8 ve .NET 10 SDK'larının console template'i -f net6.0 seçeneği sunmuyor, o yüzden eski bir hedef istiyorsan bu satırı elle değiştirmen gerekir. Bu serideki her örnek iki kurulumda da derlenip çalıştırıldı.

dotnet-ef Aracı

EF Core'un migration oluşturmak, veritabanını güncellemek ya da DbContext hakkında bilgi almak için kullandığı komut satırı aracı dotnet-ef. .NET SDK'nın içinde gelmez, ayrıca kurulur. İki seçeneğin var: makineye global kurmak ya da projeye local tool olarak eklemek. Local tool'u tercih et; aracın sürümü projenin içindeki bir manifest dosyasında yazılı olur ve depoyu klonlayan herkes aynı sürümü kullanır:

dotnet new tool-manifest
dotnet tool install dotnet-ef --version 10.0.14
dotnet ef --version
Entity Framework Core .NET Command-line Tools
10.0.14

.NET 10 SDK manifest'i proje klasörüne dotnet-tools.json adıyla yazar. .NET 8 SDK aynı komutla dosyayı .config/dotnet-tools.json altına koyuyor. Depoyu klonlayan biri dotnet tool restore çalıştırınca araç manifest'teki sürümle geri gelir. Aracın sürümü EF Core paketinin sürümüyle eşleşmeli: EF Core 6 projesinde --version 6.0.36 kullanırsın.

Aracın projeni görebildiğini doğrulamanın en kısa yolu, birazdan yazacağımız DbContext hazır olduğunda şu komutu çalıştırmak:

dotnet ef dbcontext info
Build started...
Build succeeded.
Type: BookstoreApp.BookstoreContext
Provider name: Microsoft.EntityFrameworkCore.Sqlite
Database name: main
Data source: bookstore.db
Options: None

Araç projeyi build etti, içindeki DbContext'i buldu, bir instance oluşturdu ve hangi provider ile hangi veritabanına bağlanacağını raporladı. Migration'lar konusunda bu aracı çok daha yoğun kullanacağız.

Entity ve DbContext

Önce entity. Veritabanında bir tabloya karşılık gelecek düz bir C# class'ı:

namespace BookstoreApp;

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

Hiçbir base class yok, hiçbir attribute yok. EF Core Id adındaki property'yi convention gereği primary key kabul edecek. Convention'ları bir sonraki yazıda tek tek inceleyeceğiz. Şimdi DbContext:

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

namespace BookstoreApp;

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

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder
            .UseSqlite("Data Source=bookstore.db")
            .LogTo(Console.WriteLine, LogLevel.Information);
    }
}

Üç parça var. DbContext'ten türeyen class, uygulamanın veritabanıyla konuşan oturumudur. DbSet<Book>, EF Core'a "Book bir entity, onun için bir tablo olacak" der ve sorguların başladığı yerdir. { get; set; } ile yazılan klasik hâl, nullable reference types açıkken EF Core 6 projesinde CS8618 uyarısı ("Non-nullable property 'Books' must contain a non-null value when exiting constructor") üretir. EF Core 10'da EF'in kendi analyzer'ı DbSet property'leri için bu uyarıyı bastırıyor. Books => Set<Book>() yazımı ise iki sürümde de temiz derlenir.

OnConfiguring, context'in hangi provider'ı ve hangi bağlantıyı kullanacağını söylediğin yer. UseSqlite provider'ı seçer, "Data Source=bookstore.db" da connection string'dir: uygulamanın çalıştığı klasördeki bookstore.db dosyası. LogTo ise EF Core'un Information ve üstü seviyedeki log'larını console'a basar. ASP.NET Core uygulamasında bu ayarları OnConfiguring yerine dependency injection üzerinden AddDbContext ile yaparız; bunu ASP.NET Core Web API serisinde ve bu serinin entegrasyon yazısında göreceğiz.

EnsureCreated ile Veritabanını Oluşturmak

Program'ın ilk işi db.Database.EnsureCreated(). Bu method veritabanına bakar. Veritabanı yoksa ya da içinde hiç tablo yoksa modeldeki bütün tabloları oluşturur ve true döner. Tablolar zaten varsa hiçbir şey yapmadan false döner. Programı ilk kez çalıştıralım (EF Core 6 çıktısı):

info: 11/4/2026 00:48:15.679 CoreEventId.ContextInitialized[10403] (Microsoft.EntityFrameworkCore.Infrastructure)
      Entity Framework Core 6.0.36 initialized 'BookstoreContext' using provider 'Microsoft.EntityFrameworkCore.Sqlite:6.0.36' with options: None
info: 11/4/2026 00:48:15.805 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (3ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      PRAGMA journal_mode = 'wal';
info: 11/4/2026 00:48:15.827 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "Books" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_Books" PRIMARY KEY AUTOINCREMENT,
          "Title" TEXT NOT NULL,
          "Price" TEXT NOT NULL
      );
Database created: True

İlk satır, context'in hangi EF Core ve provider sürümüyle başlatıldığını söylüyor; EF Core 10'un Information seviyesindeki çıktısında bu satır yer almıyor. PRAGMA journal_mode = 'wal' SQLite dosyasını yeni oluştururken gönderilen bir ayar. Ardından modelden üretilen CREATE TABLE geliyor: Id otomatik artan primary key oldu, Price ise SQLite'ta decimal tipi olmadığı için TEXT olarak saklanıyor.

Programı ikinci kez çalıştırdığında bu kısım değişir:

info: 11/4/2026 00:48:30.797 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT COUNT(*) FROM "sqlite_master" WHERE "type" = 'table' AND "rootpage" IS NOT NULL;
Database created: False

EF Core tablo var mı diye baktı, buldu ve oluşturma adımını atladı. Buradaki kritik nokta: EnsureCreated var olan bir şemayı güncellemez. Book'a yeni bir property eklersen tablo eski hâliyle kalır, sorgu da var olmayan kolon yüzünden hata verir. Deneme projeleri ve testler için çok pratik, ama şeması zamanla değişecek gerçek bir uygulamada yerini migration'lara bırakır. Geliştirme sırasında temiz bir başlangıç istersen önüne db.Database.EnsureDeleted() koyabilirsin; adının da söylediği gibi veritabanını tamamen siler.

İlk Ekleme ve İlk Sorgu

db.Books.Add(...) veritabanına hiçbir şey göndermez; yalnızca entity'yi change tracker'a Added durumunda kaydeder. Asıl iş SaveChanges'te olur: EF Core bekleyen bütün değişiklikleri toplar, gerekli komutları üretir, çalıştırır ve etkilenen satır sayısını döner. İlk çalıştırmanın geri kalanı şöyle:

info: 11/4/2026 00:48:15.859 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (1ms) [Parameters=[@p0='?' (DbType = Decimal), @p1='?' (Size = 20)], CommandType='Text', CommandTimeout='30']
      INSERT INTO "Books" ("Price", "Title")
      VALUES (@p0, @p1);
      SELECT "Id"
      FROM "Books"
      WHERE changes() = 1 AND "rowid" = last_insert_rowid();
info: 11/4/2026 00:48:15.863 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[@p0='?' (DbType = Decimal), @p1='?' (Size = 11)], CommandType='Text', CommandTimeout='30']
      INSERT INTO "Books" ("Price", "Title")
      VALUES (@p0, @p1);
      SELECT "Id"
      FROM "Books"
      WHERE changes() = 1 AND "rowid" = last_insert_rowid();
Saved rows: 2
info: 11/4/2026 00:48:15.914 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT "b"."Id", "b"."Price", "b"."Title"
      FROM "Books" AS "b"
      WHERE ('Design' = '') OR (instr("b"."Title", 'Design') > 0)
      ORDER BY "b"."Title"
#1 Domain-Driven Design (54.90)

Tahmin sorusunun cevabı: ilk çalıştırmada beş komut (PRAGMA, CREATE TABLE, iki INSERT, bir SELECT), ikincide dört (tablo kontrolü, iki INSERT, bir SELECT). Her INSERT'in arkasında bir SELECT daha var, çünkü Id'yi veritabanı üretiyor ve EF Core bu değeri geri okuyup entity'ye yazıyor. Kayıttan sonra book.Id'nin dolu gelmesinin sebebi bu.

Sorgu tarafında ToList() çağrılana kadar hiçbir şey çalışmadı. Where ve OrderBy yalnızca bir sorgu tarifi kurdu; tarifi SQL'e çevirip veritabanına gönderen, sonucu isteyen ToList() oldu. Contains ise SQLite'ın instr fonksiyonuna çevrildi.

Gerçek uygulamalarda, özellikle web tarafında, bu çağrıların async karşılıklarını kullanırız: SaveChangesAsync, ToListAsync, SingleAsync. Davranış aynı, yalnızca veritabanını beklerken thread serbest kalır.

Üretilen SQL'i Okumak

LogTo(Console.WriteLine, LogLevel.Information) her şeyi basar, kalabalık olabilir. Yalnızca çalışan SQL komutlarını görmek istiyorsan log kategorisini filtreleyebilirsin. Parametre değerlerini de görmek istiyorsan EnableSensitiveDataLogging ekle:

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

Arama terimini de bir değişkene alalım ve sorguyu çalıştırmadan önce ToQueryString ile SQL'ine bakalım:

string term = "Design";
IQueryable<Book> query = db.Books
    .Where(b => b.Title.Contains(term))
    .OrderBy(b => b.Title);

Console.WriteLine(query.ToQueryString());
List<Book> books = await query.ToListAsync();

EF Core 6 çıktısı:

.param set @__term_0 'Design'

SELECT "b"."Id", "b"."Price", "b"."Title"
FROM "Books" AS "b"
WHERE (@__term_0 = '') OR (instr("b"."Title", @__term_0) > 0)
ORDER BY "b"."Title"
info: 11/4/2026 00:49:21.143 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (1ms) [Parameters=[@__term_0='Design' (Size = 6)], CommandType='Text', CommandTimeout='30']
      SELECT "b"."Id", "b"."Price", "b"."Title"
      FROM "Books" AS "b"
      WHERE (@__term_0 = '') OR (instr("b"."Title", @__term_0) > 0)
      ORDER BY "b"."Title"

Değişken olarak verdiğin değer artık SQL'in içine gömülmüyor, @__term_0 adlı bir parametreyle gidiyor. Bu hem SQL injection'a karşı korur hem de veritabanının aynı sorgu planını tekrar kullanmasını sağlar. ToQueryString çıktısının başındaki .param set satırı, SQL'i olduğu gibi kopyalayıp SQLite komut satırında çalıştırabilmen için.

Aynı kod EF Core 10'da daha sade bir SQL üretiyor:

.param set @term 'Design'

SELECT "b"."Id", "b"."Price", "b"."Title"
FROM "Books" AS "b"
WHERE instr("b"."Title", @term) > 0
ORDER BY "b"."Title"

Parametre adı değişkenin adına sadeleşti, boş string kontrolü de kalktı. EF Core 10'da INSERT'ler de SELECT ... last_insert_rowid() yerine RETURNING "Id" ile bitiyor. Kodun bir harfi değişmedi; SQL'i provider üretiyor ve her sürümde biraz daha iyi üretiyor.

Sık Düşülen Tuzaklar

Design paketini unutmak. Paket yokken dotnet ef komutu build'i geçer ama şu mesajla durur:

Your startup project 'BookstoreApp' doesn't reference Microsoft.EntityFrameworkCore.Design. This package is required for the Entity Framework Core Tools to work. Ensure your startup project is correct, install the package, and try again.

Araç ile paketin sürümünü ayırmak. EF Core 10 projesinde 6.0.36 sürümlü aracı çalıştırırsan komut çalışır ama önce şu uyarıyı basar:

The Entity Framework tools version '6.0.36' is older than that of the runtime '10.0.14'. Update the tools for the latest features and bug fixes. See https://aka.ms/AAc1fbw for more information.

Veritabanı dosyasını yanlış klasörde aramak. Data Source=bookstore.db göreli bir yoldur ve uygulamanın çalışma klasörüne göre çözülür. dotnet run ile çalıştırınca dosya proje klasöründe oluşur. IDE'nin ayarına göre çalışma klasörü bin/Debug/... olabilir; o zaman iki ayrı veritabanı dosyası olur ve "kaydettiğim veri nereye gitti?" diye ararsın.

EnsureCreated ile başlayıp sonra migration'a geçmeye çalışmak. EnsureCreated ile oluşturulan veritabanında migration geçmişini tutan tablo yoktur, ilk migration da var olan tabloları yeniden oluşturmaya kalkar ve dotnet ef database update şu hatayla durur: SQLite Error 1: 'table "Books" already exists'. Migration kullanacaksan baştan migration ile başla; bunu migration yazısında ayrıntılı göreceğiz.

EnableSensitiveDataLogging'i production'da açık bırakmak. Adı boşuna böyle değil: parametre değerleri, yani müşteri adları, e-posta adresleri ve diğer kişisel veriler log'a düşer. Yalnızca geliştirme ortamında aç.

Küçük Bir Deneme

★☆☆ Projeyi sıfırdan kur, programı iki kez çalıştır ve iki çalıştırma arasındaki SQL farkını log'dan kendin bul. Kitaplar her çalıştırmada iki kez mi ekleniyor? Ekleniyorsa bunu nasıl önlersin?

★★☆ Book class'ına public int PageCount { get; set; } ekle ve programı veritabanını silmeden çalıştır. Aldığın hatayı oku, sonra EnsureCreated'ın önüne EnsureDeleted ekleyip tekrar dene.

★★★ Program'ı async hâle getir: EnsureCreatedAsync, SaveChangesAsync ve ToListAsync kullan. Ardından sorguya .Where(b => b.Price > 50m) ekle ve ToQueryString çıktısında decimal karşılaştırmasının nasıl çevrildiğine bak.

Bu yazıda EF Core'a tek bir class verdik ve gerisini o halletti: tablo adı, primary key, NOT NULL kolonlar, otomatik artan Id. Bu kararların hepsi convention'lardan geliyor. Bir sonraki yazıda EF Core'un modeli nasıl kurduğuna bakacağız: key convention'ı, nullable reference types ile zorunlu ve opsiyonel alanlar, string uzunlukları, generated value'lar, tablo ve kolon adları ve shadow property'ler.