Mert Özen Her Satırda Daha İleri
Backend .NET Core Web API — #16

ASP.NET Core Web API'de Katmanlı Yapı: Service Katmanı ve Repository Tartışması

Mert Özen 4 Kas 2026 10 dk 2 görüntülenme
ASP.NET Core Web API'de Katmanlı Yapı: Service Katmanı ve Repository Tartışması

Şişman controller'ı inceltip işi IProductService'e devrediyoruz. Expression ile tekrar kullanılan projection, scoped kayıt, generic repository'nin EF Core üstündeki sorunları ve interface'lerin test edilebilirliğe katkısı.

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 ProductsController'ın işini ProductService adında bir class'a devredeceğiz. Service'i Program.cs'te AddSingleton<IProductService, ProductService>() ile kaydettiğini, service'in de constructor'ından AppDbContext aldığını düşün. Uygulamayı Development ortamında dotnet run ile başlatırsan ne olur? Uygulama açılır mı, ilk istekte mi patlar, yoksa hiç ayağa kalkmaz mı? Bir de aynı kodu Production'da çalıştırdığını düşün. Cevabı service'i kaydettiğimiz bölümde konuşacağız.

Controller Neden Şişer?

Bir önceki yazıda entity yerine DTO dönmeye başladık. ProductsController şu an kabaca şöyle görünüyor:

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly AppDbContext _db;

    public ProductsController(AppDbContext db)
    {
        _db = db;
    }

    [HttpGet("{id:int}")]
    public async Task<ActionResult<ProductDto>> GetById(int id)
    {
        ProductDto? product = await _db.Products
            .AsNoTracking()
            .Where(p => p.Id == id)
            .Select(p => new ProductDto(p.Id, p.Name, p.Price, p.Category!.Name))
            .FirstOrDefaultAsync();

        if (product is null)
        {
            return NotFound();
        }

        return product;
    }

    [HttpPost]
    public async Task<ActionResult<ProductDto>> Create(CreateProductRequest request)
    {
        // iş kuralı controller'ın içinde yaşıyor
        bool nameTaken = await _db.Products.AnyAsync(p => p.Name == request.Name);
        if (nameTaken)
        {
            return Conflict($"A product named '{request.Name}' already exists.");
        }

        Product product = new()
        {
            Name = request.Name,
            Price = request.Price,
            CategoryId = request.CategoryId
        };
        _db.Products.Add(product);
        await _db.SaveChangesAsync();

        ProductDto dto = await _db.Products
            .Where(p => p.Id == product.Id)
            .Select(p => new ProductDto(p.Id, p.Name, p.Price, p.Category!.Name))
            .FirstAsync();

        return CreatedAtAction(nameof(GetById), new { id = dto.Id }, dto);
    }
}

Çalışıyor, hatta derli toplu duruyor. Ama bu controller şu an dört ayrı işi aynı anda yapıyor: HTTP'yi konuşuyor (status code, CreatedAtAction), iş kuralını uyguluyor ("aynı isimde ikinci ürün olamaz"), veri erişimini yönetiyor (LINQ, SaveChangesAsync) ve mapping yapıyor (new ProductDto(...)). Projection ifadesi şimdiden iki kere yazıldı.

Restoran benzetmesiyle düşün. Garson siparişi alıyor, mutfağa girip yemeği pişiriyor, sonra depoya inip malzeme sayıyor. Küçük bir büfede bu idare eder. Masa sayısı arttıkça garson hiçbir işi düzgün yapamaz hâle gelir. Controller garsondur: isteği alır, cevabı götürür. Yemeği pişirmek başkasının işi.

Somut sorun da şu: aynı "isim tekrar etmesin" kuralını yarın bir toplu import endpoint'inde ya da bir arka plan işinde de uygulaman gerekecek. Kural controller'ın içindeyse ya kopyalarsın ya da bir controller'dan diğerini çağırmaya kalkarsın. İkisi de kötü.

Service Katmanı: Önce Sözleşme

İşi devredeceğimiz yeri önce bir interface olarak tanımlıyoruz:

public interface IProductService
{
    Task<List<ProductDto>> GetAllAsync();
    Task<ProductDto?> GetByIdAsync(int id);
    Task<ProductDto?> CreateAsync(CreateProductRequest request);
    Task<bool> UpdateAsync(int id, UpdateProductRequest request);
    Task<bool> DeleteAsync(int id);
}

Dikkat et: bu imzalarda HTTP'ye dair hiçbir şey yok. IActionResult, NotFound, status code yok. Service "ürün bulunamadı" bilgisini null ya da false ile verir; bunun 404'e mi, 409'a mı dönüşeceğine controller karar verir. Bu ayrım önemli, çünkü aynı service'i yarın HTTP'nin hiç olmadığı bir yerden, mesela bir background job'dan da çağırabilmelisin.

Bir şey daha: service DTO alıp DTO dönüyor. Entity'ler service'in içinde kalıyor; controller Product class'ını hiç görmüyor.

ProductService'i Yazmak

public class ProductService : IProductService
{
    // EF Core'un SQL'e çevirebildiği tek bir projection, her sorguda tekrar kullanılıyor
    private static readonly Expression<Func<Product, ProductDto>> ToDto =
        p => new ProductDto(p.Id, p.Name, p.Price, p.Category!.Name);

    private readonly AppDbContext _db;

    public ProductService(AppDbContext db)
    {
        _db = db;
    }

    public async Task<List<ProductDto>> GetAllAsync()
    {
        return await _db.Products
            .AsNoTracking()
            .OrderBy(p => p.Id)
            .Select(ToDto)
            .ToListAsync();
    }

    public async Task<ProductDto?> GetByIdAsync(int id)
    {
        return await _db.Products
            .AsNoTracking()
            .Where(p => p.Id == id)
            .Select(ToDto)
            .FirstOrDefaultAsync();
    }

    public async Task<ProductDto?> CreateAsync(CreateProductRequest request)
    {
        // iş kuralı: aynı isimde ikinci bir ürün olamaz
        bool nameTaken = await _db.Products.AnyAsync(p => p.Name == request.Name);
        if (nameTaken)
        {
            return null;
        }

        Product product = new()
        {
            Name = request.Name,
            Price = request.Price,
            CategoryId = request.CategoryId
        };

        _db.Products.Add(product);
        await _db.SaveChangesAsync();

        return await GetByIdAsync(product.Id);
    }

    public async Task<bool> UpdateAsync(int id, UpdateProductRequest request)
    {
        Product? product = await _db.Products.FindAsync(id);
        if (product is null)
        {
            return false;
        }

        product.Name = request.Name;
        product.Price = request.Price;
        await _db.SaveChangesAsync();
        return true;
    }

    public async Task<bool> DeleteAsync(int id)
    {
        Product? product = await _db.Products.FindAsync(id);
        if (product is null)
        {
            return false;
        }

        _db.Products.Remove(product);
        await _db.SaveChangesAsync();
        return true;
    }
}

En üstteki ToDto alanına bak. Tipi Func değil, Expression<Func<Product, ProductDto>>. Fark büyük: Func derlenmiş bir koddur, EF Core onun içine bakamaz. Expression ise kodun ağaç hâlidir; EF Core onu okuyup yalnızca Id, Name, Price ve kategori adını çeken bir SQL üretebilir. Böylece projection tek bir yerde yaşıyor ve hâlâ veritabanında çalışıyor. Bu, DTO'lar ve mapping konusunda elle yazdığımız mapping'in doğal devamı.

İnce Controller ve Kayıt

Controller artık yalnızca HTTP'yi konuşuyor:

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IProductService _productService;

    public ProductsController(IProductService productService)
    {
        _productService = productService;
    }

    [HttpGet]
    public async Task<ActionResult<List<ProductDto>>> GetAll()
    {
        return await _productService.GetAllAsync();
    }

    [HttpGet("{id:int}")]
    public async Task<ActionResult<ProductDto>> GetById(int id)
    {
        ProductDto? product = await _productService.GetByIdAsync(id);
        if (product is null)
        {
            return NotFound();
        }

        return product;
    }

    [HttpPost]
    public async Task<ActionResult<ProductDto>> Create(CreateProductRequest request)
    {
        ProductDto? created = await _productService.CreateAsync(request);
        if (created is null)
        {
            return Conflict($"A product named '{request.Name}' already exists.");
        }

        return CreatedAtAction(nameof(GetById), new { id = created.Id }, created);
    }

    [HttpPut("{id:int}")]
    public async Task<IActionResult> Update(int id, UpdateProductRequest request)
    {
        bool updated = await _productService.UpdateAsync(id, request);
        return updated ? NoContent() : NotFound();
    }

    [HttpDelete("{id:int}")]
    public async Task<IActionResult> Delete(int id)
    {
        bool deleted = await _productService.DeleteAsync(id);
        return deleted ? NoContent() : NotFound();
    }
}

Her action aynı kalıbı izliyor: service'i çağır, sonucu bir status code'a çevir. Controller'da artık using Microsoft.EntityFrameworkCore; satırına bile gerek yok. Kaydı Program.cs'te yapıyoruz:

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlite(builder.Configuration.GetConnectionString("Catalog")));
builder.Services.AddScoped<IProductService, ProductService>();

Neden AddScoped? Çünkü AddDbContext DbContext'i scoped kaydeder ve ona bağımlı olan service'in ömrü ondan uzun olmamalı. İşte baştaki sorunun cevabı. AddSingleton yazarsan Development'ta uygulama hiç ayağa kalkmaz; builder.Build() sırasında şu hatayı alırsın:

Unhandled exception. System.AggregateException: Some services are not able to be constructed (Error while validating the service descriptor 'ServiceType: ProductCatalog.Services.IProductService Lifetime: Singleton ImplementationType: ProductCatalog.Services.ProductService': Cannot consume scoped service 'ProductCatalog.Data.AppDbContext' from singleton 'ProductCatalog.Services.IProductService'.)

Bu, dependency injection konusunda gördüğümüz scope validation. Development'ta açık, Production'da kapalı. Production'da uygulama sorunsuz açılır ve tek bir AppDbContext instance'ı bütün isteklere paylaştırılır. Aynı anda 200 istek attığımız denemede isteklerin çoğu 500 döndü ve log şu satırla doldu:

System.InvalidOperationException: A second operation was started on this context instance before a previous operation completed. This is usually caused by different threads concurrently using the same instance of DbContext. For more information on how to avoid threading issues with DbContext, see https://go.microsoft.com/fwlink/?linkid=2097913.

DbContext thread-safe değildir; her request kendi instance'ını almalı.

Repository Pattern: EF Core Zaten Bunu Yapıyor

Katmanlı mimari denince çoğu projede ilk iş şu interface'i yazmak olur:

public interface IRepository<T> where T : class
{
    IEnumerable<T> GetAll();
    T? GetById(int id);
    void Add(T entity);
    void Remove(T entity);
    void Save();
}

Sonra her entity için bir ProductRepository, CategoryRepository gelir ve service bunların üstüne oturur. Kulağa düzenli geliyor, ama EF Core ile birlikte kullanıldığında ciddi sorunları var.

Birincisi, DbSet<Product> zaten bir repository: ekleme, silme, sorgulama onun işi. DbContext de zaten bir Unit of Work: yaptığın bütün değişiklikleri takip eder ve SaveChangesAsync ile tek transaction'da yazar. Üstüne generic repository koyduğunda aynı soyutlamayı ikinci kez, daha zayıf hâliyle yazmış olursun.

İkincisi, IEnumerable<T> GetAll() imzası tuzaktır. "Fiyatı 1000'den büyük ilk 20 ürün" sorusu için bütün tabloyu belleğe çekip filtrelemeyi orada yaparsın. Bunu çözmek için IQueryable<T> dönersen bu kez EF Core'u soyutlamanın bütün anlamı kaybolur, çünkü sorgu yine dışarıda yazılıyor.

Üçüncüsü, her repository'nin kendi Save() method'u olunca "ürün ve kategori aynı transaction'da kaydedilsin" isteği karmaşıklaşır.

Repository'nin anlamlı olduğu durumlar da var: veriyi EF Core dışında bir yerden de okuyorsan (Dapper, harici bir API), domain modelini persistence'tan kesin çizgilerle ayıran bir mimari kuruyorsan ya da karmaşık sorguları isimlendirip tek yerde toplamak istiyorsan. O zaman bile generic değil, ihtiyaca göre yazılmış bir IProductRepository (GetBestSellersAsync gibi method'larla) daha iyi hizmet eder. Bu seride service'ler AppDbContext'i doğrudan kullanacak; EF Core tarafının ayrıntılarını EF Core serisinde detaylandırıyoruz.

Interface'ler ve Test Edilebilirlik

Controller'ın ProductService'e değil IProductService'e bağımlı olmasının en somut faydası test anında ortaya çıkar. Veritabanı olmadan controller'ı denemek için sahte bir service yazman yeter:

public class FakeProductService : IProductService
{
    private readonly List<ProductDto> _products = new()
    {
        new ProductDto(1, "Mechanical Keyboard", 1450m, "Peripherals")
    };

    public Task<List<ProductDto>> GetAllAsync() => Task.FromResult(_products);

    public Task<ProductDto?> GetByIdAsync(int id) =>
        Task.FromResult(_products.FirstOrDefault(p => p.Id == id));

    public Task<ProductDto?> CreateAsync(CreateProductRequest request) =>
        Task.FromResult<ProductDto?>(null);

    public Task<bool> UpdateAsync(int id, UpdateProductRequest request) => Task.FromResult(false);

    public Task<bool> DeleteAsync(int id) => Task.FromResult(false);
}
ProductsController controller = new(new FakeProductService());
ActionResult<ProductDto> result = await controller.GetById(42);
Console.WriteLine(result.Result?.GetType().Name);
// NotFoundResult

Ne veritabanı ne HTTP sunucusu var; controller'ın "ürün yoksa 404" davranışını milisaniyeler içinde doğruladık. Bunu ilerleyen yazılarda xUnit ve Moq ile düzgün unit test'lere çevireceğiz. Burada önemli olan ilke: interface, bağımlılığın yerine başka bir şey takabileceğin bir prizdir.

Her class'a refleks olarak interface açmana gerek yok. Değiştirmek ya da testte sahtesini kullanmak isteyeceğin sınırlar için açman yeterli. Service katmanı tam olarak böyle bir sınır.

Klasör Yapısı ve Proje Ayırma

Bu noktada ProductCatalog projesi şöyle görünüyor:

ProductCatalog/
  Controllers/ProductsController.cs
  Data/AppDbContext.cs
  Dtos/ProductDto.cs
  Dtos/CreateProductRequest.cs
  Dtos/UpdateProductRequest.cs
  Models/Category.cs
  Models/Product.cs
  Services/IProductService.cs
  Services/ProductService.cs
  Program.cs

Bir sonraki adım genellikle bu klasörleri ayrı projelere bölmek olur: ProductCatalog.Api, ProductCatalog.Application, ProductCatalog.Infrastructure. İki seçenek ve tradeoff'ları şöyle: Tek proje içinde klasörler hızlıdır, gezinmesi kolaydır, ama katmanlar arası sınırı yalnızca disiplin korur; bir controller yine AppDbContext'i inject edebilir. Ayrı projeler ise sınırı derleyiciye korutur, çünkü Api projesi Infrastructure'ı referans etmiyorsa kimse AppDbContext'e erişemez; bedeli daha fazla proje, daha fazla referans ve küçük bir API için gereksiz tören. Orta büyüklükte bir API'ye tek projeyle başlayıp sınırlar gerçekten zorlanmaya başlayınca bölmek çoğu zaman en ucuz yoldur.

Sık Düşülen Tuzaklar

Service'ten IActionResult dönmek. return NotFound() service'in içine girdiği an service HTTP'ye bağlanır ve background job'dan çağrılamaz hâle gelir. Service sonucu söyler, controller onu HTTP'ye çevirir.

Mapping'i Select(p => p.ToDto()) gibi sıradan bir method ile yapmak. EF Core o method'un içine bakamaz; son Select'i bellekte çalıştırır ve p.Category yüklenmediği için kategori adı boş gelir. Projection'ı Expression olarak tutmak bu yüzden önemli.

Service'i singleton kaydetmek. Development'ta açılış hatası alırsın, Production'da ise uygulama açılıp ilk yoğunlukta "A second operation was started" hatasıyla dağılır. DbContext'e bağımlı her şey scoped olmalı.

null'a çok fazla anlam yüklemek. Bugün CreateAsync'in null dönmesi "isim zaten var" demek. Yarın "kategori yok" kuralını da eklersen null iki şey anlatmaya başlar ve controller hangisi olduğunu bilemez. Bu sınıra geldiğinde iki seçeneğin var: sonucu ve hata türünü taşıyan küçük bir result tipi (açık ama her çağrıda kontrol ister) ya da domain exception'ları ve onları tek merkezde status code'a çeviren bir yapı (çağıran kod temiz kalır, ama akış exception üzerinden ilerler). İkinci yolu global hata yönetimi yazısında kuracağız.

Her şeyi generic repository'nin arkasına saklamak. EF Core'un Include, projection ve AsNoTracking gibi güçlü taraflarını en az ortak paydaya indirirsin.

Küçük Bir Deneme

★☆☆ Program.cs'te AddScoped'u AddSingleton yap, uygulamayı Development'ta başlat ve hatayı kendi gözünle oku. Sonra AddTransient dene; neden hata almadığını ve yine de neden scoped'un daha doğru seçim olduğunu düşün.

★★☆ CategoryService ve ICategoryService yaz: kategorileri listeleyen ve her kategorideki ürün sayısını dönen bir GetAllAsync. Sayımı bellekte değil SQL'de yaptığından emin ol.

★★★ CreateAsync'e "kategori yoksa ürün oluşturulamaz" kuralını ekle. Controller'ın 409 ile 400'ü ayırt edebilmesi için ne değiştirmen gerektiğini bul ve iki farklı çözümü karşılaştır.

Service method'larının hepsi async ve Task dönüyor; ama bir istemci isteği yarıda bırakıp giderse arkada çalışan sorguya ne oluyor? Bir sonraki yazıda async/await'in ASP.NET Core'da gerçekte ne kazandırdığına bakacak ve CancellationToken'ı action'dan EF Core sorgusuna kadar taşıyacağız.