Eski .NET Core 3.1 ve .NET 5 Projelerini Okumak: Startup.cs'ten .NET 6'ya Geçiş
.NET Core 3.1 ve .NET 5 dönemindeki Startup.cs, Generic Host ve UseRouting/UseEndpoints yapısını okuyup aynı ASP.NET Core API'yi adım adım .NET 6'ya taşıyoruz; System.Text.Json farkları ve tuzaklar dahil.
Sürüm notu: Bu yazı .NET Core 3.1 ve .NET 5 projelerini okumayı ve onları .NET 6'ya taşımayı anlatır; kodlar .NET 6 içindir.
Eski Bir Projeyi Açtığında
Serinin ilk bölümünde kemik yapıyı .NET 6 üzerine kurduk: tek bir Program.cs, top-level statements, WebApplication.CreateBuilder. Yeni bir proje açtığında karşına çıkan tablo bu. Ama işe girdiğin ilk hafta sana verilen repo büyük ihtimalle böyle görünmeyecek. Klasörde bir Startup.cs duracak, Program.cs'te Main method'u olacak, csproj'de netcoreapp3.1 yazacak.
Bu bir istisna değil. JetBrains'in 2025 .NET anketinde katılımcıların yaklaşık yüzde 10'u hâlâ .NET 5 ve öncesiyle çalıştığını söylüyor. O yüzden sürüm yazılarına zamanda iki adım geri giderek başlıyoruz: önce .NET Core 3.1 ve .NET 5 döneminin iskeletini okumayı öğreneceğiz, sonra aynı projeyi adım adım .NET 6'ya taşıyacağız.
Önce Tahmin Et
Birazdan göreceğin Startup.Configure method'unda app.UseAuthorization() satırını alıp app.UseEndpoints(...) bloğunun altına taşıdığını düşün. Proje sorunsuz derlenir. Sonra token göndermeden [Authorize] ile işaretli DELETE /api/products/1 endpoint'ine istek atıyorsun. Hangi status code döner: 401 mi, 403 mü, yoksa başka bir şey mi? Peki herkese açık GET /api/products ne yapar?
Program.cs: Generic Host ile Ayağa Kalkmak
.NET Core 3.1 ya da .NET 5 ile açılmış bir Web API'de Program.cs neredeyse her zaman şöyledir:
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
namespace ProductCatalog.Api
{
public class Program
{
public static void Main(string[] args)
{
CreateHostBuilder(args).Build().Run();
}
// EF Core araçları da host'u bu method üzerinden bulur
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
});
}
}
Burada üç katman var. Host.CreateDefaultBuilder, 3.0 ile varsayılan olan Generic Host'u kurar: configuration kaynaklarını (appsettings.json, appsettings.{Environment}.json, environment variable'lar, komut satırı), logging'i ve dependency injection container'ını hazırlar. ConfigureWebHostDefaults bu host'un üstüne web katmanını ekler; Kestrel'i ve IIS entegrasyonunu ayarlar. UseStartup<Startup>() ise "geri kalan her şeyi şu class'ta bulacaksın" der.
Fark ettiysen, .NET 6'da WebApplication.CreateBuilder'ın tek satırda yaptığı iş tam olarak bu. Altta yatan Generic Host değişmedi; .NET 6 onu daha az törenle kullanmanı sağladı. Eski projede aynı binaya arka kapıdan giriyorsun, o kadar.
Startup.cs: ConfigureServices ve Configure
Asıl iş Startup class'ında yapılır. ProductCatalog API'mizin .NET Core 3.1 hâli şöyle:
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using ProductCatalog.Api.Services;
namespace ProductCatalog.Api
{
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// Service kayıtları: .NET 6'daki builder.Services'in karşılığı
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
services.AddSingleton<IProductService, InMemoryProductService>();
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer();
}
// Middleware pipeline: .NET 6'daki app.UseXxx satırlarının karşılığı
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
}
}
}
İki method, iki ayrı aşama. ConfigureServices container'a neyin kayıtlı olacağını söyler; uygulama henüz istek almaya başlamamıştır. Configure ise isteğin geçeceği middleware pipeline'ını sıralar. Bunları bir restoranın açılışına benzetebilirsin: ConfigureServices mutfağa malzemeleri ve aşçıları yerleştirmek, Configure ise siparişin hangi tezgâhlardan hangi sırayla geçeceğini belirlemek.
Runtime bu iki method'u reflection ile bulup çağırır. Method adları sözleşmedir; ConfigureService diye tek harf eksik yazarsan derleyici bir şey demez, method hiç çağrılmaz ve hatayı ancak uygulama çalışırken görürsün. Configure'a parametre olarak IWebHostEnvironment dışında container'daki başka service'leri de isteyebilirsin; runtime onları da çözer. Bir de UseDeveloperExceptionPage() satırına dikkat et: 3.1 ve 5'te bunu elle eklemek gerekiyordu, .NET 6'da Development ortamında otomatik geliyor.
UseRouting ile UseEndpoints Arasındaki Bölge
3.0 ile gelen endpoint routing, routing'i iki parçaya böldü. UseRouting() gelen isteğe hangi endpoint'in uyduğuna karar verir ve bu bilgiyi HttpContext'e yazar. UseEndpoints() ise seçilen endpoint'i çalıştırır. Aradaki middleware'ler seçilen endpoint'i görebilir; [Authorize] attribute'u var mı, CORS politikası ne diyor, hepsini bu bölgede okurlar.
Şimdi tahminin cevabı. UseAuthorization()'ı UseEndpoints'in altına taşıyıp uygulamayı Production ortamında çalıştırdım ve token'sız istek attım:
HTTP/1.1 500 Internal Server Error
Content-Length: 0
Server: Kestrel
fail: Microsoft.AspNetCore.Server.Kestrel[13]
Connection id "0HNPPTSAMGTI7", Request id "0HNPPTSAMGTI7:00000001": An unhandled exception was thrown by the application.
System.InvalidOperationException: Endpoint ProductCatalog.Api.Controllers.ProductsController.Delete (ProductCatalog.Api) contains authorization metadata, but a middleware was not found that supports authorization.
Configure your application startup by adding app.UseAuthorization() in the application startup code. If there are calls to app.UseRouting() and app.UseEndpoints(...), the call to app.UseAuthorization() must go between them.
Ne 401 ne 403: 500. Endpoint middleware, çalıştıracağı endpoint'te authorization bilgisi olduğunu görüyor ama isteğin authorization kontrolünden geçmediğini fark ediyor ve güvenlik açığı oluşturmak yerine exception fırlatıyor. Herkese açık GET /api/products ise 200 dönmeye devam ediyor, çünkü o endpoint'te authorization metadata'sı yok. Yani bu hata ancak korumalı bir endpoint'e istek geldiğinde ortaya çıkıyor. Satırı iki çağrının arasına geri koyduğunda DELETE isteği beklediğin gibi 401 Unauthorized ve WWW-Authenticate: Bearer header'ı ile dönüyor.
Aynı API .NET 6 Minimal Hosting ile
İki dosyayı tek bir Program.cs'e indirelim:
using Microsoft.AspNetCore.Authentication.JwtBearer;
using ProductCatalog.Api.Services;
var builder = WebApplication.CreateBuilder(args);
// Eski ConfigureServices
builder.Services.AddControllers();
builder.Services.AddSingleton<IProductService, InMemoryProductService>();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer();
var app = builder.Build();
// Eski Configure: UseRouting ve UseEndpoints artık gerekmiyor
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
ConfigureServices'in gövdesi builder.Services üzerine, Configure'ın gövdesi de app üzerine taşındı. services. ön ekini builder.Services. ile değiştirmek çoğu zaman yeterli. UseRouting ve UseEndpoints çağrıları kayboldu, çünkü WebApplication routing middleware'ini pipeline'ın başına, endpoint middleware'ini de sonuna kendisi ekliyor. app.MapControllers()'ı doğrudan app üzerinde çağırabiliyoruz. Development ortamındaki developer exception page de otomatik geldiği için o if bloğu da gitti.
Bu kodu .NET 6'da derleyip çalıştırdığımda GET /api/products/1 isteği {"id":1,"name":"Mechanical Keyboard","price":1499.90} döndü, token'sız DELETE de 401. Davranış birebir aynı; değişen sadece törenin miktarı.
Newtonsoft.Json'dan System.Text.Json'a
Eski projelerde sık göreceğin bir satır daha var: services.AddControllers().AddNewtonsoftJson(). ASP.NET Core 3.0 ile varsayılan JSON serializer System.Text.Json oldu ve Newtonsoft.Json shared framework'ten çıkarıldı. Eski davranışa bağımlı olan projeler Microsoft.AspNetCore.Mvc.NewtonsoftJson paketini ekleyip yola öyle devam etti. Taşıma sırasında bu satırı silmeden önce farkları bilmen gerekir. En sık patlayan örnek döngüsel referanslar. EF Core'dan gelen entity'lerde çok tipik:
public class Category
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public List<Product> Products { get; set; } = new();
}
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
// Product kendi Category'sini, Category de Product'ları gösteriyor
public Category? Category { get; set; }
}
Bu category'yi bir action'dan doğrudan döndüğünde System.Text.Json ilişkiyi sonsuza kadar takip etmeye çalışıyor ve 32 seviye derinlikte pes ediyor. Yanıt 500, log'da şu var:
System.Text.Json.JsonException: A possible object cycle was detected. This can either be due to a cycle or if the object depth is larger than the maximum allowed depth of 32. Consider using ReferenceHandler.Preserve on JsonSerializerOptions to support cycles. Path: $.Products.Category.Products.Category...
.NET 6 bu iş için ReferenceHandler.IgnoreCycles'ı getirdi:
using System.Text.Json.Serialization;
builder.Services.AddControllers()
.AddJsonOptions(options =>
options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles);
// Yanıt:
// {"id":1,"name":"Peripherals","products":[{"id":1,"name":"Mechanical Keyboard","category":null}]}
Newtonsoft tarafındaki karşılığı ise şu:
using Newtonsoft.Json;
builder.Services.AddControllers()
.AddNewtonsoftJson(options =>
options.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore);
// Yanıt:
// {"id":1,"name":"Peripherals","products":[{"id":1,"name":"Mechanical Keyboard"}]}
Küçük ama önemli bir fark: Newtonsoft döngüye giren property'yi hiç yazmıyor, System.Text.Json ise "category":null yazıyor. Bu yanıtı okuyan bir istemci "property yok" ile "property null" arasında ayrım yapıyorsa, serializer değiştirmek onu kırabilir. Doğru çözüm ise zaten entity yerine DTO dönmek; daha önce gördüğümüz gibi DTO'da döngü olmaz.
C# 8, 9 ve 10: Kodda Fark Edeceğin İzler
Eski bir dosyayı açtığında hangi C# döneminden kaldığını birkaç işaretten anlarsın. .NET Core 3.1 C# 8, .NET 5 C# 9, .NET 6 C# 10 ile gelir. Aynı DTO'nun iki hâli:
// .NET Core 3.1 dönemi: namespace bloğu, nullable kapalı, klasik class
namespace ProductCatalog.Api.Dtos
{
public class ProductDto
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
}
}
// .NET 6 dönemi: GlobalUsings.cs
global using ProductCatalog.Api.Dtos;
// Dtos/ProductDto.cs: file-scoped namespace ve record
namespace ProductCatalog.Api.Dtos;
public record ProductDto(int Id, string Name, decimal Price);
C# 8 nullable reference types'ı getirdi ama proje bazında opt-in bıraktı; eski projelerin çoğunda kapalıdır. C# 9 record'ları, init setter'ları, new() ile target-typed oluşturmayı ve top-level statements'ı getirdi; yani .NET 5 projesinde record görmen normal. C# 10 ise global using, file-scoped namespace ve record struct'ı ekledi. .NET 6 template'lerindeki ImplicitUsings ayarı, System.Linq ya da Microsoft.AspNetCore.Builder gibi sık kullanılan namespace'leri arka planda global using olarak ekliyor; eski dosyaların tepesindeki uzun using listesi bu yüzden yeni projelerde yok.
.NET 6'ya Geçiş Adımları
İlk adım csproj. Eski proje dosyası:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="3.1.32" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="3.1.32" />
</ItemGroup>
</Project>
Yeni hâli:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<!-- Yeni projelerde varsayılan; eski projede uyarı yağmuruna hazır ol -->
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.36" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="6.0.36" />
</ItemGroup>
</Project>
TargetFramework değeri netcoreapp3.1 ya da net5.0'dan net6.0'a çıkıyor. Microsoft.AspNetCore.*, Microsoft.Extensions.* ve EF Core paketleri framework ile aynı ana sürüme taşınıyor; 6.0 serisinin son yaması 6.0.36. 3.1 ve 5.0 tarafında son sürümler sırasıyla 3.1.32 ve 5.0.17. Repo'da bir global.json varsa içindeki SDK sürümünü de güncellemen gerekir, yoksa eski SDK aranır.
İkinci adım hosting. Burada iyi bir haber var: Microsoft'un taşıma rehberine göre .NET 6'ya geçen uygulamaların minimal hosting'e geçmesi zorunlu değil, Startup ve Generic Host tam destekli. Yukarıdaki Program.cs ve Startup.cs'i hiç değiştirmeden net6.0 ile derledim; derleme temiz geçti, uygulama aynı yanıtları verdi. Yani önce sadece TargetFramework'ü değiştirip testleri yeşile çekebilir, hosting'i sonra ele alabilirsin. Arada bir durak daha var: Startup class'ını koruyup onu yeni host'tan elle çağırmak.
using ProductCatalog.Api;
var builder = WebApplication.CreateBuilder(args);
// Startup'ı artık reflection değil, sen çağırıyorsun
var startup = new Startup(builder.Configuration);
startup.ConfigureServices(builder.Services);
var app = builder.Build();
startup.Configure(app, app.Environment);
app.Run();
Bu hâliyle de 200 ve 401 yanıtlarını aynen aldım. Büyük bir Startup'ı tek seferde dağıtmak istemiyorsan bu ara yol güvenli bir köprü.
Üçüncü adım breaking change listesi. ASP.NET Core 6 listesinde Web API'yi en çok ilgilendirenler şunlar: ActionResult<T> dönen bir action'da Response.StatusCode'u elle 202 yapıp T dönersen artık 200 alıyorsun, bunun yerine Accepted(...) ya da StatusCode(202, ...) kullanmalısın; System.Text.Json kullanırken MVC IAsyncEnumerable<T>'yi artık buffer'lamıyor; framework'ün nullable annotation'ları değişti, bu da yeni uyarılar demek. 3.1'den geliyorsan 5.0'ın listesine de göz at.
Son olarak destek durumu. .NET Core 3.1 LTS'ti ve 13 Aralık 2022'de, .NET 5 STS'ti ve 10 Mayıs 2022'de destek dışı kaldı. Hedefimiz olan .NET 6 da LTS'ti ve 12 Kasım 2024'te destek dışı kaldı. Yani bu geçiş bir varış noktası değil, ilk durak. 6'ya geldikten sonra sonraki sürümlere geçiş çok daha sakin, çünkü Program.cs iskeleti .NET 10'a kadar aynı kalıyor.
Sık Düşülen Tuzaklar
En klasiği Startup'ı yeni builder'a eski yöntemle bağlamaya çalışmak. builder.WebHost.UseStartup<Startup>() satırı derlenir ama uygulama açılırken patlar:
Unhandled exception. System.NotSupportedException: UseStartup() is not supported by WebApplicationBuilder.WebHost. Use the WebApplication returned by WebApplicationBuilder.Build() instead.
Ya eski Host.CreateDefaultBuilder iskeletinde kal ya da Startup'ı yukarıdaki gibi elle çağır; ikisini karıştırmanın yolu yok.
İkincisi Nullable'ı açıp her şeyi aynı günde düzeltmeye çalışmak. Bizim küçük projede bile ilk derlemede Product.Name için CS8618, FirstOrDefault dönen method için CS8603 uyarısı geldi. Gerçek bir projede bu sayı yüzleri bulur. Önce framework'ü taşı, nullable'ı ayrı bir iş olarak ve gerekirse dosya dosya #nullable enable ile aç.
Üçüncüsü uygulama adındaki ince değişiklik. Rehbere göre .NET 6'da WebApplicationBuilder content root yolunu sonuna dizin ayracı ekleyerek normalize ediyor; bu da uygulama adının eskisiyle aynı olmamasına yol açabiliyor. Data Protection anahtarlarını uygulama adına göre ayıran bir kurulumda bu, eski cookie'lerin ya da korunmuş verinin okunamaması demek. Böyle bir şey kullanıyorsan SetApplicationName ile adı sabitle.
Dördüncüsü Configure'a enjekte edilen service'ler. Startup'ı elle çağırdığın ara yolda runtime artık Configure'ın ekstra parametrelerini çözmüyor; onları app.Services'ten kendin alıp geçmen gerekiyor. Scoped bir service lazımsa da önce bir scope açmalısın.
Küçük Bir Deneme
★☆☆ Yazıdaki Program.cs, Startup.cs ve ProductsController ile bir proje kur, TargetFramework'ü net6.0 yap ve hiçbir satıra dokunmadan derle. GET /api/products ve token'sız DELETE /api/products/1 isteklerinin yanıtlarını not al.
★★☆ Aynı projeyi önce Startup'ı elle çağıran ara yola, sonra tek dosyalık minimal hosting'e çevir. Her adımda iki isteğin yanıtı değişmemeli. Ardından UseAuthorization()'ı UseEndpoints'in altına taşıyıp 500'ü kendi gözünle gör, uygulamayı bir de Development ortamında çalıştırıp aynı hatanın nasıl göründüğüne bak.
★★★ Category ve Product örneğinde önce ReferenceHandler.IgnoreCycles'ı, sonra ReferenceHandler.Preserve'ü dene ve iki JSON'u karşılaştır. Sonra AddNewtonsoftJson'a geçip aynı endpoint'in çıktısını yan yana koy. Hangisini bir istemciye gönül rahatlığıyla verirdin?
Artık eski bir projeyi okuyabiliyor ve onu .NET 6'nın kemik yapısına taşıyabiliyorsun. Bir sonraki yazıda .NET 7'ye geçiyoruz: minimal API'leri olgunlaştıran MapGroup ve TypedResults, endpoint filter'lar, yerleşik rate limiting ve output caching ile .NET 6'da elle yazdığımız birçok şeyin framework'e nasıl taşındığını göreceğiz.