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

.NET 10 Yenilikleri: ASP.NET Core'da AddValidation, OpenAPI 3.1 ve Server-Sent Events

Mert Özen 4 Kas 2026 9 dk 2 görüntülenme
.NET 10 Yenilikleri: ASP.NET Core'da AddValidation, OpenAPI 3.1 ve Server-Sent Events

.NET 10 LTS ile minimal API'de AddValidation, OpenAPI 3.1 ve YAML, TypedResults.ServerSentEvents, System.Text.Json tabanlı JSON Patch, dotnet run app.cs, C# 14 ve cookie auth 401/403 gibi breaking change'ler.

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

Önce Tahmin Et

Elimizde .NET 6'da yazılmış basit bir minimal API endpoint'i var. Request DTO'sunda [Required] ve [Range] attribute'ları duruyor:

using System.ComponentModel.DataAnnotations;

// ...

app.MapPost("/products", (CreateProductRequest request) =>
    Results.Created($"/products/1", new ProductDto(1, request.Name, request.Price, request.Description)));

public record CreateProductRequest(
    [property: Required, StringLength(100)] string Name,
    [property: Range(0.01, 100000)] decimal Price,
    string? Description);

public record ProductDto(int Id, string Name, decimal Price, string? Description);

Bu endpoint'e {"name":"","price":0} gönderirsen hangi status code döner? Controller yazısında [ApiController]'ın otomatik 400 verdiğini görmüştük; minimal API'de de aynısını mı beklersin?

11 Kasım 2025'te çıkan .NET 10 bir LTS sürümü ve serinin bu bölümündeki son durak. Bu yazıda validation'ın minimal API'ye gelişini, OpenAPI 3.1'i, Server-Sent Events'i, Newtonsoft'suz JSON Patch'i, tek dosyalık uygulamaları ve C# 14'ü göreceğiz. Geçiş bölümünde de davranışı değişen iki şeye bakacağız.

Minimal API'de Validation Artık Yerleşik

Tahminin cevabı, net6.0 hedefli projeyi derleyip çalıştırdığımda geldi (makinemde .NET 6 runtime'ı olmadığı için .NET 8 runtime'ında; minimal API bu konuda .NET 9'a kadar aynı davranıyor):

$ curl -i -X POST localhost:5061/products -H "Content-Type: application/json" -d '{"name":"","price":0}'
HTTP/1.1 201 Created
Location: /products/1

{"id":1,"name":"","price":0,"description":null}

201. Minimal API attribute'lara hiç bakmıyordu; boş isimli, sıfır fiyatlı ürün sessizce kabul edildi. .NET 6'da ya FluentValidation'a gidiyor ya da her endpoint'te şunu yazıyorduk:

app.MapPost("/v2/products", (CreateProductRequest request) =>
{
    var results = new List<ValidationResult>();
    if (!Validator.TryValidateObject(request, new ValidationContext(request), results, validateAllProperties: true))
    {
        Dictionary<string, string[]> errors = results
            .GroupBy(r => r.MemberNames.FirstOrDefault() ?? "")
            .ToDictionary(g => g.Key, g => g.Select(r => r.ErrorMessage ?? "").ToArray());
        return Results.ValidationProblem(errors);
    }

    return Results.Created($"/products/1", new ProductDto(1, request.Name, request.Price, request.Description));
});

.NET 10'da bu kodun tamamı iki satıra iniyor:

builder.Services.AddValidation();
builder.Services.AddProblemDetails();

// ...

app.MapPost("/products", (CreateProductRequest request) =>
    TypedResults.Created($"/products/1", new ProductDto(1, request.Name, request.Price, request.Description)));
$ curl -i -X POST localhost:5101/products -H "Content-Type: application/json" -d '{"name":"","price":0}'
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"Name":["The Name field is required."],"Price":["The field Price must be between 0.01 and 100000."]},"traceId":"00-797bed7f91d985b1420a00b87e547734-f56972a59d1c5eab-00"}

AddValidation() reflection'la çalışmıyor; build sırasında bir source generator endpoint'lerin parametre tiplerini bulup validation kodunu üretiyor. Data Annotations'ı, IValidatableObject'i ve record'ları tanıyor. Bir endpoint'te kapatmak istersen .DisableValidation() var. API'ler Microsoft.Extensions.Validation namespace'inde; üst seviye AddValidation stable, daha derindeki resolver API'leri experimental.

OpenAPI 3.1 Varsayılan Oldu

Bir önceki yazıda .NET 9'un belgesinin "openapi": "3.0.4" ile başladığını görmüştük. Aynı AddOpenApi() çağrısı .NET 10'da şunu üretiyor:

$ curl localhost:5101/openapi/v1.json
{
  "openapi": "3.1.1",

Fark yalnızca sürüm numarasında değil. Nullable olan Description alanı .NET 9 belgesinde "type": "string", "nullable": true olarak yazılıyordu. OpenAPI 3.1 JSON Schema ile hizalandığı için .NET 10'da aynı alan "type": ["null", "string"] oldu. Belgeyi YAML olarak yayınlamak da artık tek satır:

app.MapOpenApi();
app.MapOpenApi("/openapi/{documentName}.yaml");
$ curl localhost:5101/openapi/v1.yaml
openapi: '3.1.1'
info:
  title: ProductCatalog.Api | v1
  version: 1.0.0

Arka planda üretici Microsoft.OpenApi 2.0'a geçti ve bu, transformer yazdıysan seni etkileyen bir breaking change. Örneğin schema'ya OpenApiString ile örnek değer koyan .NET 9 kodu, .NET 10'da using Microsoft.OpenApi.Any; satırında CS0234 ile derlenmiyor. Yeni modelde örnek değerler JsonNode:

using System.Text.Json.Nodes;

// ...

options.AddSchemaTransformer((schema, context, cancellationToken) =>
{
    if (context.JsonTypeInfo.Type == typeof(decimal))
    {
        schema.Example = JsonValue.Create("89.90");
    }
    return Task.CompletedTask;
});

Server-Sent Events: TypedResults.ServerSentEvents

Sipariş durumunu istemciye canlı akıtmak istediğini düşün. SignalR fazla gelir, polling ise israf. Server-Sent Events tam bunun için: sunucudan istemciye tek yönlü, düz HTTP üzerinden bir event akışı. .NET 6'da protokolü elle yazıyorduk:

using System.Text.Json;

// ...

app.MapGet("/orders/{id:int}/status-stream", async (int id, HttpContext context, CancellationToken cancellationToken) =>
{
    context.Response.Headers.ContentType = "text/event-stream";
    string[] statuses = { "Received", "Packed", "Shipped" };
    foreach (string status in statuses)
    {
        await Task.Delay(1000, cancellationToken);
        string json = JsonSerializer.Serialize(new OrderStatus(id, status), new JsonSerializerOptions(JsonSerializerDefaults.Web));
        await context.Response.WriteAsync($"event: order-status\ndata: {json}\n\n", cancellationToken);
        await context.Response.Body.FlushAsync(cancellationToken);
    }
});

Header'ı, satır formatını, flush'ı unutmamak senin işindi. .NET 10'da bir IAsyncEnumerable verip gerisini framework'e bırakıyorsun:

using System.Runtime.CompilerServices;

// ...

app.MapGet("/orders/{id:int}/status-stream", (int id, CancellationToken cancellationToken) =>
    TypedResults.ServerSentEvents(GetStatusesAsync(id, cancellationToken), eventType: "order-status"));

static async IAsyncEnumerable<OrderStatus> GetStatusesAsync(int orderId, [EnumeratorCancellation] CancellationToken cancellationToken)
{
    string[] statuses = ["Received", "Packed", "Shipped"];
    foreach (string status in statuses)
    {
        await Task.Delay(1000, cancellationToken);
        yield return new OrderStatus(orderId, status);
    }
}

public record OrderStatus(int OrderId, string Status);
$ curl -N -i localhost:5101/orders/42/status-stream
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache,no-store

event: order-status
data: {"orderId":42,"status":"Received"}

event: order-status
data: {"orderId":42,"status":"Packed"}

event: order-status
data: {"orderId":42,"status":"Shipped"}

Satırlar birer saniye arayla geldi. İstemci bağlantıyı kapatınca CancellationToken tetikleniyor ve döngü duruyor. OpenAPI belgesi de bu endpoint için text/event-stream yanıtını kendiliğinden yazdı. Aynı sonuç tipi controller action'larından da dönebiliyor.

JSON Patch Newtonsoft'tan Kurtuldu

JSON Patch, bir kaynağın yalnızca değişen alanlarını işlem listesiyle göndermeni sağlar. .NET 6'da bunun tek yolu Newtonsoft'tu: Microsoft.AspNetCore.Mvc.NewtonsoftJson paketi, AddControllers().AddNewtonsoftJson() ve Microsoft.AspNetCore.JsonPatch namespace'i. Uygulamanın geri kalanı System.Text.Json kullanırken tek bir özellik için ikinci bir JSON library'si taşıyordun. .NET 10'da System.Text.Json tabanlı bir paket var:

<PackageReference Include="Microsoft.AspNetCore.JsonPatch.SystemTextJson" Version="10.0.14" />
using Microsoft.AspNetCore.JsonPatch.SystemTextJson;
using Microsoft.AspNetCore.Mvc;

namespace ProductCatalog.Api.Controllers;

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    [HttpPatch("{id:int}")]
    public ActionResult<Product> Patch(int id, [FromBody] JsonPatchDocument<Product> patch)
    {
        var product = new Product { Id = id, Name = "Mechanical Keyboard", Price = 89.90m, Stock = 10 };

        patch.ApplyTo(product, error => ModelState.AddModelError(error.Operation.path, error.ErrorMessage));
        if (!ModelState.IsValid)
        {
            return ValidationProblem(ModelState);
        }

        return product;
    }
}
$ curl -X PATCH localhost:5101/api/products/1 -H "Content-Type: application/json-patch+json" \
  -d '[{"op":"replace","path":"/price","value":79.90},{"op":"replace","path":"/stock","value":0}]'
{"id":1,"name":"Mechanical Keyboard","price":79.90,"stock":0}

$ curl -X PATCH localhost:5101/api/products/1 -H "Content-Type: application/json-patch+json" \
  -d '[{"op":"replace","path":"/color","value":"black"}]'
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"/color":["The target location specified by path segment 'color' was not found."]},"traceId":"00-e440e3353e5fe8192d65652b58f90047-79389282fb767bd1-00"}

Bir fark: Newtonsoft sürümündeki ApplyTo(product, ModelState) overload'ı burada yok; derlemeye çalıştığımda CS1503 aldım. Hataları yukarıdaki gibi bir callback ile ModelState'e aktarıyorsun. Paket birebir yerine geçen bir sürüm de değil; örneğin ExpandoObject desteklenmiyor.

Tek Dosyalık API: dotnet run app.cs

Bir fikri denemek için artık proje açman gerekmiyor. Tek bir .cs dosyası yeterli:

#:sdk Microsoft.NET.Sdk.Web
#:property PublishAot=false

var app = WebApplication.Create(args);

app.MapGet("/products", () => new ProductDto[] { new(1, "Mechanical Keyboard", 89.90m) });

app.Run("http://localhost:5103");

record ProductDto(int Id, string Name, decimal Price);
$ dotnet run products.cs
$ curl localhost:5103/products
[{"id":1,"name":"Mechanical Keyboard","price":89.90}]

#:sdk dosyanın hangi SDK ile derleneceğini, #:package NuGet paketlerini, #:property da MSBuild property'lerini söylüyor. Script büyüyünce dotnet project convert ile normal bir projeye çevirebilirsin. PublishAot=false satırının neden orada olduğunu tuzaklarda anlatacağım.

Kısa notlar: ASP.NET Core Identity passkey (WebAuthn) desteği aldı; public partial class Program artık source generator ile üretildiği için integration testlerinde elle eklemen gerekmiyor; dotnet new sln da varsayılan olarak .slnx üretiyor.

C# 14: Extension Members ve field

.NET 9 yazısında preview olduğu için derleyicinin reddettiği field keyword'ü C# 14 ile stable. Yanına extension members geldi: artık extension method'ların yanında extension property ve static extension member yazabiliyorsun.

var keyboard = new Product { Name = "  Mechanical Keyboard  ", Stock = 3 };
Console.WriteLine($"[{keyboard.Name}] in stock: {keyboard.IsInStock}");

Product placeholder = Product.Placeholder;
Console.WriteLine($"[{placeholder.Name}] in stock: {placeholder.IsInStock}");

Product? missing = null;
missing?.Stock = 10;
Console.WriteLine(missing is null);

public class Product
{
    public string Name
    {
        get;
        set => field = value.Trim();
    } = "";

    public int Stock { get; set; }
}

public static class ProductExtensions
{
    extension(Product product)
    {
        public bool IsInStock => product.Stock > 0;
    }

    extension(Product)
    {
        public static Product Placeholder => new() { Name = "Unnamed product" };
    }
}
[Mechanical Keyboard] in stock: True
[Unnamed product] in stock: False
True

field, otomatik property'nin arkasındaki alanı adlandırmadan kullanmanı sağlıyor; setter'a tek bir kural eklemek için artık private alan açmıyorsun. missing?.Stock = 10 da yeni null-conditional atama: nesne null ise atama hiç yapılmıyor.

Sık Düşülen Tuzaklar

Birincisi, AddProblemDetails()'ı unutmak. Onsuz da validation çalışıyor ama yanıt application/json geliyor ve içinde type, status, traceId yok; yalnızca title ve errors var. Diğer hata yanıtlarınla aynı biçimi istiyorsan ikisini birlikte kaydet.

İkincisi, record'larda attribute hedefi. AddValidation constructor parametresindeki attribute'u da görüyor, ama .NET 6'daki Validator.TryValidateObject görmüyor. [property: ...] hedefini yazmadan denediğimde elle validation boş isimli ürünü kabul etti. Kodu iki dünyada da kullanacaksan [property: ...] yaz.

Üçüncüsü, tek dosyalık uygulamaların Native AOT varsayılanı. PublishAot=false satırı olmadan aynı dosya derlendi, ama ilk istekte System.NotSupportedException: JsonTypeInfo metadata for type 'ProductDto[]' was not provided by TypeInfoResolver hatası geldi. AOT modunda reflection tabanlı JSON kapalı; ya bu satırı ekle ya da bir JsonSerializerContext yaz. Anonymous type dönen bir endpoint ise source generator'ın ürettiği kodda derleme hatası verdi.

Dördüncüsü, OpenAPI 3.1'de sayıların tipi. decimal bir alan .NET 10 belgesinde "type": ["number", "string"] ve bir pattern ile geldi; çünkü web varsayılanları sayıların string olarak okunmasına izin veriyor. NumberHandling'i Strict yaptığımda alan yine yalnızca number oldu. Belgeden istemci üretiyorsan bunu kontrol et.

.NET 10'a Geçiş

<TargetFramework>net10.0</TargetFramework>

<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.14" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.14" />

.NET 10 SDK gerekiyor; örnekleri 10.0.402 SDK ile derledim. Microsoft.* paketlerini 10.0.x'e çek; EF Core 10 yalnızca .NET 10'da çalışıyor. Swashbuckle'da kalıyorsan 10.x serisi .NET 10'u destekliyor.

Davranışı değişen ilk şey cookie authentication. Cookie ile korunan bir endpoint'e giriş yapmadan istek attığında .NET 9 ve öncesinde login sayfasına yönlendirme gelirdi. Aynı kodu .NET 8 runtime'ında ve .NET 10'da çalıştırdım:

# .NET 8
HTTP/1.1 302 Found
Location: http://localhost:5061/Account/Login?ReturnUrl=%2Forders

# .NET 10
HTTP/1.1 401 Unauthorized
Location: http://localhost:5101/Account/Login?ReturnUrl=%2Forders

[ApiController] controller'lar, JSON dönen minimal API'ler ve TypedResults kullanan endpoint'ler artık "API endpoint'i" sayılıyor ve redirect yerine 401/403 dönüyor. İstemcin 302'ye göre yazıldıysa güncellemen gerekir.

İkincisi IExceptionHandler. TryHandleAsync true dönerse middleware artık exception'ı loglamıyor. Denediğimde varsayılan ayarda log'da hiç fail satırı çıkmadı; eski davranışı istersen:

app.UseExceptionHandler(new ExceptionHandlerOptions
{
    SuppressDiagnosticsCallback = context => false
});

Bu ayarla "An unhandled exception has occurred while executing the request." satırı geri geldi. Listede başka uyarılar da var: .NET 7'den beri kullandığımız WithOpenApi() artık ASPDEPR002 uyarısı veriyor, WebHostBuilder ve IActionContextAccessor obsolete oldu.

.NET 10 bir LTS ve 2028-11-14'e kadar destekleniyor. .NET 8 ile .NET 9 2026-11-10'da birlikte destek dışı kaldığında desteklenen tek GA sürüm bu olacak.

Küçük Bir Deneme

★☆☆ Önce Tahmin Et'teki endpoint'i .NET 10'da AddValidation() ile kur. Aynı geçersiz isteği önce AddProblemDetails() olmadan, sonra onunla at ve iki yanıtı karşılaştır.

★★☆ CreateProductRequest'e IValidatableObject ekle ve Description doluysa en az 10 karakter olsun kuralını yaz. Sonra endpoint'e .DisableValidation() ekleyip aynı isteğin geçtiğini gör.

★★★ Sipariş durumu akışını bir route group'a taşı, tarayıcıda EventSource ile dinleyen küçük bir HTML sayfası yaz ve sekmeyi kapattığında sunucudaki döngünün durduğunu bir log satırıyla doğrula.

.NET 6'da kurduğumuz kemik yapı bu sürüm yazılarıyla bugünün .NET 10 LTS'ine ulaştı. Seri burada bitmiyor: .NET 11 bir STS sürümü olarak şu an RC aşamasında ve GA'sının 2026-11-10'da gelmesi bekleniyor. Sürüm çıkıp neyin kesinleştiği belli olduğunda .NET 11 ile kaldığımız yerden devam edeceğiz.