ASP.NET Core Web API'de EF Core ve SQLite: DbContext, İlk Migration ve CRUD
ASP.NET Core Web API'ye EF Core ve SQLite ile veri katmanı ekliyoruz: EF Core 6 ve 10 paket sürümleri, AppDbContext, connection string, DbContext'in neden scoped olduğu, ilk migration, async CRUD ve konsoldaki SQL.
Sürüm notu: Bu yazıdaki kodlar .NET 6 ile yazılmıştır ve .NET 10'da da değişmeden çalışır.
Önce Tahmin Et
Bu yazının sonunda ürünler bir SQLite veritabanında duracak ve her ürün bir kategoriye bağlı olacak. Veritabanında yalnızca 1 ve 2 numaralı kategoriler var. Şu isteği atarsan ne olur?
curl -i -X POST http://localhost:5080/api/products -H "Content-Type: application/json" -d '{"name":"Ghost","price":10,"categoryId":99}'
[ApiController] body'yi sorunsuz bağlayacak, çünkü categoryId geçerli bir sayı. 201 mi döner, 400 mü, 404 mü, yoksa bambaşka bir şey mi? Cevap CRUD bölümünde.
EF Core API'nin Neresinde Durur
Şimdiye kadar ürünler bellekteki bir listedeydi ve uygulama her yeniden başladığında eklediklerimiz kayboldu. Entity Framework Core, .NET'in ORM'i: C# class'larını tablolara, LINQ sorgularını SQL'e çevirir. Bu yazıda EF Core'a bir API geliştiricisinin gözünden bakacağız; DbContext'i kaydetmek, connection string vermek, ilk migration'ı çalıştırmak ve CRUD'u controller'a bağlamak. İlişkiler, loading stratejileri, change tracker'ın içi ve performans gibi konuları ayrı EF Core serisinde detaylandırıyoruz.
Veritabanı olarak SQLite seçtik, çünkü kurulum gerektirmiyor; veritabanı proje klasöründe tek bir dosya. Kodu klonlayan herkes hiçbir sunucu kurmadan çalıştırabilir. Provider'ı değiştirmek, birazdan göreceğin tek satırı UseSqlServer ya da UseNpgsql ile değiştirmek kadar kolay.
Paketler ve Sürüm Eşleşmesi
EF Core paketlerinin major sürümü, hedeflediğin .NET sürümüyle eşleşmeli. net6.0 projesi için EF Core 6'nın son yaması 6.0.36:
dotnet add package Microsoft.EntityFrameworkCore.Sqlite --version 6.0.36
dotnet add package Microsoft.EntityFrameworkCore.Design --version 6.0.36
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ProductCatalog</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.36">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.36" />
</ItemGroup>
</Project>
Projeyi .NET 10'a taşıdığında TargetFramework'ü net10.0, iki paketi de 10.0.14 yapman yeterli; bu yazıdaki C# kodunun tek satırı değişmiyor, ikisini de derleyip çalıştırdım. Sqlite paketi provider'ı ve EF Core'un kendisini getirir. Design paketi migration üretmek için gereken araçları içerir ve PrivateAssets sayesinde uygulamanın çıktısına taşınmaz.
Migration komutları için bir de dotnet-ef aracı gerekiyor. Sürümünü paketlerle aynı tut:
dotnet tool install --global dotnet-ef --version 6.0.36
Makinende başka bir sürüm kuruluysa install yerine update kullan. Projeye özel sürüm istiyorsan dotnet new tool-manifest ile bir local tool manifest oluşturup --global olmadan kurabilirsin; ben iki sürümü yan yana denerken bu yolu kullandım.
Entity'ler ve AppDbContext
Product artık bir kategoriye bağlı:
namespace ProductCatalog.Models;
public class Category
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public List<Product> Products { get; set; } = new();
}
namespace ProductCatalog.Models;
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
public int CategoryId { get; set; }
public Category? Category { get; set; }
}
EF Core convention'larla çalışır. Id adındaki property primary key olur, CategoryId ile Category navigation property'si birlikte foreign key ilişkisini tarif eder. Category'yi nullable yaptık; aksi hâlde [ApiController] POST body'sinde kategorinin kendisini de zorunlu sayardı. Bu inceliğin entity'yi doğrudan API'ye açmakla ilgili olduğunu bir sonraki yazıda konuşacağız.
using Microsoft.EntityFrameworkCore;
using ProductCatalog.Models;
namespace ProductCatalog.Data;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{
}
public DbSet<Product> Products => Set<Product>();
public DbSet<Category> Categories => Set<Category>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>()
.Property(p => p.Name)
.HasMaxLength(100);
// Başlangıç verisi migration'ın içine yazılır
modelBuilder.Entity<Category>().HasData(
new Category { Id = 1, Name = "Keyboards" },
new Category { Id = 2, Name = "Mice" });
modelBuilder.Entity<Product>().HasData(
new Product { Id = 1, Name = "Mechanical Keyboard", Price = 1450m, CategoryId = 1 },
new Product { Id = 2, Name = "Wireless Mouse", Price = 520m, CategoryId = 2 });
}
}
DbContext, veritabanıyla yaptığın bir oturumun temsilcisi. DbSet<T>'ler tablolara açılan kapılar, OnModelCreating ise convention'ların yetmediği yerde modeli elle ayarladığın yer. HasData ile verdiğimiz başlangıç verisi migration'a dönüşecek.
DbContext Kaydı ve Connection String
Connection string configuration'dan gelir. ConnectionStrings bölümü özel bir bölüm; GetConnectionString onu doğrudan okur:
{
"ConnectionStrings": {
"Catalog": "Data Source=catalog.db"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
using Microsoft.EntityFrameworkCore;
using ProductCatalog.Data;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlite(builder.Configuration.GetConnectionString("Catalog")));
var app = builder.Build();
app.MapControllers();
app.Run();
AddDbContext, AppDbContext'i scoped olarak kaydeder. Dependency injection yazısında "nedenini veri katmanında göreceğiz" demiştik, işte nedeni: DbContext bir iş birimidir. İçindeki change tracker o request'te okuduğun ve değiştirdiğin entity'leri izler, SaveChanges da bunları tek seferde veritabanına yazar. Bu state'i iki request'in paylaşması hem yanlış hem tehlikeli, çünkü DbContext thread-safe değil. Her request kendi DbContext'ini alır, request bitince scope kapanır ve DbContext dispose edilir. EF Core 6 bunu logda da gösteriyor; her request'te şu satır bir kez görünür:
info: Microsoft.EntityFrameworkCore.Infrastructure[10403]
Entity Framework Core 6.0.36 initialized 'AppDbContext' using provider 'Microsoft.EntityFrameworkCore.Sqlite:6.0.36' with options: None
İlk Migration
Model hazır ama veritabanı henüz yok. Migration, modelindeki değişikliği veritabanına uygulanacak adımlara çeviren bir C# dosyası. Proje klasöründe:
dotnet ef migrations add InitialCreate
Build started...
Build succeeded.
Done. To undo this action, use 'ef migrations remove'
Migrations klasöründe üç dosya oluştu: 20261103213548_InitialCreate.cs, onun .Designer.cs eşi ve modelin son hâlini tutan AppDbContextModelSnapshot.cs. Migration'ın Up method'undan bir parça:
migrationBuilder.CreateTable(
name: "Products",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
Price = table.Column<decimal>(type: "TEXT", nullable: false),
CategoryId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Products", x => x.Id);
table.ForeignKey(
name: "FK_Products_Categories_CategoryId",
column: x => x.CategoryId,
principalTable: "Categories",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
Migration'ı bir ev tadilatının proje çizimi gibi düşün; çizim masada durur, duvar henüz yıkılmadı. Uygulamak için:
dotnet ef database update
Komut catalog.db dosyasını oluşturur, tabloları kurar, başlangıç verisini ekler ve uygulanan migration'ı __EFMigrationsHistory tablosuna yazar. Çıktının son satırları:
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20261103213548_InitialCreate', '6.0.36');
Done.
EF Core 10 ile aynı komutlar aynı tabloları üretir; migration dosyasında seed verisi tek bir InsertData çağrısında toplanıyor, history tablosuna da 10.0.14 yazılıyor. Migration'lar sürüm kontrolüne girer, çünkü veritabanının tarihçesi onlar.
CRUD'u Controller'a Bağlamak
Bellekteki store'un yerini DbContext alıyor. Veritabanına giden her çağrı I/O olduğu için async method'ları kullanıyoruz; async'in detayını ve CancellationToken'ı ayrı bir yazıda ele alacağız.
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using ProductCatalog.Data;
using ProductCatalog.Models;
namespace ProductCatalog.Controllers;
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly AppDbContext _db;
public ProductsController(AppDbContext db)
{
_db = db;
}
[HttpGet]
public async Task<ActionResult<IEnumerable<Product>>> GetAll()
{
// Sadece okuyacağımız için change tracker'a yük bindirmiyoruz
List<Product> products = await _db.Products.AsNoTracking().ToListAsync();
return products;
}
[HttpGet("{id:int}")]
public async Task<ActionResult<Product>> GetById(int id)
{
Product? product = await _db.Products.FindAsync(id);
if (product is null)
{
return NotFound();
}
return product;
}
[HttpPost]
public async Task<ActionResult<Product>> Create(Product product)
{
_db.Products.Add(product);
await _db.SaveChangesAsync();
return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
}
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(int id, Product input)
{
Product? product = await _db.Products.FindAsync(id);
if (product is null)
{
return NotFound();
}
product.Name = input.Name;
product.Price = input.Price;
product.CategoryId = input.CategoryId;
await _db.SaveChangesAsync();
return NoContent();
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
Product? product = await _db.Products.FindAsync(id);
if (product is null)
{
return NotFound();
}
_db.Products.Remove(product);
await _db.SaveChangesAsync();
return NoContent();
}
}
curl http://localhost:5080/api/products
[{"id":1,"name":"Mechanical Keyboard","price":1450.0,"categoryId":1,"category":null},{"id":2,"name":"Wireless Mouse","price":520.0,"categoryId":2,"category":null}]
curl -i -X POST http://localhost:5080/api/products -H "Content-Type: application/json" -d '{"name":"USB-C Hub","price":890,"categoryId":1}'
HTTP/1.1 201 Created
Location: http://localhost:5080/api/Products/3
{"id":3,"name":"USB-C Hub","price":890,"categoryId":1,"category":null}
Add entity'yi change tracker'a "eklenecek" olarak işaretler, asıl iş SaveChangesAsync'te olur; Id'yi de veritabanı üretip entity'ye geri yazar. Update'te ise hiçbir Update çağrısı yok. FindAsync ile okunan entity zaten izleniyor; property'lerini değiştirmen yeterli, EF Core yalnızca değişen kolonları yazar.
Tahmin sorusunun cevabı: 500 Internal Server Error. Model binding ve validation sorunsuz geçer, EF Core INSERT'ü gönderir, SQLite foreign key'i reddeder ve exception action'dan dışarı taşar:
Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.
---> Microsoft.Data.Sqlite.SqliteException (0x80004005): SQLite Error 19: 'FOREIGN KEY constraint failed'.
İstemcinin hatası yüzünden sunucu hatası dönmek yanlış; doğrusu kategorinin varlığını önce kontrol edip 400 dönmek. Exception'ları anlamlı status code'lara çevirmeyi global hata yönetimi yazısında yapacağız.
Konsoldaki SQL
Logging yazısında category'lere göre filtre vermeyi görmüştük. EF Core, çalıştırdığı her komutu Microsoft.EntityFrameworkCore.Database.Command category'siyle Information seviyesinde loglar. Default Information olduğu için bunlar zaten konsolda:
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (1ms) [Parameters=[@__p_0='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
SELECT "p"."Id", "p"."CategoryId", "p"."Name", "p"."Price"
FROM "Products" AS "p"
WHERE "p"."Id" = @__p_0
LIMIT 1
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (0ms) [Parameters=[@p2='?' (DbType = Int32), @p0='?' (Size = 13), @p1='?' (DbType = Decimal)], CommandType='Text', CommandTimeout='30']
UPDATE "Products" SET "Name" = @p0, "Price" = @p1
WHERE "Id" = @p2;
SELECT changes();
Bu, PUT isteğinin izi. CategoryId aynı kaldığı için UPDATE'te yok; change tracker'ın işini canlı görüyorsun. Parametre değerleri ? olarak gizlenir, hassas veri loga düşmesin diye. Geliştirirken EF'in ne ürettiğini bu satırlardan takip et; Production'da bu category'yi Warning'e çekmek genellikle doğru tercih. EF Core 10'da aynı INSERT tek bir RETURNING "Id" ile geliyor; SQL'in ayrıntısı sürümden sürüme değişebilir, senin kodun değişmez.
Sık Düşülen Tuzaklar
Birincisi, uygulamayı başka bir klasörden çalıştırmak. Data Source=catalog.db göreli bir yol ve process'in çalışma klasörüne göre çözülür. Uygulamayı proje klasörü dışından başlattığımda SQLite orada boş bir catalog.db oluşturdu ve ilk istek şununla düştü:
Microsoft.Data.Sqlite.SqliteException (0x80004005): SQLite Error 1: 'no such table: Products'.
İkincisi, action adına Async eki koymak. Action'ı GetByIdAsync diye adlandırıp CreatedAtAction(nameof(GetByIdAsync), ...) dersen MVC, action adından Async ekini varsayılan olarak sildiği için route bulunamaz. Daha kötüsü, SaveChangesAsync çoktan çalışmıştır; ürün veritabanına yazılır ama istemci şunu alır:
System.InvalidOperationException: No route matches the supplied values.
Üçüncüsü, DbContext'i bir singleton'a enjekte etmek. Dependency injection yazısındaki captive dependency'nin en pahalı hâli bu; Development'ta uygulama açılmaz, Production'da bütün request'ler tek bir DbContext'i paylaşır.
Dördüncüsü, SQLite'ta decimal. SQLite'ın decimal tipi yok, EF Core değeri TEXT olarak saklar; JSON'daki 1450.0 bu yüzden. EF Core 6'da OrderBy(p => p.Price) sorgusu şu hatayla reddedilir: SQLite does not support expressions of type 'decimal' in ORDER BY clauses. Aynı sorgu EF Core 10'da çalışıyor. SQL Server ya da PostgreSQL'de bu sorun yok.
Beşincisi, migration'ları uygulama açılırken otomatik çalıştırmak. Geliştirme ortamında pratik bir kısayol:
if (app.Environment.IsDevelopment())
{
using (IServiceScope scope = app.Services.CreateScope())
{
AppDbContext db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.Migrate();
}
}
DbContext scoped olduğu için root provider'dan değil, açtığımız scope'tan çözdük. Production'da ise birden fazla instance aynı anda migration çalıştırmaya kalkabilir; orada migration'ı deploy sürecinin ayrı bir adımı yap.
Küçük Bir Deneme
★☆☆ Product'a Stock adında bir int property ekle, AddStock adında yeni bir migration oluştur ve uygula. Migration dosyasındaki AddColumn çağrısını oku, ardından dotnet ef migrations list ile listeye bak.
★★☆ Create action'ında, SaveChangesAsync'ten önce _db.Categories.AnyAsync(c => c.Id == product.CategoryId) ile kategoriyi kontrol et ve yoksa BadRequest dön. Tahmin sorusundaki isteği tekrar at.
★★★ GetAll'a Include(p => p.Category) ekle ve /api/products'a istek at. Ne oldu? Hata mesajını oku ve nedenini entity'lerin birbirine nasıl bağlı olduğuyla açıklamaya çalış.
Üçüncü denemede gördüğün sorun tesadüf değil. Entity'leri doğrudan API'ye açmak döngüsel referanslardan, istemcinin değiştirmemesi gereken alanları değiştirebilmesine kadar bir sürü soruna kapı aralar. Bir sonraki yazıda request ve response DTO'larıyla API sözleşmesini veritabanı modelinden ayıracak, mapping'i elle ve AutoMapper ile yapmayı karşılaştıracağız.