Mert Özen Her Satırda Daha İleri
DevOps Deployment Stratejileri — #07

Feature Flag'ler: Deploy'u Release'ten Ayırmak, Microsoft.FeatureManagement ve OpenFeature

Mert Özen 8 Kas 2026 11 dk 5 görüntülenme
Feature Flag'ler: Deploy'u Release'ten Ayırmak, Microsoft.FeatureManagement ve OpenFeature

Feature flag kodu deploy edip davranışı bir config değişikliğiyle release etmeyi sağlar. Microsoft.FeatureManagement 4.8 ile targeting (1000 kullanıcıda %10,1, kullanıcıya sabit), variant'lar, minimal API'de WithFeatureGate ve 0,4 sn'de etkili olan kill switch; Angular'da signal tabanlı flag service ve test edilmiş structural directive; OpenFeature'ın preview provider'ında targeting'li boolean flag'in false dönmesi ve flag borcu.

Sürüm notu: Bu yazıdaki örnekler .NET 10 (SDK 10.0.402), Microsoft.FeatureManagement.AspNetCore 4.8.0, OpenFeature 2.14.1, OpenFeature.Contrib.Provider.FeatureManagement 0.1.2-preview ve Angular 22.2 (Vitest 5.1) ile denenmiştir.

Önce Tahmin Et

"Tek tıkla ödeme" özelliğini önce yalnız Ada'ya, sonra beta grubuna, sonra herkesin %10'una açmak istiyorsun. Flag'i DefaultRolloutPercentage: 10 ile yazdın. Aynı kullanıcı sayfayı on kez yenilerse özelliği kaç kez görür? Her seferinde zar mı atılır, yoksa kullanıcı hep aynı tarafta mı kalır?

İkinci soru: Öneri servisi production'da yavaşladı ve sayfa açılışlarını sürüklüyor. Flag'i config'te kapattın. Uygulamayı yeniden başlatmadan değişiklik kaç saniyede etkili olur?

Deploy'u Release'ten Ayırmak

Serinin ilk yazısında deploy ile release'i ayırmıştık. Blue-green ve canary bu ayrımı trafik seviyesinde yapıyor: kod hazır, kullanıcı ne zaman ve ne kadar görecek? Feature flag aynı ayrımı kod seviyesinde yapar. Yeni davranışın kodu production'a gider ama bir koşulun arkasında bekler. Release ise o koşulun değişmesidir, yani bir config değişikliği. Kodu günler önce deploy edebilir, özelliği pazartesi sabahı pazarlama ekibiyle birlikte açabilirsin. Bir sorun çıkarsa da geri dönüş yeni bir deploy değil, aynı config'i geri almaktır.

Bunun bir yan etkisi trunk-based development'ı mümkün kılması: yarım kalmış bir özellik, flag kapalı olduğu sürece main branch'e merge edilip deploy edilebilir. Uzun yaşayan feature branch'lerin merge acısı ortadan kalkar.

Flag Türleri ve Ömürleri

Pete Hodgson'ın Martin Fowler'ın sitesindeki "Feature Toggles (aka Feature Flags)" yazısı flag'leri iki eksende sınıflandırıyor: ne kadar yaşadıkları ve kararın ne kadar dinamik olduğu. Dört tür çıkıyor:

TürAmaçÖmürKarar
Release toggleYarım ya da henüz açılmamış özelliği saklamakGünler, en fazla birkaç haftaGenelde herkes için aynı, kademeli açılış için kullanıcıya göre
Experiment toggleA/B testi, iki davranışı ölçmekDeney süresi, saatler ile haftalarHer kullanıcı için, tutarlı
Ops toggle (kill switch)Bir özelliği yük ya da hata anında kapatmakKısa ya da kalıcıHerkes için, anında değişmeli
Permission toggleBelli kullanıcılara özellik açmak (premium, iç kullanıcı)Uzun, yıllarHer kullanıcı için

Ayrım önemli, çünkü her türün borcu farklı. Release toggle'ın özelliği herkese açıldığında silinmesi gerekir; unutulan release toggle, kodda kimsenin bilmediği bir if olarak yıllarca yaşar. Ops toggle'ın ise tam tersine kalması gerekir, ama en zor anda çalıştığından emin olmak için düzenli olarak denenmesi gerekir. Permission toggle aslında bir yetkilendirme kuralıdır ve zamanla gerçek bir authorization (yetkilendirme) mekanizmasına taşınmalıdır.

Microsoft.FeatureManagement 4.x ile Flag'ler

Microsoft.FeatureManagement flag'leri .NET configuration'dan okur, yani flag'ler appsettings.json'da, environment variable'larda ya da Azure App Configuration'da durabilir. 4.x, Microsoft'un ortak feature_management şemasını kullanıyor:

{
  "feature_management": {
    "feature_flags": [
      {
        "id": "NewCheckout",
        "enabled": true,
        "conditions": {
          "client_filters": [
            {
              "name": "Microsoft.Targeting",
              "parameters": {
                "Audience": {
                  "Users": [ "ada" ],
                  "Groups": [ { "Name": "beta", "RolloutPercentage": 100 } ],
                  "DefaultRolloutPercentage": 10
                }
              }
            }
          ]
        }
      },
      {
        "id": "Recommendations",
        "enabled": true
      },
      {
        "id": "BulkImport",
        "enabled": false
      },
      {
        "id": "CheckoutButton",
        "enabled": true,
        "variants": [
          { "name": "Blue", "configuration_value": "#1f6feb" },
          { "name": "Green", "configuration_value": "#2da44e" }
        ],
        "allocation": {
          "seed": "checkout-button-2026-11",
          "percentile": [
            { "variant": "Blue", "from": 0, "to": 50 },
            { "variant": "Green", "from": 50, "to": 100 }
          ]
        }
      }
    ]
  }
}

Dört flag dört türü temsil ediyor: NewCheckout targeting'li bir release toggle, Recommendations bir kill switch, BulkImport kapalı bir permission toggle ve CheckoutButton iki variant'lı bir experiment. Targeting filter'ın kullanıcının kim olduğunu bilmesi gerekiyor. Varsayılan accessor bunu HttpContext.User'dan alıyor; örneği curl ile denenebilir tutmak için header'dan okuyan küçük bir accessor yazdım:

public sealed class HeaderTargetingContextAccessor(IHttpContextAccessor httpContextAccessor) : ITargetingContextAccessor
{
    public ValueTask<TargetingContext> GetContextAsync()
    {
        HttpRequest request = httpContextAccessor.HttpContext!.Request;
        string userId = request.Headers["X-User-Id"].ToString();
        string[] groups = request.Headers["X-User-Groups"].ToString()
            .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
        return ValueTask.FromResult(new TargetingContext { UserId = userId, Groups = groups });
    }
}
builder.Services.AddHttpContextAccessor();
builder.Services.AddFeatureManagement()
    .WithTargeting<HeaderTargetingContextAccessor>();

WebApplication app = builder.Build();

app.MapGet("/api/checkout", async (IVariantFeatureManager features, CancellationToken ct) =>
{
    bool newCheckout = await features.IsEnabledAsync(FeatureFlags.NewCheckout, ct);
    Variant button = await features.GetVariantAsync(FeatureFlags.CheckoutButton, ct);
    return new
    {
        flow = newCheckout ? "new" : "classic",
        button = button.Name,
        color = button.Configuration?.Value,
    };
});

app.MapGet("/api/books/{id:int}/recommendations", async (int id, IVariantFeatureManager features, CancellationToken ct) =>
{
    if (!await features.IsEnabledAsync(FeatureFlags.Recommendations, ct))
    {
        return Results.Ok(Array.Empty<string>());
    }
    return Results.Ok(new[] { "Release It!", "Accelerate" });
});

app.MapPost("/api/admin/bulk-import", () => Results.Accepted())
    .WithFeatureGate(FeatureFlags.BulkImport);

WithFeatureGate 4.x'te minimal API'ler (Application Programming Interface) için geliyor; MVC (Model-View-Controller) tarafındaki karşılığı [FeatureGate] attribute'u. Sonuçlar:

ada:  {"flow":"new","button":"Green","color":"#2da44e"}
bob (beta): {"flow":"new","button":"Green","color":"#2da44e"}
carol: {"flow":"classic","button":"Green","color":"#2da44e"}
carol again: {"flow":"classic","button":"Green","color":"#2da44e"}

Ada adıyla listede olduğu için, Bob beta grubunda olduğu için yeni akışı görüyor. Carol ikisine de girmiyor ve %10'luk dilimin dışında kalıyor. İki isteğinin cevabı da aynı. Targeting filter kullanıcı kimliğini ve flag adını hash'leyip kullanıcıyı sabit bir yüzdelik dilime yerleştiriyor; zar her istekte atılmıyor. Baştaki ilk sorunun cevabı bu: kullanıcı hep aynı tarafta kalır. 1000 farklı kullanıcıyla dağılım:

for u in $(seq 1 1000); do
    curl -s -H "X-User-Id: user$u" localhost:18630/api/checkout; echo
done | sed 's/.*"flow":"\([a-z]*\)","button":"\([A-Za-z]*\)".*/\1 \2/' | sort | uniq -c
 461 classic Blue
 438 classic Green
  48 new Blue
  53 new Green

Yeni akış 101 kullanıcıda açık (%10,1). Buton variant'ı da 509 maviye 491 yeşil bölünmüş, yani yaklaşık yarı yarıya. Variant ataması seed'e bağlı. Seed'i değiştirirsen kullanıcılar yeniden karışır, bu yüzden bir deney boyunca seed sabit kalmalı. Geri kalan iki durum:

== bulk import
404
== anonymous
{"flow":"classic","button":"Blue","color":"#1f6feb"}

Kapalı flag'in arkasındaki endpoint 404 dönüyor; endpoint'in varlığı bile belli olmuyor. Kimliği olmayan istek de flag'in kapalı tarafına düşüyor. Anonim kullanıcının hangi deneyde olacağına karar vermek senin işin: ilk ziyarette bir anonim kimlik üretip cookie'de saklamak en yaygın yol.

Kill Switch: Ne Kadar Hızlı Kapanır?

Ops toggle'ın değeri, kapatıldığı anla etkili olduğu an arasındaki süreyle ölçülür. Uygulama çalışırken appsettings.json'da Recommendations'ı false yaptım ve endpoint'i her 100 ms'de bir yokladım:

before: ["Release It!","Accelerate"]
edited at 06:51:59 1794109919.626985
kill switch effective after 0.4s
after: []

0,4 saniye. WebApplication.CreateBuilder appsettings.json'ı reloadOnChange ile yükler ve FeatureManagement her değerlendirmede güncel configuration'ı okur. Bu, tek bir sunucu için ikinci sorunun cevabı. Ama production'da dosyayı on sunucuda aynı anda değiştirmek ayrı bir deploy işidir. Bu yüzden kill switch'ler genelde merkezi bir configuration kaynağında tutulur: Azure App Configuration, bir veritabanı tablosu ya da bir flag servisi. O zaman süre, provider'ın yenileme aralığı kadar olur ve onu bilinçli seçmen gerekir. Azure App Configuration provider'ında bu aralık refresh ayarıyla belirlenir; 30 saniyelik bir aralık, bir incident'ta 30 saniyelik ek hasar demek.

Angular Tarafı: Signal Tabanlı Flag Service

Flag'leri frontend'de ayrıca değerlendirmek yerine API'nin değerlendirip sonucu döndürmesi daha güvenli. Kurallar ve kullanıcı listeleri tarayıcıya gitmez, değerlendirme de tek yerde yapılır:

app.MapGet("/api/features", async (IVariantFeatureManager features, CancellationToken ct) =>
{
    Dictionary<string, bool> result = [];
    foreach (string name in FeatureFlags.ExposedToClient)
    {
        result[name] = await features.IsEnabledAsync(name, ct);
    }
    return result;
});
{"NewCheckout":true,"Recommendations":true}

Angular'da bu sonucu bir signal'da tutan küçük bir service:

import { HttpClient } from '@angular/common/http';
import { Injectable, inject, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';

export type FeatureName = 'NewCheckout' | 'Recommendations';

@Injectable({ providedIn: 'root' })
export class FeatureFlags {
  private readonly http = inject(HttpClient);
  private readonly flags = signal<Partial<Record<FeatureName, boolean>>>({});

  async load(): Promise<void> {
    try {
      this.flags.set(await firstValueFrom(this.http.get<Record<FeatureName, boolean>>('/api/features')));
    } catch {
            // API'ye ulaşılamazsa bütün flag'ler kapalı sayılır (fail closed)
      this.flags.set({});
    }
  }

  isEnabled(name: FeatureName): boolean {
    return this.flags()[name] ?? false;
  }
}

Uygulama açılırken bir kez yükleniyor:

provideHttpClient(),
provideAppInitializer(() => inject(FeatureFlags).load()),

Template'te flag'e göre gösterip saklamak için bir structural directive:

import { Directive, TemplateRef, ViewContainerRef, effect, inject, input } from '@angular/core';
import { FeatureFlags, FeatureName } from './feature-flags';

@Directive({ selector: '[appFeature]' })
export class FeatureDirective {
  private readonly flags = inject(FeatureFlags);
  private readonly template = inject(TemplateRef<unknown>);
  private readonly viewContainer = inject(ViewContainerRef);

  readonly appFeature = input.required<FeatureName>();
  readonly appFeatureElse = input<TemplateRef<unknown> | null>(null);

  constructor() {
    effect(() => {
      const enabled = this.flags.isEnabled(this.appFeature());
      const template = enabled ? this.template : this.appFeatureElse();
      this.viewContainer.clear();
      if (template) {
        this.viewContainer.createEmbeddedView(template);
      }
    });
  }
}
@Component({
  selector: 'app-book-list',
  imports: [FeatureDirective],
  template: `
    <h1>Books</h1>
    <button *appFeature="'NewCheckout'; else classicCheckout">One-click checkout</button>
    <ng-template #classicCheckout><a href="/cart">Go to cart</a></ng-template>
  `,
})
export class BookList {}

effect, flag signal'ı değiştiğinde template'i yeniden çiziyor. Flag'ler sonradan tekrar yüklenirse ekran da kendiliğinden güncelleniyor. Bunu Vitest ile test ettim:

class FakeFeatureFlags {
  readonly state = signal<Partial<Record<FeatureName, boolean>>>({});
  isEnabled(name: FeatureName): boolean {
    return this.state()[name] ?? false;
  }
}

@Component({
  imports: [FeatureDirective],
  template: `
    <p *appFeature="'NewCheckout'; else classic">new checkout</p>
    <ng-template #classic><p>classic checkout</p></ng-template>
  `,
})
class Host {}

describe('FeatureDirective', () => {
  it('renders the else template until the flag is turned on', async () => {
    const flags = new FakeFeatureFlags();
    TestBed.configureTestingModule({ providers: [{ provide: FeatureFlags, useValue: flags }] });
    const fixture = TestBed.createComponent(Host);
    await fixture.whenStable();
    expect(fixture.nativeElement.textContent.trim()).toBe('classic checkout');

    flags.state.set({ NewCheckout: true });
    await fixture.whenStable();
    expect(fixture.nativeElement.textContent.trim()).toBe('new checkout');
  });
});
 ✓  bookstore-web  src/app/features/feature.directive.spec.ts (1 test) 33ms

 Test Files  1 passed (1)
      Tests  1 passed (1)

Bir not: frontend'de flag gizlemek güvenlik değildir. Buton görünmese de endpoint çağrılabilir. Bu yüzden BulkImport gibi flag'ler sunucuda da WithFeatureGate ile korunuyor.

OpenFeature: Vendor Bağımsız API

Flag'leri bir SaaS (Software as a Service) servisinden, Azure App Configuration'dan ya da kendi veritabanından okumaya karar verdiğinde kodun her IsEnabledAsync çağrısı o kararın bir parçası olur. CNCF (Cloud Native Computing Foundation) projesi OpenFeature, uygulama kodunu tek bir API'ye bağlayıp asıl kaynağı "provider" arkasına saklıyor. Provider değişince uygulama kodu değişmiyor. .NET SDK'sı 2.x sürümünde ve Microsoft.FeatureManagement için bir provider da var. Planlama sırasında bu provider'ın prerelease olduğunu not etmiştik; yazım anında NuGet'te hâlâ 0.1.2-preview. Aynı flag'leri onun üzerinden okudum:

await Api.Instance.SetProviderAsync(new FeatureManagementProvider(builder.Configuration));

app.MapGet("/api/of/checkout", async (HttpRequest request) =>
{
    FeatureClient client = Api.Instance.GetClient("bookstore");
    EvaluationContext context = EvaluationContext.Builder()
        .SetTargetingKey(request.Headers["X-User-Id"].ToString())
        .Set("UserId", request.Headers["X-User-Id"].ToString())
        .Build();
    bool newCheckout = await client.GetBooleanValueAsync("NewCheckout", false, context);
    string button = await client.GetStringValueAsync("CheckoutButton", "Blue", context);
    FlagEvaluationDetails<bool> typo = await client.GetBooleanDetailsAsync("NewChekout", false, context);
    return new { flow = newCheckout ? "new" : "classic", button, typo = new { typo.Value, typo.Reason, typo.ErrorType } };
});
ada OF: {"flow":"classic","button":"#2da44e","typo":{"value":false,"reason":"ERROR","errorType":2}}
carol OF: {"flow":"classic","button":"#2da44e","typo":{"value":false,"reason":"ERROR","errorType":2}}
user7 OF: {"flow":"classic","button":"#1f6feb","typo":{"value":false,"reason":"ERROR","errorType":2}}

Sonuç öğretici. Variant'lı flag'lerde provider doğru çalışıyor; Ada ve Carol yeşil, user7 mavi variant'ı alıyor ve Microsoft.FeatureManagement'ın kendi sonucu da aynı. Ama string değer olarak variant'ın adı değil configuration_value'su dönüyor. Targeting'li boolean flag ise listede olan Ada için bile false. Provider'ın kaynak koduna bakınca sebep görülüyor: targeting bağlamını yalnız UserId ve Groups attribute'larından kuruyor (OpenFeature'ın TargetingKey'inden değil) ve variant'sız boolean flag'leri o bağlam olmadan değerlendiriyor. Bilinmeyen flag için errorType 2 (FlagNotFound) ve reason ERROR dönüyor; bu davranış doğru ve kullanışlı.

Çıkarım şu: OpenFeature'ın soyutlaması değerli, ama soyutlama provider kadar iyidir. Provider'ı production'a almadan önce kendi flag türlerinle, burada yaptığımız gibi, gerçek bir karşılaştırma testi yap. Ana provider'ı stabil olan bir flag kaynağı (bir SaaS servisinin resmi provider'ı ya da kendi yazdığın küçük bir provider) seçmek de makul bir yol.

Flag Borcu

Her flag, kodda iki yol demek; iki yolun ikisi de test edilmeli ve ikisi de bakım ister. Borcu kontrol altında tutmak için üç alışkanlık:

  • Son kullanma tarihi: release toggle eklerken onu silecek işi aynı gün backlog'a yaz ve flag'in yanına bir tarih koy. Bazı ekipler süresi geçen flag'ler için build'i kırar.
  • Telemetry ile ölü flag tespiti: Microsoft.FeatureManagement 4.x, telemetry.enabled açık olan flag'lerin her değerlendirmesini Microsoft.FeatureManagement adlı ActivitySource üzerinden yayınlar. Haftalardır hep aynı sonucu dönen ya da hiç değerlendirilmeyen bir flag silinmeye adaydır.
  • İki yolu da test etmek: flag açıkken ve kapalıyken çalışan testler. Yukarıdaki Angular testi bunun küçük bir örneği; API tarafında aynı testi IVariantFeatureManager'ı taklit ederek yazarsın.

Üretimde Dikkat

Flag değişikliği bir deploy kadar etkili, ama çoğu zaman pipeline'dan geçmiyor. Bu yüzden flag değişikliklerini de kayda al: kim, ne zaman, hangi değeri değiştirdi. Bir incident'ta "son deploy'dan beri ne değişti?" sorusunun cevabı çoğu zaman bir flag'dir. Flag kaynağına ulaşılamadığında ne olacağına da karar ver: yukarıdaki Angular service'i "fail closed" çalışıyor, yani her şeyi kapalı sayıyor. Kill switch'ler için çoğu zaman doğru olan budur. Ama bir özelliğin kapalı kalması da kesinti sayılıyorsa, son bilinen değeri cache'te tutmak daha iyi bir varsayılan olabilir.

Sık Düşülen Tuzaklar

  • Flag ile şema değişikliğini saklamaya çalışmak. Flag kodu saklar, veritabanını saklamaz. Kolonu silen migration flag'den bağımsız çalışır; şema değişiklikleri için serinin altıncı yazısındaki expand-contract gerekir.
  • Release toggle'ı kalıcı bırakmak. İki yıl sonra kimse UseNewPricing kapatılırsa ne olacağını bilmez ve kimse onu kapatmaya cesaret edemez.
  • Flag'i yalnız frontend'de uygulamak. Görünmeyen buton, çağrılamayan endpoint demek değildir.
  • Seed'i ya da salt'ı deney ortasında değiştirmek. Kullanıcılar gruplar arasında yer değiştirir ve deneyin sonucu anlamını yitirir.
  • Kill switch'i hiç denememek. İlk kez bir incident'ta kapatılan flag'in arkasındaki "kapalı" yolun da çalıştığından emin olmalısın.

Küçük Bir Deneme

★☆☆ NewCheckout flag'ine Exclusion bölümü ekleyip Ada'yı hariç tut. Kullanıcı listesinde olmasına rağmen ne görüyor?

★★☆ Recommendations flag'ine "telemetry": { "enabled": true } ekle ve Microsoft.FeatureManagement ActivitySource'unu dinleyen bir ActivityListener ile değerlendirme sayısını logla.

★★★ OpenFeature için kendi FeatureProvider'ını yaz: IVariantFeatureManager'ı saran ve TargetingKey'i UserId'ye çeviren bir provider. Yukarıdaki karşılaştırmayı tekrar koştur ve Ada için flow değerinin new olduğunu gör.

Feature flag, deploy'dan bağımsız bir geri dönüş düğmesi veriyor. Ama her sorun bir flag'in arkasında durmaz. Bir sonraki yazıda geri dönüşün bütün seçeneklerini yan yana koyuyoruz: artifact rollback, roll forward, kill switch ve bunları otomatik tetikleyen kriterler.