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

ASP.NET Core'da Controller ve Action: [ApiController] ile CRUD İskeleti

Mert Özen 4 Kas 2026 9 dk 1 görüntülenme
ASP.NET Core'da Controller ve Action: [ApiController] ile CRUD İskeleti

ControllerBase ve Controller farkını, [ApiController] attribute'unun getirdiği dört davranışı, IActionResult ile ActionResult<T> arasındaki seçimi ve bellekte duran bir listeyle çalışan CRUD iskeletini gerçek curl çıktılarıyla anlatıyoruz.

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

Birazdan yazacağımız ProductsController'ın tepesinde iki attribute olacak: [ApiController] ve [Route("api/[controller]")]. Şimdi şunu düşün: [ApiController] satırını silip {"name":"USB-C Hub","price":890} body'siyle bir POST isteği atarsan ne olur? Uygulama exception mı fırlatır, 400 mü döner, yoksa her şey yolunda mı gider? Tahminini aklının bir köşesinde tut, yazının ortasında gerçek çıktıya bakacağız.

Controller Aslında Ne?

Daha önce routing'i konuşurken bir URL'in bir action'a nasıl eşlendiğini gördük. Şimdi o eşleşmenin öbür ucuna bakıyoruz. Controller, birbiriyle ilgili endpoint'leri tek bir class altında toplayan yapı. Ürünlerle ilgili her şey ProductsController'da, siparişlerle ilgili her şey OrdersController'da durur. Class'ın içindeki public method'lar da action'lardır; her biri tek bir isteği karşılayan küçük bir iş birimi.

Bir restoran mutfağı düşün. Controller bir istasyon: ızgara, tatlı, salata. Action ise o istasyondaki tek bir tarif. Routing de siparişi alıp doğru istasyona, doğru tarife götüren garson.

ASP.NET Core'da iki base class var: ControllerBase ve Controller. Controller, ControllerBase'den türer ve üstüne Razor view desteği ekler: View(), PartialView(), ViewBag gibi. Web API'de HTML üretmediğimiz için bunların hiçbirine ihtiyacımız yok. API controller'ları ControllerBase'den türetilir; Ok(), NotFound(), CreatedAtAction() gibi yardımcıların hepsi zaten orada.

Framework controller'ları kendisi bulur. Program.cs'te gördüğümüz iki satır bu işin tamamı: builder.Services.AddControllers() controller altyapısını kaydeder, app.MapControllers() da attribute route'larını endpoint'e çevirir. Bir class'ın controller sayılması için public, abstract olmayan bir class olması ve adının Controller ile bitmesi ya da [Controller] attribute'unu taşıması yeterli. ControllerBase bu attribute'u zaten taşıdığı için ondan türeyen her class, adı ne olursa olsun controller sayılır. Controllers klasörü de sadece bir gelenek; framework klasöre bakmaz.

İlk Controller: ProductsController

Örnek alanımız küçük bir ProductCatalog API. Önce model:

namespace ProductCatalog.Models;

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
}

Şimdi controller'ın okuma tarafı:

using Microsoft.AspNetCore.Mvc;
using ProductCatalog.Models;

namespace ProductCatalog.Controllers;

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    // Veritabanı gelene kadar geçici, bellekte duran bir liste
    private static readonly List<Product> Products = new()
    {
        new Product { Id = 1, Name = "Mechanical Keyboard", Price = 1450m },
        new Product { Id = 2, Name = "Wireless Mouse", Price = 520m }
    };

    [HttpGet]
    public ActionResult<IEnumerable<Product>> GetAll()
    {
        return Products;
    }

    [HttpGet("{id:int}")]
    public ActionResult<Product> GetById(int id)
    {
        Product? product = Products.FirstOrDefault(p => p.Id == id);
        if (product is null)
        {
            return NotFound();
        }

        return product;
    }
}

[Route("api/[controller]")] içindeki [controller] token'ını routing yazısından hatırlıyorsun; class adından Controller son eki atılır ve route api/Products olur. [HttpGet("{id:int}")] bu route'un üstüne {id} segment'ini ekler, :int constraint'i de sayı olmayan değerleri daha routing aşamasında eler.

Listenin static olması bilinçli bir tercih. Sebebini tuzaklar bölümünde göreceğiz; şimdilik bu listenin bir veritabanı taklidi olduğunu bilmen yeterli. Gerçek veri katmanını EF Core yazısında kuracağız.

[ApiController] Sana Neler Kazandırıyor

[ApiController] tek satır ama arkasında dört davranış var.

Birincisi, attribute routing'i zorunlu kılar. Bu attribute'u taşıyan bir controller'da route'u olmayan bir action varsa uygulama daha ayağa kalkarken durur:

Unhandled exception. System.InvalidOperationException: Action 'ProductCatalog.Controllers.CategoriesController.GetAll (ProductCatalog)' does not have an attribute route. Action methods on controllers annotated with ApiControllerAttribute must be attribute routed.

İkincisi, binding source inference. Action parametresinin nereden okunacağını tipine ve adına bakarak kendisi çıkarır: Product gibi complex bir tip body'den, route template'inde adı geçen id route'tan, geri kalan basit tipler query string'den gelir. Bu kuralların ayrıntısını model binding yazısında açacağız.

Üçüncüsü, otomatik 400. Model binding ya da validation hata ürettiyse action hiç çalışmaz, framework doğrudan 400 Bad Request ve hataları anlatan bir JSON döner. Validation'ı ayrı bir yazıda ele alacağız.

Dördüncüsü, 400 ve üstü status code'lar için ProblemDetails formatında body üretir. NotFound() dediğinde boş bir 404 yerine birazdan göreceğin JSON'u almanın sebebi bu.

Şimdi baştaki soruya dönelim. [ApiController] olmadan aynı POST isteğinin gerçek yanıtı şu:

HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: http://localhost:5080/api/Products/3

{"id":3,"name":"","price":0}

Ne exception ne 400. İstek başarılı görünüyor, listeye yeni bir ürün bile eklendi, ama gönderdiğin veri kaybolmuş. Attribute olmadan complex tip varsayılan olarak body'den okunmaz; framework form, route ve query string'e bakar, hiçbirinde değer bulamaz ve boş bir Product üretir. Otomatik 400 de olmadığı için action bu boş nesneyle mutlu mesut çalışır. "price":"abc" gibi bozuk bir body göndersen bile sonuç aynı, çünkü body'ye hiç bakılmıyor. Sessiz hata, gürültülü hatadan her zaman daha pahalıdır; bu yüzden API controller'larında [ApiController] pazarlık konusu değil.

Action Dönüş Tipleri: Somut Tip, IActionResult, ActionResult<T>

Bir action'ın dönüş tipi için üç seçeneğin var:

// 1) Somut tip: sade ama sadece 200 dönebilir
[HttpGet("concrete")]
public List<Product> GetConcrete()
{
    return Products;
}

// 2) IActionResult: her status code'u dönebilir ama veri tipi görünmez
[HttpGet("iaction/{id:int}")]
public IActionResult GetIAction(int id)
{
    Product? product = Products.FirstOrDefault(p => p.Id == id);
    if (product is null)
    {
        return NotFound();
    }

    return Ok(product);
}

// 3) ActionResult<T>: ikisinin iyi tarafı bir arada
[HttpGet("{id:int}")]
public ActionResult<Product> GetById(int id)
{
    Product? product = Products.FirstOrDefault(p => p.Id == id);
    if (product is null)
    {
        return NotFound();
    }

    return product;
}

Somut tip en sade olanı ama elin kolun bağlı: ürün bulunamadığında 404 dönmenin temiz bir yolu yok. IActionResult her türlü sonucu döndürebilir, fakat imzaya bakan kimse başarılı durumda ne geleceğini bilemez. Swagger gibi araçlar da bilemez; o yüzden her action'a ayrıca response tipini yazman gerekir.

ActionResult<T> ikisini birleştirir. İmzada başarılı durumun tipi yazar, gövdede ise hem NotFound() hem düz return product; dönebilirsin. İkincisi bir implicit conversion sayesinde çalışır: Product sessizce ActionResult<Product>'a çevrilir ve 200 ile JSON olarak yazılır. Benim varsayılanım bu: veri dönen action'larda ActionResult<T>, veri dönmeyenlerde (PUT, DELETE) IActionResult.

CRUD İskeletini Tamamlayalım

Okuma tarafı hazır. Aynı controller'a yazma action'larını ekleyelim:

[HttpPost]
public ActionResult<Product> Create(Product product)
{
    product.Id = Products.Count == 0 ? 1 : Products.Max(p => p.Id) + 1;
    Products.Add(product);
    return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
}

[HttpPut("{id:int}")]
public IActionResult Update(int id, Product product)
{
    Product? existing = Products.FirstOrDefault(p => p.Id == id);
    if (existing is null)
    {
        return NotFound();
    }

    existing.Name = product.Name;
    existing.Price = product.Price;
    return NoContent();
}

[HttpDelete("{id:int}")]
public IActionResult Delete(int id)
{
    Product? existing = Products.FirstOrDefault(p => p.Id == id);
    if (existing is null)
    {
        return NotFound();
    }

    Products.Remove(existing);
    return NoContent();
}

Create parametresinde hiçbir [FromBody] yok; [ApiController] complex tipi body'den okuyacağını kendisi biliyor. Update'te ise iki kaynak bir arada: id route'tan, product body'den. CreatedAtAction 201 dönerken yeni kaynağın adresini de üretiyor; nameof(GetById) kullanmamızın sebebi action adını string olarak elle yazıp yazım hatasına kapı açmamak. Bu yardımcının nasıl çalıştığını bir sonraki yazıda ayrıntısıyla göreceğiz.

İstekleri Atıp Sonuçlara Bakalım

Uygulamayı dotnet run ile çalıştır ve terminalden sırayla şu istekleri at. Port numarası senin launchSettings.json'una göre farklı olabilir:

curl -i http://localhost:5080/api/products/99
curl -i -X POST http://localhost:5080/api/products -H "Content-Type: application/json" -d '{"name":"USB-C Hub","price":890}'
curl -i -X PUT http://localhost:5080/api/products/3 -H "Content-Type: application/json" -d '{"name":"USB-C Hub Pro","price":990}'
curl -i -X DELETE http://localhost:5080/api/products/3

Yanıtlar şöyle geldi (Date ve Server header'larını kısalık için çıkardım):

HTTP/1.1 404 Not Found
Content-Type: application/problem+json; charset=utf-8

{"type":"https://tools.ietf.org/html/rfc7231#section-6.5.4","title":"Not Found","status":404,"traceId":"00-39414595185de15ab0136802b17ea06a-6d5ff1e21dae1feb-00"}

HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: http://localhost:5080/api/Products/3

{"id":3,"name":"USB-C Hub","price":890}

HTTP/1.1 204 No Content

HTTP/1.1 204 No Content

Dikkat edilecek iki şey var. 404'ün body'si boş değil; [ApiController] onu ProblemDetails'e çevirdi ve content type'ı application/problem+json oldu. .NET 10'da aynı kod type alanına RFC 9110'un adresini yazıyor, bu farkı ilerleyen sürüm yazılarında göreceğiz. Location header'ında ise Products büyük harfle geliyor, çünkü token class adından üretiliyor. URL path'leri routing'de büyük-küçük harf duyarlı olmadığı için bu adres sorunsuz çalışır.

Sık Düşülen Tuzaklar

Listeyi static yerine instance field yaparsan şuna benzer bir controller elde edersin:

[ApiController]
[Route("api/instance-products")]
public class InstanceListController : ControllerBase
{
    // Her istekte yeni controller, dolayısıyla yeni ve boş bir liste
    private readonly List<Product> _products = new();

    [HttpPost]
    public ActionResult<Product> Create(Product product)
    {
        _products.Add(product);
        return product;
    }

    [HttpGet]
    public ActionResult<IEnumerable<Product>> GetAll()
    {
        return _products;
    }
}

POST 200 döner, hemen ardından atılan GET ise []. Sebep basit: framework her istek için controller'dan yeni bir instance üretir ve istek bitince onu bırakır. Controller'da tutulan state, isteğin ömrü kadar yaşar. Static liste bu sorunu çözer ama onun da bedeli var: List<T> thread-safe değil ve aynı anda gelen iki POST onu bozabilir. Demo için idare eder; kalıcı çözüm veritabanı ve dependency injection.

İkinci tuzak, controller'a yardımcı olsun diye public bir method eklemek:

public string ToSlug(string name)
{
    return name.Trim().ToLowerInvariant().Replace(" ", "-");
}

Bu method HTTP attribute'u taşımadığı için zararsız görünür. Değil. Controller seviyesindeki route'u miras alır ve herhangi bir HTTP method'u kabul eden bir action olur. curl -X PATCH "http://localhost:5080/api/products?name=USB%20Hub" isteği 200 ve usb-hub döner; yani iç yardımcın dışarıya açılmış olur. Ya private yap ya da [NonAction] ile işaretle.

Üçüncüsü, ActionResult<IEnumerable<Product>> dönen bir action'da LINQ sonucunu doğrudan döndürmek:

[HttpGet("expensive")]
public ActionResult<IEnumerable<Product>> GetExpensive()
{
    return Products.Where(p => p.Price > 1000);
}

Derleyici bunu kabul etmez:

error CS0029: Cannot implicitly convert type 'System.Collections.Generic.IEnumerable<ProductCatalog.Models.Product>' to 'Microsoft.AspNetCore.Mvc.ActionResult<System.Collections.Generic.IEnumerable<ProductCatalog.Models.Product>>'

C# interface'ten yapılan user-defined implicit conversion'lara izin vermiyor ve Where bir interface döndürüyor. Sonuna .ToList() eklediğinde elinde somut bir tip olur ve conversion çalışır. GetAll'da return Products; diyebilmemizin sebebi de bu: Products zaten bir List<Product>.

Küçük Bir Deneme

★☆☆ Bir Category modeli (Id, Name) ve bellekte iki kategori tutan bir CategoriesController yaz. GET api/categories ve GET api/categories/{id} çalışsın, olmayan id için 404 dönsün.

★★☆ ProductsController'a en ucuz ürünü dönen GET api/products/cheapest action'ını ekle. Sonra {id:int} constraint'ini silip aynı isteği tekrar at ve ne değiştiğini gözle.

★★★ [ApiController] satırını geçici olarak kaldır ve {"name":"USB-C Hub","price":"abc"} body'siyle POST at. Sonra attribute'u geri koyup aynı isteği tekrarla. İki yanıtı yan yana koy ve farkın hangi davranıştan geldiğini kendi cümlelerinle yaz.

Bu yazıda Ok, NotFound, NoContent ve CreatedAtAction'ı kullandık ama çoğunun içini açmadık. 404'ün body'sindeki o JSON tam olarak ne anlatıyor, Location header'ı nasıl üretiliyor, hangi durumda 409 dönmek gerekiyor? Bir sonraki yazıda status code'ları ve response'ları masaya yatırıyoruz.