.NET 9 Yenilikleri: ASP.NET Core'da Yerleşik OpenAPI, Scalar ve HybridCache
.NET 9 ile Swashbuckle şablondan çıktı: AddOpenApi/MapOpenApi, transformer'lar, Scalar ve Swagger UI, stampede korumalı HybridCache, TypedResults.InternalServerError, StatusCodeSelector, C# 13 ve 24 aylık STS desteği.
Sürüm notu: Bu yazı .NET 9 ile gelen yenilikleri anlatır; kodlar .NET 9 içindir.
Önce Tahmin Et
.NET 9 şablonuyla yeni bir Web API projesi aç, uygulamayı Development ortamında çalıştır ve tarayıcıda /swagger adresine git. .NET 5'ten .NET 8'e kadar her projede karşına çıkan o yeşil-beyaz sayfayı mı görürsün, yoksa başka bir şey mi? Cevabı ilk bölümde veriyorum, ama tahminini şimdiden bir kenara yaz.
12 Kasım 2024'te çıkan .NET 9, bir STS sürümü. Program.cs iskeletine dokunmuyor; .NET 6'da kurduğumuz yapı aynen duruyor. Değişen şey, yıllardır "her projeye zaten eklenir" dediğimiz iki parçanın yeri: API belgesi artık framework'ün kendi işi, cache tarafında da IMemoryCache ile IDistributedCache'in arasındaki boşluğu dolduran yeni bir soyutlama var.
.NET 6'da OpenAPI Böyle Geliyordu
Kemik yapı yazılarında Swagger'ı hep şu satırlarla kurduk:
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.2.3" />
// Program.cs (.NET 6)
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.MapGet("/products/{id:int}", (int id) => new ProductDto(id, "Mechanical Keyboard", 89.90m));
app.Run();
public record ProductDto(int Id, string Name, decimal Price);
Belgeyi de arayüzü de üçüncü parti bir paket olan Swashbuckle üretiyordu. Mart 2024'te ASP.NET Core ekibi, Swashbuckle'ın aktif bakımda olmadığını ve .NET 8 için resmi bir sürüm çıkmadığını duyurdu, .NET 9 şablonundan da paketi çıkardı. Tahminin cevabı burada: .NET 9 şablonunda /swagger adresi 404 döner. Swagger UI yok, çünkü onu kuran paket yok.
AddOpenApi ve MapOpenApi
.NET 9 şablonunun ürettiği Program.cs'te Swashbuckle'ın yerinde iki satır var:
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.22" />
builder.Services.AddOpenApi();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
AddOpenApi() belge üreticisini DI container'a kaydeder, MapOpenApi() da belgeyi /openapi/v1.json adresinden yayınlayan bir endpoint ekler. Üretici, minimal API'lerde ve controller'larda aynı endpoint metadata'sını okuyor; WithName, Produces, [ProducesResponseType] gibi bildiğin her şey belgeye yansıyor. Trimming ve Native AOT ile de uyumlu.
Swashbuckle'da belgeyi özelleştirmek için filter yazardın. Yerleşik üreticide bunun karşılığı transformer. Document, operation ve schema seviyesinde üç çeşidi var:
builder.Services.AddOpenApi(options =>
{
options.AddDocumentTransformer((document, context, cancellationToken) =>
{
document.Info.Title = "ProductCatalog API";
document.Info.Description = "Products, categories and orders.";
return Task.CompletedTask;
});
});
Uygulamayı çalıştırıp belgeye baktığımda ilk satırlar şöyle geldi:
$ curl http://localhost:5091/openapi/v1.json
{
"openapi": "3.0.4",
"info": {
"title": "ProductCatalog API",
"description": "Products, categories and orders.",
"version": "1.0.0"
},
openapi alanına dikkat et: .NET 9'un ürettiği belge OpenAPI 3.0 sürümünde. Bu değer bir sonraki sürümde değişiyor.
Belgeye Bir Arayüz: Scalar ya da Swagger UI
JSON belge makineler için harika, ama endpoint'leri tarayıcıdan denemek istiyorsun. Microsoft.AspNetCore.OpenApi bilerek bir arayüz içermiyor; belgeyi üretiyor, gösterme işini sana bırakıyor. Microsoft'un dokümanlarında öne çıkan seçenek Scalar:
<PackageReference Include="Scalar.AspNetCore" Version="2.18.7" />
using Scalar.AspNetCore;
// ...
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
Uygulamayı çalıştırıp /scalar adresini açtığında belgen okunaklı bir referans sayfasına dönüşüyor ve istekleri oradan atabiliyorsun. Swagger UI'a alışkın bir ekipsen onu da bırakmak zorunda değilsin. Swashbuckle'ın yalnızca arayüz paketini ekleyip yerleşik belgeye yönlendirmen yeterli:
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.3.1" />
app.MapOpenApi();
app.UseSwaggerUI(options => options.SwaggerEndpoint("/openapi/v1.json", "ProductCatalog API"));
Bu kurulumda /swagger/index.html tekrar 200 döndü. Belgeyi framework üretiyor, eski tanıdık arayüz sadece onu gösteriyor. Restoran benzetmesiyle: mutfak artık evin kendi mutfağı, sen yalnızca hangi tabağa koyacağını seçiyorsun.
HybridCache: IMemoryCache'in Eksik Parçası
Caching yazısında ürün detayını şöyle cache'lemiştik:
using Microsoft.Extensions.Caching.Memory;
// ...
app.MapGet("/legacy/products/{id:int}", async (int id, IMemoryCache cache, ProductRepository repository) =>
{
ProductDto? product = await cache.GetOrCreateAsync($"product:{id}", entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
return repository.GetAsync(id);
});
return product is null ? Results.NotFound() : Results.Ok(product);
});
Ölçmek için ProductRepository'nin içine bir sayaç koydum; her çağrıda sayacı artırıp 500 ms bekliyor, yani yavaş bir veritabanı sorgusunu taklit ediyor:
public class ProductRepository
{
private int _calls;
public int Calls => _calls;
public async Task<ProductDto?> GetAsync(int id, CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref _calls);
await Task.Delay(500, cancellationToken);
return id == 1 ? new ProductDto(1, "Mechanical Keyboard", 89.90m) : null;
}
}
Cache boşken aynı ürüne aynı anda on istek gelsin:
$ for i in $(seq 1 10); do curl -s localhost:5091/legacy/products/1 > /dev/null & done; wait
$ curl localhost:5091/stats
{"databaseCalls":10}
On istek, on veritabanı çağrısı. IMemoryCache aynı key için bekleyen başka bir isteğin olduğunu bilmiyor; herkes "cache'te yok" deyip mutfağa koşuyor. Buna cache stampede deniyor ve popüler bir ürünün cache süresi dolduğu anda veritabanını gerçekten yorar. Bir de dağıtık cache'e geçmek istersen IDistributedCache sana byte[] verir; serialization'ı ve iki katmanı birlikte yönetmeyi kendin yazarsın.
.NET 9 ile gelen HybridCache ikisini tek API'de topluyor:
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="9.10.0" />
using Microsoft.Extensions.Caching.Hybrid;
// ...
builder.Services.AddHybridCache(options =>
{
options.DefaultEntryOptions = new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(5),
LocalCacheExpiration = TimeSpan.FromMinutes(1)
};
});
// ...
app.MapGet("/products/{id:int}", async (int id, HybridCache cache, ProductRepository repository, CancellationToken cancellationToken) =>
{
ProductDto? product = await cache.GetOrCreateAsync(
$"product:{id}",
async token => await repository.GetAsync(id, token),
cancellationToken: cancellationToken);
return product is null ? Results.NotFound() : Results.Ok(product);
})
.WithName("GetProductById");
Aynı on eşzamanlı isteği bu endpoint'e attığımda sayaç 10'dan 11'e çıktı:
$ for i in $(seq 1 10); do curl -s localhost:5091/products/1 > /dev/null & done; wait
$ curl localhost:5091/stats
{"databaseCalls":11}
Tek bir çağrı. HybridCache aynı key için gelen istekleri bekletiyor, factory'yi bir kez çalıştırıyor ve sonucu hepsine dağıtıyor. Yapı iki katmanlı: LocalCacheExpiration process içindeki bellek katmanının süresi, Expiration ise kayıtlı bir IDistributedCache (örneğin Redis) varsa oradaki süre. Redis kayıtlı değilse yalnızca bellek katmanı çalışır; kodun değişmez. Serialization da sana ait değil, System.Text.Json ile kendisi yapıyor.
Paketle ilgili bir not: HybridCache .NET 9 çıktığında preview'du; ilk stable sürüm 9.3.0 ile Mart 2025'te geldi.
Hata Yanıtlarında İki Küçük Ekleme
.NET 6'da 500 dönmek için Results.StatusCode(500) ya da Results.Problem() yazardık. .NET 7'de gördüğümüz Results<T1, T2> dönüş tipinde 500 için ayrı bir typed result yoktu; ProblemHttpResult ya da StatusCodeHttpResult ile idare ediyorduk. .NET 9'da TypedResults.InternalServerError geldi:
using Microsoft.AspNetCore.Http.HttpResults;
// ...
app.MapGet("/products/{id:int}/stock", Results<Ok<StockDto>, InternalServerError<string>> (int id) =>
id == 1
? TypedResults.Ok(new StockDto(id, 42))
: TypedResults.InternalServerError("Stock service is unavailable."));
$ curl -i localhost:5091/products/7/stock
HTTP/1.1 500 Internal Server Error
Content-Type: application/json; charset=utf-8
"Stock service is unavailable."
Dönüş tipi Results<...> olduğu için OpenAPI belgesi bu endpoint'e kendiliğinden hem 200 hem 500 yanıtını yazdı. Route group'lara da ProducesProblem ve ProducesValidationProblem eklenebiliyor.
İkinci ekleme global hata yönetimi tarafında. .NET 6'da exception tipine göre status code seçmek için kendi middleware'imizi yazmıştık. .NET 9'da bu eşleme ExceptionHandlerOptions'ın içinde:
builder.Services.AddProblemDetails();
// ...
app.UseExceptionHandler(new ExceptionHandlerOptions
{
StatusCodeSelector = exception => exception switch
{
KeyNotFoundException => StatusCodes.Status404NotFound,
ArgumentException => StatusCodes.Status400BadRequest,
_ => StatusCodes.Status500InternalServerError
}
});
app.MapGet("/orders/{id:int}", (int id) =>
{
throw new KeyNotFoundException($"Order {id} was not found.");
});
$ curl -i localhost:5091/orders/5
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"Not Found","status":404,"traceId":"00-2188e4b371511df95b4a69ebde5cc14d-6949c3dddc1a52b1-00"}
Arayüz tarafında bir de MapStaticAssets() var; statik dosyaları build sırasında sıkıştırıp fingerprint'liyor. Saf bir API'yi pek ilgilendirmiyor, aynı projede bir SPA sunuyorsan göz atmaya değer.
C# 13'ten API Koduna Yansıyanlar
.NET 9 ile C# 13 geldi. Günlük API kodunda en çok iki şey işine yarar: params artık yalnızca dizi değil, ReadOnlySpan<T> ve IEnumerable<T> gibi koleksiyonlarla da çalışıyor; thread senkronizasyonu için de yeni bir System.Threading.Lock tipi var:
var log = new AuditLog();
log.Add("product created");
log.Add("price changed", "stock updated", "published");
Console.WriteLine(log.Count); // 4
public class AuditLog
{
private readonly Lock _lock = new();
private readonly List<string> _entries = [];
public void Add(params ReadOnlySpan<string> messages)
{
lock (_lock)
{
foreach (string message in messages)
{
_entries.Add(message);
}
}
}
public int Count
{
get
{
lock (_lock)
{
return _entries.Count;
}
}
}
}
lock ifadesi Lock tipini tanıyor ve object ile kilitlemeye göre daha hafif bir yol kullanıyor. params ReadOnlySpan<string> da her çağrıda arka planda dizi oluşturma maliyetinden kurtarıyor.
Sık Düşülen Tuzaklar
Birincisi, HybridCache'in null değeri de cache'lemesi. Olmayan ürünü iki kez istediğimde ikisi de 404 döndü ama sayaç yalnızca bir arttı ({"databaseCalls":12}). Yani "ürün yok" bilgisi de beş dakika saklanıyor; ürün o arada eklenirse görünmez. Bunu istemiyorsan null'ı cache'lenmeyen bir yoldan döndür ya da kayıt eklendiğinde RemoveAsync ile key'i temizle.
İkincisi, Swashbuckle alışkanlıklarını taşımaya çalışmak. IOperationFilter, ISchemaFilter ya da Swashbuckle annotation'ları yerleşik üreticide çalışmaz; karşılıkları transformer'lar. Büyük bir Swashbuckle yapılandırman varsa geçişi ayrı bir iş olarak planla, ya da Swashbuckle ile devam et; zorunlu değil.
Üçüncüsü, belgeyi yalnızca Development'ta yayınladığını unutmak. CI'da istemci kodu üretmek için production'daki /openapi/v1.json'a güveniyorsan orada bulamazsın. Belgeyi build sırasında dosyaya yazdırmak için Microsoft.Extensions.ApiDescription.Server paketi var.
Dördüncüsü, C# 13 ile gelen field keyword'ünü normal özellik sanmak. .NET 9'da bu özellik preview. net9.0 projesinde LangVersion preview yapmadan kullandığımda derleyici CS9260 verdi: "Feature 'field keyword' is not available in C# 13.0. Please use language version 14.0 or greater." Stable hâlini bir sonraki yazıda göreceğiz.
.NET 9'a Geçiş
.NET 8 projesini yükseltmenin kod tarafı kısa:
<TargetFramework>net9.0</TargetFramework>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.22" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.22" />
SDK olarak .NET 9 SDK ya da daha yenisi gerekiyor; ben bu yazının örneklerini 10.0.402 SDK ile net9.0 hedefleyerek derleyip .NET 9.0.22 runtime'ında çalıştırdım. Microsoft.* paketlerini 9.0.x'e çek. EF Core 9 .NET 8'i hedeflediği için istersen önce yalnızca EF'i yükseltebilirsin. Swashbuckle'ı kullanmaya devam edeceksen güncel sürümünü kontrol et; paketin bakımı yeniden başladı ve 10.x serisi çıktı.
Breaking change listesinde API projelerini en çok ilgilendirenler şunlar: BinaryFormatter artık her çağrıda exception fırlatıyor; Startup.cs dönemindeki Host.CreateDefaultBuilder projelerinde ValidateOnBuild ve ValidateScopes Development'ta varsayılan olarak açık, yani gizli kalmış captive dependency'ler uygulama açılırken patlayabilir; SDK da net7.0 hedefi için uyarı veriyor.
Destek tarafında önemli bir değişiklik oldu. Eylül 2025'te Microsoft STS desteğini 18 aydan 24 aya çıkardı ve bu .NET 9'a da uygulandı. .NET 9'un destek bitişi 2026-11-10, yani .NET 8 LTS ile aynı gün. O tarihten sonra desteklenen tek GA sürüm .NET 10 LTS. Bugün .NET 9'da olan bir projenin hedefi .NET 10 olmalı; .NET 9'daki değişiklikleri bilmek de o geçişi yarı yarıya bitirmiş olmak demek.
Küçük Bir Deneme
★☆☆ Kendi projende AddOpenApi ile MapScalarApiReference'ı kur, bir document transformer ile belgenin başlığını değiştir ve /openapi/v1.json'daki info alanında gör.
★★☆ Yukarıdaki sayaçlı ProductRepository'yi kur ve on eşzamanlı isteği önce IMemoryCache'li, sonra HybridCache'li endpoint'e at. Sonra olmayan bir ürün id'siyle aynı denemeyi yap ve sayacın nasıl davrandığına bak.
★★★ Projene Redis'i AddStackExchangeRedisCache ile ekle, endpoint koduna dokunmadan uygulamayı yeniden başlat ve ürünün Redis'ten geldiğini sayaçla doğrula. İpucu: bellek katmanı uygulama kapanınca sıfırlanır, Redis katmanı sıfırlanmaz.
Bu yazıda OpenAPI belgesinin 3.0'da kaldığını ve field keyword'ünün preview olduğunu gördük; minimal API'de validation ise hâlâ bizim işimiz. Bir sonraki yazıda üçünün de değiştiği .NET 10 LTS'e geçiyoruz.