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

ASP.NET Core'da Configuration ve Options Pattern: Provider Sırası, User Secrets ve Validation

Mert Özen 4 Kas 2026 7 dk 2 görüntülenme
ASP.NET Core'da Configuration ve Options Pattern: Provider Sırası, User Secrets ve Validation

ASP.NET Core configuration provider'larının sırasını gerçek çıktıyla görüyoruz: appsettings, environment variable, command line, user secrets. Options pattern, IOptions/IOptionsSnapshot/IOptionsMonitor farkı ve ValidateOnStart ile doğrulama.

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

Projede iki ayar dosyası var. appsettings.json şöyle:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "Catalog": {
    "PageSize": 20,
    "Currency": "TRY",
    "SupportEmail": "support@example.com"
  }
}

appsettings.Development.json ise yalnızca tek bir değeri eziyor:

{
  "Catalog": {
    "PageSize": 5
  }
}

Uygulamayı terminalde Catalog__PageSize=50 dotnet run ile başlatıyorsun; launchSettings.json da ortamı Development yapıyor. Catalog:PageSize değerini okuyan bir endpoint'e istek attığında 20, 5 ve 50'den hangisi döner? Bir de şunu düşün: aynı komuta -- --Catalog:PageSize=80 eklersen ne değişir?

Configuration Bir Katman Pastasıdır

ASP.NET Core'da configuration tek bir dosya değil, üst üste konmuş kaynakların birleşimi. Her kaynağa configuration provider denir. Aynı key birden fazla katmanda varsa en üstteki, yani en son eklenen kazanır. WebApplication.CreateBuilder'ın hangi provider'ları hangi sırayla eklediğini görmek için Program.cs'in başına geçici olarak şunu yaz:

foreach (IConfigurationProvider provider in ((IConfigurationRoot)builder.Configuration).Providers)
{
    Console.WriteLine(provider);
}

.NET 6'da Development ortamında çıktı şu:

MemoryConfigurationProvider
Microsoft.Extensions.Configuration.ChainedConfigurationProvider
Microsoft.Extensions.Configuration.ChainedConfigurationProvider
JsonConfigurationProvider for 'appsettings.json' (Optional)
JsonConfigurationProvider for 'appsettings.Development.json' (Optional)
EnvironmentVariablesConfigurationProvider Prefix: ''
CommandLineConfigurationProvider

İlk satırlar host'un kendi ayarları (ASPNETCORE_ ve DOTNET_ ile başlayan environment variable'lar bunların içinde). Bizi ilgilendiren sıra aşağıdan yukarıya şöyle: command line argümanları en güçlü, sonra environment variable'lar, sonra appsettings.{Environment}.json, en altta da appsettings.json. Birazdan göreceğimiz user secrets, Development ortamında iki JSON dosyasıyla environment variable'ların arasına giriyor. .NET 10'da bu liste daha uzun ama bu dört katmanın sırası aynı.

Tahmin sorusunun cevabı da buradan çıkıyor. appsettings.json 20 der, Development dosyası onu 5 ile ezer, environment variable 50 ile onu ezer. Command line argümanı eklersen 80 kazanır. Pastanın en üstündeki katman ne diyorsa o.

Değer Okumak: IConfiguration, İki Nokta ve Çift Alt Çizgi

Configuration'a doğrudan erişmek için IConfiguration'ı enjekte edersin. Minimal API handler'ında bu bir satır:

app.MapGet("/api/config/page-size", (IConfiguration config) => config.GetValue<int>("Catalog:PageSize"));

JSON'daki iç içe yapı key'de : ile düzleşir: Catalog section'ının altındaki PageSize, Catalog:PageSize olur. GetValue<int> string'i istediğin tipe çevirir. Bütün bir bölümle çalışmak istersen builder.Configuration.GetSection("Catalog") sana o alt ağacı verir. Üç katmanı sırayla deneyelim:

dotnet run
curl http://localhost:5080/api/config/page-size
5

Catalog__PageSize=50 dotnet run
curl http://localhost:5080/api/config/page-size
50

Catalog__PageSize=50 dotnet run -- --Catalog:PageSize=80
curl http://localhost:5080/api/config/page-size
80

Environment variable'da : yerine __ (çift alt çizgi) kullandık. : her platformda environment variable adında desteklenmez; __ ise her yerde çalışır ve ASP.NET Core onu okurken :'ya çevirir. Docker ve Kubernetes'te ayar geçerken en çok bu formu kullanacaksın.

User Secrets: Şifreler Repoya Girmesin

Ürün fiyatlarını dış bir service'ten çektiğimizi ve bunun için bir API key gerektiğini düşün. Bu key'i appsettings.json'a yazarsan bir sonraki commit'le repoya girer. Geliştirme ortamı için user secrets aracı var:

dotnet user-secrets init
dotnet user-secrets set "Catalog:PricingApiKey" "dev-only-key-123"
dotnet user-secrets list
Catalog:PricingApiKey = dev-only-key-123

init komutu csproj'a bir UserSecretsId ekler:

<UserSecretsId>7267b694-9024-4688-a01c-651f9d1b771c</UserSecretsId>

Değerler proje klasörüne değil, kullanıcı profiline yazılır: macOS ve Linux'ta ~/.microsoft/usersecrets/<UserSecretsId>/secrets.json, Windows'ta %APPDATA%\Microsoft\UserSecrets\<UserSecretsId>\secrets.json. Provider listesine tekrar bakarsan Development'ta yeni bir satır görürsün:

JsonConfigurationProvider for 'appsettings.Development.json' (Optional)
JsonConfigurationProvider for 'secrets.json' (Optional)
EnvironmentVariablesConfigurationProvider Prefix: ''

İki önemli not. User secrets yalnızca Development ortamında yüklenir; aynı uygulamayı Production'da çalıştırdığında bu key yoktur. Bir de adı seni yanıltmasın: dosya şifrelenmez, sadece repodan uzakta durur. Production'daki secret'lar environment variable'lardan ya da Azure Key Vault gibi bir secret store'dan gelir; o konuya güvenlik yazısında döneceğiz.

Options Pattern: Ayarları Tipli Bir Class'a Bağlamak

Kodun her yerine "Catalog:PageSize" gibi string key'ler serpiştirmek kırılgandır. Bir harf hatası derleyiciden kaçar. Options pattern, bir configuration bölümünü düz bir class'a bağlar:

using System.ComponentModel.DataAnnotations;

namespace ProductCatalog.Options;

public class CatalogOptions
{
    public const string SectionName = "Catalog";

    [Range(1, 100)]
    public int PageSize { get; set; } = 10;

    [Required]
    public string Currency { get; set; } = string.Empty;

    [EmailAddress]
    public string SupportEmail { get; set; } = string.Empty;

    public string PricingApiKey { get; set; } = string.Empty;
}

Kaydın en sade hâli tek satır:

builder.Services.Configure<CatalogOptions>(builder.Configuration.GetSection(CatalogOptions.SectionName));

Artık ayarı isteyen class string key bilmek zorunda değil, IOptions<CatalogOptions> ya da kardeşlerinden birini enjekte ediyor. Bir controller yazalım:

using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using ProductCatalog.Options;

namespace ProductCatalog.Controllers;

[ApiController]
[Route("api/[controller]")]
public class SettingsController : ControllerBase
{
    private readonly IOptions<CatalogOptions> _options;
    private readonly IOptionsSnapshot<CatalogOptions> _snapshot;
    private readonly IOptionsMonitor<CatalogOptions> _monitor;

    public SettingsController(
        IOptions<CatalogOptions> options,
        IOptionsSnapshot<CatalogOptions> snapshot,
        IOptionsMonitor<CatalogOptions> monitor)
    {
        _options = options;
        _snapshot = snapshot;
        _monitor = monitor;
    }

    [HttpGet]
    public IActionResult Get()
    {
        CatalogOptions current = _snapshot.Value;
        return Ok(new
        {
            current.PageSize,
            current.Currency,
            current.SupportEmail,
            // Secret'ın kendisini asla dönmüyoruz, sadece var olup olmadığını
            HasPricingApiKey = current.PricingApiKey.Length > 0
        });
    }

    [HttpGet("compare")]
    public IActionResult Compare()
    {
        return Ok(new
        {
            options = _options.Value.PageSize,
            snapshot = _snapshot.Value.PageSize,
            monitor = _monitor.CurrentValue.PageSize
        });
    }
}
curl http://localhost:5080/api/settings
{"pageSize":5,"currency":"TRY","supportEmail":"support@example.com","hasPricingApiKey":true}

Aynı isteği Production ortamında attığında pageSize 20'ye döner, hasPricingApiKey da false olur; user secrets orada devrede değil.

IOptions, IOptionsSnapshot ve IOptionsMonitor

Üç interface'in farkı, ayar dosyası uygulama çalışırken değiştiğinde ortaya çıkar. JSON provider'ları dosyayı varsayılan olarak izler. Uygulama Production ortamında açıkken compare endpoint'ini çağır, sonra appsettings.json'daki PageSize'ı 30 yapıp kaydet ve tekrar çağır:

curl http://localhost:5080/api/settings/compare
{"options":20,"snapshot":20,"monitor":20}

curl http://localhost:5080/api/settings/compare
{"options":20,"snapshot":30,"monitor":30}

IOptions<T> singleton'dır; değeri ilk okunduğunda hesaplanır ve uygulama yeniden başlayana kadar değişmez. IOptionsSnapshot<T> scoped'dır; her request'in başında güncel değeri okur ve request boyunca sabit tutar. IOptionsMonitor<T> yine singleton'dır ama CurrentValue her zaman en güncel değeri verir, OnChange ile değişikliğe abone de olabilirsin.

Lifetime yazısındaki kural burada da geçerli. Bir singleton service'te güncel ayar istiyorsan IOptionsMonitor<T> kullanırsın; IOptionsSnapshot<T> scoped olduğu için singleton'a enjekte edilemez. Ayar uygulama ömrü boyunca değişmeyecekse IOptions<T> en sade ve en ucuz seçenek.

Options Validation: Hatalı Ayarla Açılmayan Uygulama

CatalogOptions'a koyduğumuz [Range] ve [EmailAddress] attribute'ları kendiliğinden çalışmaz. Kaydı AddOptions ile kurup doğrulamayı açıkça istemen gerekir:

builder.Services.AddOptions<CatalogOptions>()
    .Bind(builder.Configuration.GetSection(CatalogOptions.SectionName))
    .ValidateDataAnnotations()
    .Validate(o => o.Currency.Length == 3, "Currency must be a 3-letter ISO code.")
    .ValidateOnStart();

ValidateDataAnnotations attribute'ları devreye sokar, Validate ise attribute ile ifade edilemeyen kurallar için bir lambda alır. ValidateOnStart .NET 6'da mevcut ve kontrolü uygulamanın açılış anına çeker. Üç değeri bilerek bozup başlatalım:

Catalog__PageSize=500 Catalog__Currency=TL Catalog__SupportEmail=support dotnet run
Unhandled exception. Microsoft.Extensions.Options.OptionsValidationException: DataAnnotation validation failed for 'CatalogOptions' members: 'PageSize' with the error: 'The field PageSize must be between 1 and 100.'.; DataAnnotation validation failed for 'CatalogOptions' members: 'SupportEmail' with the error: 'The SupportEmail field is not a valid e-mail address.'.; Currency must be a 3-letter ISO code.

Uygulama hiç açılmadı ve üç hatanın üçü de tek mesajda. ValidateOnStart'ı kaldırırsan uygulama sorunsuz açılır, hata ancak birisi options'ın Value'suna ilk eriştiğinde patlar. Bizim örnekte bu, /api/settings'e gelen ilk request'in 500 Internal Server Error ile dönmesi demek. Yanlış ayarla deploy edilmiş bir uygulamanın açılışta düşmesi, gece yarısı bir kullanıcının isteğinde düşmesinden her zaman iyidir.

Sık Düşülen Tuzaklar

Birincisi, section adında harf hatası. GetSection("Catalgo") yazarsan hiçbir exception almazsın; boş bir section bağlanır ve class'ın varsayılan değerleri kullanılır:

{"pageSize":10,"currency":"","supportEmail":"","hasPricingApiKey":false}

Validation açık olsaydı [Required] alanı bu hatayı hemen yakalardı. Options class'larına en az bir zorunlu alan koymak bu yüzden iyi bir alışkanlık.

İkincisi, IOptionsSnapshot<T>'ı singleton'a enjekte etmek. Development ortamında uygulama şu hatayla açılmaz:

Cannot consume scoped service 'Microsoft.Extensions.Options.IOptionsSnapshot`1[ProductCatalog.Options.CatalogOptions]' from singleton 'ProductCatalog.Options.ShippingCalculator'.

Üçüncüsü, secret'ları appsettings.json'a ya da appsettings.Development.json'a yazmak. İkisi de repoda durur. Git geçmişine giren bir key'i silmek yetmez, key'i iptal edip yenisini üretmen gerekir.

Dördüncüsü, environment variable'ı Catalog:PageSize diye vermeye çalışmak. Bash gibi shell'ler değişken adında : kabul etmez ve değer uygulamaya hiç ulaşmaz. Hiyerarşik key'ler için her zaman __ kullan.

Beşincisi, IOptions<T>'nin dosya değişikliğini göreceğini sanmak. Gördüğün gibi görmez; canlıda ayar değiştirip restart etmeden etkisini bekliyorsan IOptionsSnapshot<T> ya da IOptionsMonitor<T> kullanmalısın.

Küçük Bir Deneme

★☆☆ appsettings.Production.json adında bir dosya oluştur, içine PageSize için 40 yaz. Uygulamayı Development ve Production ortamlarında ayrı ayrı çalıştırıp /api/config/page-size sonucunu karşılaştır.

★★☆ CatalogOptions'a MaxPrice adında bir property ekle ve Validate ile "MaxPrice, PageSize'dan büyük olmalı" gibi iki alanı birlikte kontrol eden bir kural yaz. Kuralı bozan bir değeri command line'dan verip hata mesajını oku.

★★★ Singleton bir CurrencyFormatter service'i yaz. Constructor'ında IOptionsMonitor<CatalogOptions> alsın, OnChange ile değişikliğe abone olsun ve para birimi değiştiğinde konsola bir satır yazsın. Uygulama açıkken appsettings.json'daki Currency'yi değiştirip konsolu izle.

Ayar değiştiğinde konsola bir satır yazmak dedik ama Console.WriteLine gerçek bir uygulamada iz bırakmanın doğru yolu değil. Bir sonraki yazıda ILogger<T>, log seviyeleri ve filtreleri, structured logging ve Serilog ile uygulamanın ne yaptığını gerçekten okunabilir hâle getireceğiz. Log seviyelerini de bugün gördüğün Logging bölümünden yöneteceğiz.