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

.NET 8 ile ASP.NET Core Yenilikleri: Keyed DI, IExceptionHandler, Identity API ve Native AOT

Mert Özen 4 Kas 2026 9 dk 2 görüntülenme
.NET 8 ile ASP.NET Core Yenilikleri: Keyed DI, IExceptionHandler, Identity API ve Native AOT

.NET 8 ile gelen keyed DI, IExceptionHandler, MapIdentityApi, ShortCircuit, TimeProvider ve Native AOT'u .NET 6 karşılaştırmasıyla anlatıyor; C# 12'ye, geçiş notlarına ve 10 Kasım 2026 destek bitişine bakıyoruz.

Sürüm notu: Bu yazı .NET 8 ile gelen yenilikleri anlatır; kodlar .NET 8 içindir.

.NET 8: Sahadaki En Yaygın LTS

14 Kasım 2023'te çıkan .NET 8, .NET 6'dan sonraki ilk LTS sürümü. JetBrains'in 2025 anketinde katılımcıların yarıdan fazlası .NET 8 kullandığını söylüyor; yani bugün devraldığın bir API'nin .NET 8 üzerinde olma ihtimali çok yüksek. .NET 7'nin minimal API tarafında attığı adımların üstüne bu sürüm DI'a, hata yönetimine, authentication'a ve performansa dokunuyor. C# 12 de aynı paketle geldi.

Bu yazıda keyed DI'ı, IExceptionHandler'ı, Identity API endpoint'lerini, short-circuit routing'i, TimeProvider'ı, Native AOT'u ve template'teki değişikliği .NET 6 karşılaştırmasıyla göreceğiz. En sonda da geçiş notları ve destek tarihiyle ilgili önemli bir hatırlatma var.

Önce Tahmin Et

Aynı interface için iki implementasyonu art arda kaydediyorsun: önce EmailNotificationService, sonra SmsNotificationService. Bir endpoint parametre olarak INotificationService istiyor. Hangisi gelir: ilk kaydedilen mi, son kaydedilen mi, yoksa exception mı?

Keyed DI ve [FromKeyedServices]

Sorunun cevabı: son kaydedilen. .NET 6'da bu kodu çalıştırdığımda /ship endpoint'i "SMS sent for order 42" döndü. Email'e ihtiyacın varsa elinde pek seçenek yoktu:

builder.Services.AddSingleton<INotificationService, EmailNotificationService>();
builder.Services.AddSingleton<INotificationService, SmsNotificationService>();

var app = builder.Build();

app.MapPost("/api/orders/{id:int}/ship", (int id, INotificationService notifier) =>
    Results.Ok(notifier.Send(id)));

app.MapPost("/api/orders/{id:int}/confirm", (int id, IEnumerable<INotificationService> notifiers) =>
{
    // Doğru implementasyonu tipine bakarak seçmek zorundayız
    var email = notifiers.OfType<EmailNotificationService>().Single();
    return Results.Ok(email.Send(id));
});

Tüm listeyi isteyip somut tipe göre filtrelemek, interface kullanmanın anlamını ortadan kaldırıyor. Alternatifler ya bir factory class'ı ya da Autofac gibi üçüncü parti bir container'dı. .NET 8 bunu yerleşik container'a taşıdı:

builder.Services.AddKeyedSingleton<INotificationService, EmailNotificationService>("email");
builder.Services.AddKeyedSingleton<INotificationService, SmsNotificationService>("sms");

// Minimal API: key parametrede
app.MapPost("/api/orders/{id:int}/ship", (int id, [FromKeyedServices("sms")] INotificationService notifier) =>
    TypedResults.Ok(notifier.Send(id)));

// Controller: key constructor parametresinde
[ApiController]
[Route("api/orders")]
public class OrdersController([FromKeyedServices("email")] INotificationService notifier) : ControllerBase
{
    [HttpPost("{id:int}/confirm")]
    public IActionResult Confirm(int id)
    {
        return Ok(notifier.Send(id));
    }
}

/ship artık SMS sent for order 42, /confirm da Email sent for order 42 dönüyor. AddKeyedScoped ve AddKeyedTransient da var; lifetime kuralları daha önce gördüklerimizle aynı. Key bir string olmak zorunda değil, herhangi bir object olabiliyor; enum kullanmak yazım hatalarına karşı iyi bir sigorta.

C# 12: Primary Constructor ve Collection Expression

Controller'ın tanımı dikkatini çektiyse, o C# 12'nin primary constructor'ı. Class adının yanındaki parametre listesi constructor'ın kendisi; .NET 6'da yazdığımız private readonly field ve atama satırları gitti. Aynı yeniliğin service ve exception class'larındaki hâli:

public class ProductNotFoundException(int id) : Exception($"Product {id} was not found.");

public class InMemoryProductService : IProductService
{
    // C# 12 collection expression
    private readonly List<Product> _products =
    [
        new() { Id = 1, Name = "Mechanical Keyboard", Price = 1499.90m },
        new() { Id = 2, Name = "Wireless Mouse", Price = 649.00m }
    ];

    public Product GetById(int id) =>
        _products.FirstOrDefault(p => p.Id == id) ?? throw new ProductNotFoundException(id);
}

Köşeli parantezli [...] syntax'ı array'ler, List<T> ve Span<T> gibi koleksiyonlar için ortak bir yazım sağlıyor; new List<Product> { ... } demeye gerek kalmıyor. Primary constructor parametrelerinin bir record'daki gibi property'ye dönüşmediğini bil: sadece class'ın içinde kullanılabilen parametreler olarak kalıyorlar.

IExceptionHandler ile Hata Yönetimi

Global hata yönetimi yazısında domain exception'larını status code'lara eşlemek için kendi middleware'imizi yazmıştık. .NET 8 bunun için bir sözleşme getirdi:

using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;

namespace ProductCatalog.Api;

public class GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
    {
        // Tanımadığımız exception'ı bir sonraki handler'a bırak
        if (exception is not ProductNotFoundException)
        {
            return false;
        }

        logger.LogWarning("Handled: {Message}", exception.Message);

        httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
        await httpContext.Response.WriteAsJsonAsync(new ProblemDetails
        {
            Status = StatusCodes.Status404NotFound,
            Title = "Product not found",
            Detail = exception.Message
        }, options: null, contentType: "application/problem+json", cancellationToken);

        return true;
    }
}

// Program.cs
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();

app.UseExceptionHandler();

TryHandleAsync true dönerse iş biter. false dönerse sıradaki handler denenir; birden fazla handler kaydedebiliyorsun ve kayıt sırasıyla çalışıyorlar. Hiçbiri üstlenmezse AddProblemDetails'ın varsayılan 500 yanıtı devreye giriyor. İki isteğin sonucu:

GET /api/products/99
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{"title":"Product not found","status":404,"detail":"Product 99 was not found."}

GET /boom
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json
{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.1","title":"An error occurred while processing your request.","status":500}

Handler bir service olduğu için constructor injection alıyor, test edilebiliyor ve middleware sırasını düşünmeni gerektirmiyor.

Identity API Endpoint'leri

.NET 6'da bir SPA ya da mobil uygulama için login yazmak istediğinde ya JWT üretimini kendin kuruyordun ya da IdentityServer gibi ağır bir çözüme gidiyordun. .NET 8, ASP.NET Core Identity'ye JSON konuşan hazır endpoint'ler ekledi:

public class AppDbContext(DbContextOptions<AppDbContext> options) : IdentityDbContext<IdentityUser>(options)
{
}

// Program.cs
builder.Services.AddDbContext<AppDbContext>(options => options.UseInMemoryDatabase("ProductCatalog"));
builder.Services.AddAuthorization();
builder.Services.AddIdentityApiEndpoints<IdentityUser>()
    .AddEntityFrameworkStores<AppDbContext>();

app.MapGroup("/account").MapIdentityApi<IdentityUser>();

app.MapGet("/api/me", (ClaimsPrincipal user) => TypedResults.Ok(user.Identity!.Name))
    .RequireAuthorization();

Örnekte InMemory provider'ı kullandım; gerçek bir veritabanı ve migration'lar EF Core serisinin konusu. MapIdentityApi tek satırda /register, /login, /refresh, e-posta onayı, şifre sıfırlama ve iki adımlı doğrulama endpoint'lerini açıyor. Denedim:

POST /account/register  {"email":"ada@example.com","password":"Passw0rd!"}
HTTP/1.1 200 OK

POST /account/register  {"email":"alan@example.com","password":"123"}
HTTP/1.1 400 Bad Request
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"PasswordTooShort":["Passwords must be at least 6 characters."],"PasswordRequiresNonAlphanumeric":["Passwords must have at least one non alphanumeric character."],"PasswordRequiresLower":["Passwords must have at least one lowercase ('a'-'z')."],"PasswordRequiresUpper":["Passwords must have at least one uppercase ('A'-'Z')."]}}

POST /account/login  {"email":"ada@example.com","password":"Passw0rd!"}
{"tokenType":"Bearer","accessToken":"CfDJ8EINq_wm4TFC...","expiresIn":3600,"refreshToken":"..."}

Token'sız /api/me 401 döndü, Authorization: Bearer ... header'ı ile "ada@example.com". Yanlış şifreyle login ise 401 ve "detail":"Failed".

ShortCircuit ve TimeProvider

Bazı istekler pipeline'ın tamamından geçmeyi hak etmiyor. Tarayıcıların otomatik istediği favicon.ico ya da load balancer'ın saniyede bir vurduğu liveness endpoint'i için authentication, logging ve diğer middleware'lerin çalışması boşa iş. .NET 8 ile bunları routing seviyesinde kesebiliyorsun:

app.MapShortCircuit(404, "robots.txt", "favicon.ico");
app.MapGet("/health/live", () => "Healthy").ShortCircuit();

favicon.ico isteği body'siz bir 404, /health/live da Healthy döndü. Endpoint eşleştiği anda yanıt yazılıyor ve sonraki middleware'ler hiç çalışmıyor.

İkinci küçük ama değerli yenilik TimeProvider. Saate bağlı bir kuralı DateTime.UtcNow ile yazınca test etmek için gerçekten akşam sekizi beklemek gerekiyordu. .NET 8'de zamanı da bir bağımlılık olarak alıyorsun:

public class FlashSaleService(TimeProvider timeProvider)
{
    private static readonly TimeSpan SaleStart = TimeSpan.FromHours(20);
    private static readonly TimeSpan SaleEnd = TimeSpan.FromHours(22);

    public bool IsActive()
    {
        var now = timeProvider.GetUtcNow().TimeOfDay;
        return now >= SaleStart && now < SaleEnd;
    }
}

// Program.cs
builder.Services.AddSingleton(TimeProvider.System);

// Test: Microsoft.Extensions.TimeProvider.Testing paketi
var time = new FakeTimeProvider(new DateTimeOffset(2024, 11, 29, 20, 30, 0, TimeSpan.Zero));
var sale = new FlashSaleService(time);
Assert.True(sale.IsActive());

time.Advance(TimeSpan.FromHours(2));
Assert.False(sale.IsActive());

Test yeşil geçti. FakeTimeProvider.Advance ile iki saati bir satırda atlıyorsun.

Template Değişikliği ve Native AOT

.NET 8 SDK ile dotnet new webapi artık varsayılan olarak minimal API üretiyor; .NET 6 ve 7'de controller'lı bir proje gelirdi. Controller'lı template'i istiyorsan seçeneği açıkça vermelisin:

dotnet new webapi -o ProductCatalog.Api
dotnet new webapi --use-controllers -o ProductCatalog.Api

Swagger tarafında bir değişiklik yok; iki template de hâlâ Swashbuckle.AspNetCore'u (6.6.2) içeriyor. Bu son kez böyle.

Daha radikal yenilik Native AOT desteği. Yeni webapiaot template'i, uygulamayı JIT'e ihtiyaç duymayan tek bir native dosyaya derliyor:

using System.Text.Json.Serialization;

var builder = WebApplication.CreateSlimBuilder(args);

builder.Services.ConfigureHttpJsonOptions(options =>
{
    options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default);
});

var app = builder.Build();

Product[] products =
[
    new(1, "Mechanical Keyboard", 1499.90m),
    new(2, "Wireless Mouse", 649.00m)
];

var api = app.MapGroup("/api/products");
api.MapGet("/", () => products);
api.MapGet("/categories", () => new Category(1, "Peripherals"));

app.Run();

public record Product(int Id, string Name, decimal Price);

public record Category(int Id, string Name);

// AOT'ta reflection yok: JSON'a giren her tip burada listelenmeli
[JsonSerializable(typeof(Product[]))]
[JsonSerializable(typeof(Category))]
internal partial class AppJsonSerializerContext : JsonSerializerContext
{
}

CreateSlimBuilder, AOT ile uyumsuz ya da nadiren gereken parçaları (IIS entegrasyonu, HTTPS, regex route constraint'leri gibi) dışarıda bırakan hafif bir builder. csproj'de <PublishAot>true</PublishAot> ile dotnet publish -c Release dediğimde macOS arm64 üzerinde yaklaşık 11 MB'lık tek bir çalıştırılabilir dosya çıktı. Bedeli kısıtlar: EF Core'un büyük kısmı, MVC controller'ları ve reflection'a dayanan kütüphaneler AOT ile çalışmıyor. Bu yüzden AOT'u küçük, hızlı açılması gereken service'ler için düşün.

.NET 8'e Geçiş

TargetFramework değeri net8.0'a, Microsoft.AspNetCore.*, Microsoft.Extensions.* ve EF Core paketleri 8.0 serisine çıkıyor; son yama 8.0.33. 6'dan geliyorsan 7'nin breaking change'lerini de üstlenmiş olursun. ASP.NET Core 8 listesinde Web API'yi en çok ilgilendirenler şunlar:

JWT bearer event'lerindeki context.SecurityToken artık JwtSecurityToken değil, JsonWebToken. OnTokenValidated içinde as JwtSecurityToken diye cast eden kod sessizce null almaya başlar; cast'i JsonWebToken'a çevir ya da UseSecurityTokenValidators = true ile eski davranışa dön. Container image'larında ASP.NET Core'un varsayılan portu 80'den 8080'e taşındı ve Linux image'larına root olmayan bir app kullanıcısı eklendi; Docker yazısındaki port eşlemelerini buna göre kontrol et. UseRateLimiter artık AddRateLimiter olmadan, UseHttpLogging da AddHttpLogging olmadan çalışmıyor. Ve minimal API'lerde IFormFile parametreleri artık antiforgery kontrolü istiyor.

Destek tarafında ise acil bir not var. .NET 8 LTS olarak üç yıl destek aldı ve bu destek 10 Kasım 2026'da, .NET 9 ile aynı gün bitiyor. Bu satırları Kasım 2026'nın ilk haftasında yazıyorum; yani .NET 8'de kalan projeler birkaç gün içinde güvenlik yaması alamaz hâle geliyor. O tarihten sonra desteklenen tek GA LTS sürüm .NET 10. 8'deysen planın 10'a geçmek olmalı; 9 bir ara durak bile değil, çünkü onun desteği de aynı gün bitiyor.

Sık Düşülen Tuzaklar

AOT'ta en sinsi hata, JsonSerializerContext'e bir tipi eklemeyi unutmak. Yukarıdaki örnekte Category satırını silip denedim. Normal dotnet run ile her şey çalıştı, çünkü JIT modunda serializer reflection'a geri düşüyor. AOT publish'ten sonra ise yalnızca /categories değil, /api/products dahil bütün endpoint'ler 500 döndü ve log'da şu vardı: System.NotSupportedException: JsonTypeInfo metadata for type 'Category' was not provided by TypeInfoResolver of type '[AppJsonSerializerContext]'. Derleme sırasında hiçbir uyarı çıkmadı. AOT'u CI'da gerçekten publish edip test etmeden production'a çıkma.

Keyed DI'da [FromKeyedServices]'ı unutmak da ilginç sonuç veriyor. Sadece keyed kayıt varken minimal API'de düz INotificationService parametresi istediğimde framework onu DI'da bulamadı ve body'den okumaya çalıştı: body'siz istekte 400, {} gönderince de Deserialization of interface types is not supported exception'ı ile 500.

MapIdentityApi'nin verdiği token bir JWT değil. Aldığım access token 710 karakterlik, içinde tek bir nokta bile olmayan opak bir değerdi; ASP.NET Core'un Data Protection'ı ile korunuyor. Yani başka bir service bu token'ı kendi başına doğrulayamaz. Birden fazla service'in aynı token'a güvenmesi gerekiyorsa JWT yazısındaki yaklaşıma ya da ayrı bir kimlik sağlayıcısına ihtiyacın var.

Son olarak IExceptionHandler'ın log davranışı. .NET 8'de handler'ın true döndüğü exception'lar bile exception handler middleware tarafından fail seviyesinde loglanıyor; benim log'umda önce bu satır, sonra handler'ın kendi uyarısı vardı. Beklenen bir 404 için error log'u görmek alarm sistemlerini gereksiz yere tetikleyebilir.

Küçük Bir Deneme

★☆☆ INotificationService için üçüncü bir implementasyon (PushNotificationService) yaz, "push" key'iyle kaydet ve bir endpoint'te kullan. Key'leri string yerine bir enum ile tanımlayıp farkı gör.

★★☆ GlobalExceptionHandler'ın yanına, ArgumentException'ları 400'e çeviren ikinci bir IExceptionHandler ekle. İki handler'ı farklı sıralarla kaydedip hangisinin önce çalıştığını log'dan izle.

★★★ webapiaot template'iyle küçük bir ürün API'si kur, dotnet publish -c Release ile native çıktı al. Sonra JsonSerializerContext'ten bir tipi çıkarıp önce dotnet run ile, sonra publish edilmiş dosyayla çalıştır ve farkı kendin gör.

.NET 8 ile template minimal API'ye geçti ama Swagger hâlâ Swashbuckle'dan geliyordu. Bir sonraki yazıda .NET 9'a bakacağız: Swashbuckle'ın template'ten çıkarılması, yerleşik AddOpenApi ve MapOpenApi, Scalar ile dokümantasyon arayüzü, HybridCache ve STS desteğinin 24 aya çıkması.