Table of contents
- Feature Flags in .NET with LaunchDarkly
- Prerequisites
- Why Web API and Not MVC?
- What We Are Building
- Project Structure
- Step 1: LaunchDarkly Account Setup
- Step 2: Create the .NET Project
- Step 3: Register LaunchDarkly and Build the Flag Service
- Step 4: Build the Products Controller
- Step 5: Advanced Flag Types — JSON and Multivariate
- Step 6: Targeting Rules — The Real Power
- Step 7: Run and Test the API
- Step 8: Unit Testing
- Best Practices
- Common Pitfalls
- Production Patterns
- Advanced Topics
- Summary
Feature Flags in .NET with LaunchDarkly
A complete, practical guide to implementing feature flags in an ASP.NET Core Web API using LaunchDarkly — from account setup to production patterns.
Prerequisites
- .NET 8 SDK (or .NET 9+)
- A free LaunchDarkly account (app.launchdarkly.com)
- Basic understanding of C# and ASP.NET Core
- IDE (Visual Studio, VS Code, or Rider)
Why Web API and Not MVC?
Feature flags shine brightest in a Web API context. The flag's effect shows up directly in JSON responses — there are no HTML templates or Razor views in the way. This also mirrors how real-world microservices and backend APIs use LaunchDarkly in production. Every concept you learn here transfers directly to MVC, Blazor, and Worker Services.
What We Are Building
We will build a Product Catalog API that uses five different feature flags to control real behaviour. Here is what each flag controls:
| Flag Key | Type | What It Controls |
|---|---|---|
|
|
Boolean | Switch between old and new pricing model per user |
|
|
String (multivariate) | Sort algorithm: "default", "ml-ranked", or "trending" |
|
|
Number | Pagination limit: 10, 25, or 50 results |
|
|
Boolean | Gate the entire search endpoint behind a flag |
|
|
JSON | Dynamic banner config with text, type, and dismissible |
The request flow looks like this:
HTTP Request → Controller → FeatureFlagService → LaunchDarkly SDK → Flag value → Response
Project Structure
ProductCatalogApi/
├── Controllers/
│ └── ProductsController.cs ← API controller with flag-driven logic
├── Models/
│ └── Product.cs ← data models
├── Services/
│ ├── IFeatureFlagService.cs ← interface for testability
│ └── FeatureFlagService.cs ← wraps the LaunchDarkly SDK
├── appsettings.json
└── Program.cs ← DI setup and SDK initialization
Step 1: LaunchDarkly Account Setup
Create Your Account and Get the SDK Key
- Go to app.launchdarkly.com and sign up. The free trial gives full access.
- Navigate to Account Settings → Projects → your project name.
- Find the Test environment row and click the eye icon next to SDK key to reveal it, then copy it.
⚠️ Two keys, two very different purposes
Each LaunchDarkly environment has an SDK key (starts with
sdk-) for server-side apps, and a Client-side ID for browser and mobile apps. We are building a .NET server app, so we always use the SDK key. Never commit it to source control — treat it like a database password.
Create the Feature Flags
In the LaunchDarkly dashboard go to Feature Flags → Create Flag and create all five flags from the table above. Leave them all OFF for now — we will toggle them on as we test each one.
✅ Tip: Safe defaults for string flags
For
product-sort-algorithm, set both the default variation and the off variation to"default". This way, if the flag is off or something goes wrong, users always fall back to the known-good sort order.
Step 2: Create the .NET Project
Scaffold and Install Packages
# Create a new minimal Web API project
dotnet new webapi -n ProductCatalogApi --no-openapi
cd ProductCatalogApi
# Add the LaunchDarkly server-side SDK
dotnet add package LaunchDarkly.ServerSdk
# Use user secrets to store the SDK key safely during development
dotnet user-secrets init
dotnet user-secrets set "LaunchDarkly:SdkKey" "sdk-YOUR-KEY-HERE"
ℹ️ Why user secrets?
dotnet user-secretsstores sensitive values outside the project folder so they never get committed to git. For production deployments, use an environment variable (LaunchDarkly__SdkKey) or a secrets manager like Azure Key Vault or AWS Secrets Manager.
Step 3: Register LaunchDarkly and Build the Flag Service
appsettings.json
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"LaunchDarkly": {
"SdkKey": "",
"StartWaitSeconds": 5
}
}
Program.cs — The Most Important File
The LaunchDarkly client must be registered as a singleton. The SDK opens a persistent streaming connection and caches all flag data in memory. Every flag evaluation is a sub-millisecond local lookup — creating a new client per request would be a serious performance bug.
using LaunchDarkly.Sdk.Server;
using LaunchDarkly.Sdk.Server.Interfaces;
using ProductCatalogApi.Services;
var builder = WebApplication.CreateBuilder(args);
// ──────────────────────────────────────────────────────────────
// 1. Read config
// ──────────────────────────────────────────────────────────────
var sdkKey = builder.Configuration["LaunchDarkly:SdkKey"]
?? throw new InvalidOperationException("LaunchDarkly SDK key is not configured." +
"Run: dotnet user-secrets set \"LaunchDarkly:SdkKey\" \"your-sdk-key-here\"");
var startWaitTime = TimeSpan.FromSeconds(
builder.Configuration.GetValue<int>("LaunchDarkly:StartWaitSeconds", 5));
// ──────────────────────────────────────────────────────────────
// 2. Build LaunchDarkly configuration
// ──────────────────────────────────────────────────────────────
var ldConfig = Configuration.Builder(sdkKey)
// Stream updates in real-time from LD (default & recommended)
.DataSource(Components.StreamingDataSource())
// In-memory storage is default - no need to call .DataStore() at all.
// If you want Redis for high-availability, you would use:
// .DataStore(Components.PersistentDataStore(Redis.DataStore()..Uri("redis://localhost:6379")))
.Build();
// ──────────────────────────────────────────────────────────────
// 3. Register as singleton — ONE client for the entire app
// ──────────────────────────────────────────────────────────────
var ldClient = new LdClient(ldConfig);
// Block until the client has loaded all flags (or timeout)
if (!ldClient.Initialized)
{
Console.WriteLine("⚠️ LaunchDarkly client did not initialize within the timeout. " +
"Using fallback values.");
}
builder.Services.AddSingleton<ILdClient>(ldClient);
// ──────────────────────────────────────────────────────────────
// 4. Register our feature flag service
// ──────────────────────────────────────────────────────────────
builder.Services.AddScoped<IFeatureFlagService, FeatureFlagService>();
builder.Services.AddControllers();
// ──────────────────────────────────────────────────────────────
// 5. Graceful shutdown — IMPORTANT: dispose the LD client so
// it flushes any pending analytics events
// ──────────────────────────────────────────────────────────────
var app = builder.Build();
app.Lifetime.ApplicationStopping.Register(() =>
{
Console.WriteLine("Flushing LaunchDarkly events...");
ldClient.Dispose();
});
// Configure the HTTP request pipeline.
app.UseHttpsRedirection();
app.MapControllers();
app.Run();
IFeatureFlagService.cs — The Interface
Always wrap the LaunchDarkly SDK behind your own interface. This keeps controllers clean and makes unit testing trivial — you can swap the real client for a mock without touching a single controller.
File: Services/IFeatureFlagService.cs
using LaunchDarkly.Sdk;
using System;
namespace ProductCatalogApi.Services;
public interface IFeatureFlagService
{
/// Evaluate a boolean flag. Returns <paramref name="defaultValue"/> on error.
bool GetBoolFlag(string flagKey, Context context, bool defaultValue = false);
/// Evaluate a string (multivariate) flag.
string GetStringFlag(string flagKey, Context context, string defaultValue = "");
/// Evaluate a number flag.
double GetNumberFlag(string flagKey, Context context, double defaultValue = 0);
/// Evaluate a JSON flag. Returns the raw JSON string.
string GetJsonFlag(string flagKey, Context context, string defaultValue = "{}");
/// Returns flag value and the reason why (useful for debugging).
(bool Value, string Reason) GetBoolFlagWithReason(
string flagKey, Context context, bool defaultValue = false);
}
FeatureFlagService.cs — The Implementation
File: Services/FeatureFlagService.cs
using LaunchDarkly.Sdk;
using LaunchDarkly.Sdk.Server.Interfaces;
using System;
namespace ProductCatalogApi.Services;
public class FeatureFlagService : IFeatureFlagService
{
private readonly ILdClient _ldClient;
private readonly ILogger<FeatureFlagService> _logger;
public FeatureFlagService(ILdClient ldClient, ILogger<FeatureFlagService> logger)
{
_ldClient = ldClient;
_logger = logger;
}
public bool GetBoolFlag(string flagKey, Context context, bool defaultValue = false)
{
// BoolVariation returns defaultValue if:
// - flag doesn't exist
// - SDK is not initialized
// - context is invalid
var value = _ldClient.BoolVariation(flagKey, context, defaultValue);
_logger.LogDebug("Flag [{FlagKey}] for user [{UserId}] = {Value}",
flagKey, context.Key, value);
return value;
}
public string GetStringFlag(string flagKey, Context context, string defaultValue = "")
=> _ldClient.StringVariation(flagKey, context, defaultValue);
public double GetNumberFlag(string flagKey, Context context, double defaultValue = 0)
=> _ldClient.DoubleVariation(flagKey, context, defaultValue);
public string GetJsonFlag(string flagKey, Context context, string defaultValue = "{}")
{
var value = _ldClient.JsonVariation(flagKey, context, LdValue.Parse(defaultValue));
return value.ToJsonString();
}
public (bool Value, string Reason) GetBoolFlagWithReason(
string flagKey, Context context, bool defaultValue = false)
{
// VariationDetail gives you the evaluation reason — very useful for debugging
var detail = _ldClient.BoolVariationDetail(flagKey, context, defaultValue);
var reason = detail.Reason.Kind.ToString() ?? "UNKNOWN";
// If it was a rule match, include which rule index matched
if (detail.Reason.Kind == EvaluationReasonKind.RuleMatch)
reason += $" (rule #{detail.Reason.RuleIndex})";
return (detail.Value, reason);
}
}
Understanding the LaunchDarkly Context
Context is basically "who is asking" — it's the information you pass to LaunchDarkly so it can decide which variation of a flag to return for that specific person.
Think of it this way. When your API evaluates a flag, LaunchDarkly needs to answer: "should THIS user get true or false?" It can't do that without knowing anything about the user. The Context is how you tell it.
// Without context — LD has no idea who to evaluate for
_ldClient.BoolVariation("show-new-pricing", ???, false);
// With context — now LD knows exactly who is asking
var context = Context.Builder("user-456")
.Set("plan", "premium")
.Build();
_ldClient.BoolVariation("show-new-pricing", context, false);
The key field is the unique key — that's the minimum required. Everything else is optional attributes you add when you want targeting rules to use them.
// Minimum — just an ID
var context = Context.New("user-456");
// With attributes — needed if your LD rules say things like
// "if plan = premium → return true"
var context = Context.Builder("user-456")
.Set("plan", "premium")
.Set("country", "PL")
.Build();
The attributes only matter if you use them in a targeting rule. In the LaunchDarkly dashboard when you write a rule like:
If plan is premium → serve true
...LaunchDarkly looks at the context's plan attribute to evaluate that rule. If you never write rules based on country, there's no point setting it.
So in short: Context = user identity + any attributes LaunchDarkly needs to apply your targeting rules. Without it, LD can only return the same value to everyone. With it, you get per-user, per-plan, per-country control.
Step 4: Build the Products Controller
Flag keys are defined as constants at the top of the class — never scatter magic strings through your code.
File: Controllers/ProductsController.cs
using System.Text.Json;
using LaunchDarkly.Sdk;
using Microsoft.AspNetCore.Mvc;
using ProductCatalogApi.Models;
using ProductCatalogApi.Services;
namespace ProductCatalogApi.Controllers;
[Route("api/products")]
[ApiController]
public class ProductController : ControllerBase
{
// --- Flag keys as constants: avoids typos across the codebase
private const string FlagShowNewPricing = "show-new-pricing";
private const string FlagSortAlgorithm = "product-sort-algorithm";
private const string FlagMaxResults = "max-results-per-page";
private const string FlagEnableSearchV2 = "enable-search-v2";
private const string FlagBannerMessage = "banner-message";
private readonly IFeatureFlagService _flags;
private readonly ILogger<ProductController> _logger;
// Simulated product database
private static readonly List<Product> _products = new()
{
new(1, "Laptop Pro", 999m, 879m, "Electronics", 8.5, 9.1),
new(2, "Wireless Mouse", 29m, 24m, "Peripherals", 7.2, 6.8),
new(3, "USB-C Hub", 49m, 39m, "Peripherals", 9.1, 8.3),
new(4, "Mechanical Keyboard",89m, 79m, "Peripherals", 6.4, 7.2),
new(5, "4K Monitor", 399m, 349m, "Electronics", 8.9, 9.5),
new(6, "Webcam HD", 59m, 49m, "Electronics", 7.7, 7.1),
new(7, "Desk Lamp", 35m, 29m, "Accessories", 5.5, 5.9),
new(8, "Cable Manager", 15m, 12m, "Accessories", 4.2, 4.8),
};
public ProductController(IFeatureFlagService flags, ILogger<ProductController> logger)
{
_flags = flags;
_logger = logger;
}
// ─────────────────────────────────────────────────────────────────────
// GET /api/products?userId=user-123&plan=premium
// ─────────────────────────────────────────────────────────────────────
[HttpGet]
public IActionResult GetProducts(
[FromQuery] string userId = "anonymous",
[FromQuery] string plan = "free")
{
// ── Build a LaunchDarkly context from the request ─────────────────
// In a real app, this would come from your auth token / JWT claims.
var context = Context.Builder(userId)
.Set("plan", plan)
.Set("country", Request.Headers["X-Country"].FirstOrDefault() ?? "unknown")
.Build();
// ── FLAG 1: Boolean — show new or old pricing ─────────────────────
bool useNewPricing = _flags.GetBoolFlag(FlagShowNewPricing, context);
_logger.LogInformation("User {UserId}: show-new-pricing = {Value}", userId, useNewPricing);
// ── FLAG 2: String multivariate — sort algorithm ──────────────────
string sortAlgo = _flags.GetStringFlag(FlagSortAlgorithm, context, "default");
// ── FLAG 3: Number — max results ──────────────────────────────────
int maxResults = (int)_flags.GetNumberFlag(FlagMaxResults, context, 10);
// ── FLAG 4: JSON — banner config ──────────────────────────────────
string? bannerText = null;
var bannerJson = _flags.GetJsonFlag(FlagBannerMessage, context, "{}");
var banner = JsonSerializer.Deserialize<JsonElement>(bannerJson);
bool bannerEnabled = banner.TryGetProperty("enabled", out var e) && e.GetBoolean();
if (bannerEnabled)
{
bannerText = banner.TryGetProperty("text", out var t) ? t.GetString() : null;
}
// ── Apply sort algorithm ──────────────────────────────────────────
var sorted = SortProducts(_products, sortAlgo);
// ── Apply pagination ──────────────────────────────────────────────
var paged = sorted.Take(maxResults).ToList();
// ── Build response — pricing depends on the boolean flag ──────────
var responseProducts = paged.Select(p => useNewPricing
? (object)new { p.Id, p.Name, Price = p.NewPrice, p.Category, Label = "NEW PRICE 🎉" }
: (object)new { p.Id, p.Name, Price = p.OldPrice, p.Category, Label = "" }
);
var response = new ProductListResponse(
Products: responseProducts,
SortAlgorithm: sortAlgo,
TotalReturned: paged.Count,
MaxAllowed: maxResults,
NewPricingEnabled: useNewPricing,
BannerMessage: bannerText
);
return Ok(response);
}
// ─────────────────────────────────────────────────────────────────────
// GET /api/products/search?q=laptop&userId=user-123
// Only works if enable-search-v2 flag is ON for this user
// ─────────────────────────────────────────────────────────────────────
[HttpGet("search")]
public IActionResult Search([FromQuery] string q, [FromQuery] string userId = "anonymous")
{
var context = Context.New(userId);
// ── FLAG: Gate an entire endpoint behind a boolean flag ───────────
bool searchV2Enabled = _flags.GetBoolFlag(FlagEnableSearchV2, context);
if (!searchV2Enabled)
{
return StatusCode(501, new
{
Error = "Search v2 is not enabled for your account.",
FlagKey = FlagEnableSearchV2,
Suggestion = "Contact support or join the beta program."
});
}
// Only reached if flag is ON
var results = _products
.Where(p => p.Name.Contains(q, StringComparison.OrdinalIgnoreCase))
.Select(p => new { p.Id, p.Name, p.Category });
return Ok(new { Query = q, Results = results, Engine = "v2-elasticsearch" });
}
// ─────────────────────────────────────────────────────────────────────
// GET /api/products/{id}/flag-debug
// Shows evaluation detail — useful for troubleshooting targeting rules
// ─────────────────────────────────────────────────────────────────────
[HttpGet("{id}/flag-debug")]
public IActionResult FlagDebug(int id,
[FromQuery] string userId = "anonymous",
[FromQuery] string plan = "free")
{
var context = Context.Builder(userId).Set("plan", plan).Build();
var (pricingValue, pricingReason) = _flags
.GetBoolFlagWithReason(FlagShowNewPricing, context);
return Ok(new
{
UserId = userId,
Plan = plan,
FlagKey = FlagShowNewPricing,
Value = pricingValue,
EvaluationReason = pricingReason,
// Reason values: FALLTHROUGH, RULE_MATCH, PREREQUISITE_FAILED,
// TARGET_MATCH, OFF, ERROR
});
}
// ── Private helpers ───────────────────────────────────────────────────
private static IEnumerable<Product> SortProducts(
IEnumerable<Product> products, string algorithm) => algorithm switch
{
"ml-ranked" => products.OrderByDescending(p => p.MlRankScore),
"trending" => products.OrderByDescending(p => p.TrendingScore),
_ => products // "default" — keep DB order
};
}
and add necessary models in Models/Product.cs:
namespace ProductCatalogApi.Models;
public record Product(
int Id,
string Name,
decimal OldPrice,
decimal NewPrice,
string Category,
double TrendingScore,
double MlRankScore);
public record ProductListResponse(
IEnumerable<object> Products,
string SortAlgorithm,
int TotalReturned,
int MaxAllowed,
bool NewPricingEnabled,
string? BannerMessage);
Step 5: Advanced Flag Types — JSON and Multivariate
Setting Up the JSON Flag in the Dashboard
JSON flags are LaunchDarkly's most flexible type. Instead of a single boolean or string, you ship an entire configuration object that your app deserialises at runtime.
Create the banner-message flag as JSON type with these variations:
| Variation Name | JSON Value |
|---|---|
| Banner off |
|
| Sale banner |
|
| Warning banner |
|
⚠️ JSON flags do not accept null
LaunchDarkly rejects
nullas a JSON flag variation. Always use a real JSON object. Use{"enabled": false}as your safe "off" state rather thannull.
Deserialise to a Typed C# Record
You could work with the raw JSON string, but it's much cleaner to deserialise it into a typed C# record. This way, you get compile-time safety and IntelliSense when accessing the banner config.
// Define a model that mirrors the JSON flag shape
public record BannerConfig(bool Enabled, string Text, string Type, bool Dismissible);
// In your controller or service:
var bannerJson = _flags.GetJsonFlag("banner-message", ctx, "{\"enabled\":false}");
var config = JsonSerializer.Deserialize<BannerConfig>(bannerJson,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (config?.Enabled == true)
Console.WriteLine($"Show banner: {config.Text} ({config.Type})");
Percentage Rollouts for A/B/C Tests
String flags with multiple variations are perfect for controlled experiments. Configure a percentage rollout for product-sort-algorithm:
- 33% of traffic →
"default" - 33% of traffic →
"ml-ranked" - 34% of traffic →
"trending"
LaunchDarkly uses a deterministic hash of the user's key to assign buckets, so the same user always gets the same variant. This is essential for experiment integrity and a consistent user experience.
Step 6: Targeting Rules — The Real Power
Targeting rules are what separates a production-grade feature flag system from a simple environment variable. All rules live in the LaunchDarkly dashboard — no code change, no redeploy needed.
Individual User Targeting
In the LaunchDarkly dashboard, go to show-new-pricing → Individual Targeting tab. Add specific user keys that should always receive true. This lets you verify a feature in production while everyone else still sees the old behaviour.
Attribute-Based Rules
Add a rule to show-new-pricing: "If plan is one of [premium, enterprise], serve true." The plan attribute comes from the context we build in the controller:
// ?userId=alice&plan=premium → rule matches → gets true
// ?userId=bob&plan=free → no rule match → falls through to default
var ctx = Context.Builder(userId)
.Set("plan", plan) // this attribute drives the LaunchDarkly targeting rule
.Build();
Gradual Percentage Rollout
The safest release strategy. Under the flag's Default Rule, choose Percentage rollout and start at 5% true. Watch your error rates and latency. If healthy, raise to 25% → 50% → 100%. If something looks wrong, drop back to 0% instantly — no deployment needed.
Rule Evaluation Order
LaunchDarkly evaluates rules top-to-bottom and stops at the first match:
- Individual targeting — specific user keys always go first
- Rule 1 — attribute conditions (e.g.
plan = enterprise) - Rule 2 — further conditions (e.g.
country = PL) - Default rule — percentage rollout or fixed variation
- Off variation — returned when the flag is disabled globally
Step 7: Run and Test the API
Start the Application
cd ProductCatalogApi
dotnet run
# Now listening on: http://localhost:5000
Test Flag OFF vs ON
# Flag is OFF → old pricing, 10 results, default sort
curl "http://localhost:5000/api/products?userId=user-1&plan=free" | jq
# Toggle show-new-pricing ON in the LaunchDarkly dashboard, then:
curl "http://localhost:5000/api/products?userId=user-1&plan=premium" | jq
Response when the flag is ON for a premium user:
{
"newPricingActive": true,
"sortAlgorithm": "default",
"maxAllowed": 10,
"banner": null,
"products": [
{ "id": 1, "name": "Laptop Pro", "price": 879, "tag": "SALE" }
]
}
Test the Gated Search Endpoint
# Flag OFF → 501 Not Implemented
curl "http://localhost:5000/api/products/search?q=laptop&userId=user-1"
# Enable enable-search-v2 in the dashboard, then:
curl "http://localhost:5000/api/products/search?q=laptop&userId=user-1"
Inspect Evaluation Reasons
curl "http://localhost:5000/api/products/1/flag-debug?userId=user-1&plan=premium"
{
"userId": "user-1",
"plan": "premium",
"flagKey": "show-new-pricing",
"value": true,
"reason": "RULE_MATCH (rule #0)"
}
Step 8: Unit Testing
Because we coded to an interface, unit tests never need a live LaunchDarkly connection. Just mock IFeatureFlagService:
using Moq;
using Xunit;
using LaunchDarkly.Sdk;
public class ProductsControllerTests
{
[Fact]
public void GetProducts_WhenNewPricingOn_ReturnsNewPrice()
{
var mockFlags = new Mock<IFeatureFlagService>();
mockFlags
.Setup(f => f.GetBoolFlag("show-new-pricing", It.IsAny<Context>(), false))
.Returns(true); // simulate flag ON
mockFlags
.Setup(f => f.GetStringFlag("product-sort-algorithm", It.IsAny<Context>(), "default"))
.Returns("default");
mockFlags
.Setup(f => f.GetNumberFlag("max-results-per-page", It.IsAny<Context>(), 10))
.Returns(10);
mockFlags
.Setup(f => f.GetJsonFlag("banner-message", It.IsAny<Context>(), It.IsAny<string>()))
.Returns("{\"enabled\":false}");
var controller = new ProductsController(mockFlags.Object);
var result = controller.GetProducts("user-1", "free") as OkObjectResult;
Assert.NotNull(result);
var json = JsonSerializer.Serialize(result!.Value);
Assert.Contains("\"newPricingActive\":true", json);
}
[Fact]
public void Search_WhenFlagOff_Returns501()
{
var mockFlags = new Mock<IFeatureFlagService>();
mockFlags
.Setup(f => f.GetBoolFlag("enable-search-v2", It.IsAny<Context>(), false))
.Returns(false); // flag is OFF
var controller = new ProductsController(mockFlags.Object);
var result = controller.Search("laptop", "user-1");
Assert.IsType<ObjectResult>(result);
Assert.Equal(501, ((ObjectResult)result).StatusCode);
}
}
Best Practices
1. One client, forever
Register LdClient as AddSingleton. It opens a single streaming connection and caches all flags in memory. Creating one per request is a critical bug.
2. Always use the interface
Wrap the SDK behind IFeatureFlagService. Controllers stay clean, and unit tests never need a live connection.
3. Choose safe fallback values
The third argument in every variation call is returned when LaunchDarkly is unreachable. For risky operations, fail closed (false). For performance features, fail open (true).
// ✅ Fail closed — if LD is down, don't run the migration
bool runMigration = _flags.GetBoolFlag("run-db-migration", ctx, false);
// ✅ Fail open — if LD is down, keep the cache running
bool useCache = _flags.GetBoolFlag("enable-response-cache", ctx, true);
4. Flag keys as constants
Never scatter string literals through your codebase. Define all flag keys as private const string at the top of each class that uses them.
5. Dispose gracefully
Register a shutdown handler to call ldClient.Dispose(). Without this, buffered analytics events are lost when the process exits.
Common Pitfalls
1. Creating LdClient per request
// ❌ Wrong — opens a new streaming connection on every request
public IActionResult Get()
{
var client = new LdClient(config);
...
}
// ✅ Correct — inject the singleton registered in Program.cs
public IActionResult Get()
{
var value = _flags.GetBoolFlag("my-flag", ctx);
...
}
2. Using null as a JSON variation
// ❌ Wrong — LaunchDarkly rejects null as a JSON variation value
// (you will see "Null variation value is not supported for JSON variations")
// ✅ Correct — always use a real JSON object as the off state
// {"enabled": false, "text": "", "type": "info"}
3. Forgetting to dispose on shutdown
// ❌ Wrong — buffered events are lost on process exit
var app = builder.Build();
app.Run();
// ✅ Correct — flush buffered events first
app.Lifetime.ApplicationStopping.Register(() => ldClient.Dispose());
app.Run();
4. Leaking the SDK key
# ❌ Wrong — never hardcode or commit the key
var sdkKey = "sdk-abc123";
# ✅ Correct — use user secrets locally
dotnet user-secrets set "LaunchDarkly:SdkKey" "sdk-abc123"
# ✅ Correct — use environment variable in production
export LaunchDarkly__SdkKey="sdk-abc123"
5. Leaving flags in code indefinitely
Once a flag reaches 100% rollout, archive it in the dashboard and delete the dead code branch. Stale flags become invisible complexity that confuses future developers.
Production Patterns
Pattern 1 — The Strangler Fig (Safe Migration)
Run old and new code paths side by side. Gradually shift traffic until you can safely delete the old path entirely.
public async Task<IEnumerable<Order>> GetOrdersAsync(string userId)
{
var ctx = Context.New(userId);
return _flags.GetBoolFlag("use-new-order-service", ctx)
? await _newService.GetOrdersAsync(userId) // new path
: await _legacyService.GetOrdersAsync(userId); // old path
// When flag reaches 100%, delete the legacy branch and archive the flag
}
Pattern 2 — Kill Switch for Integrations
If a third-party service has an incident, toggle a flag to instantly route to a backup. No deployment, no on-call escalation.
public async Task<PaymentResult> ChargeAsync(PaymentRequest req)
{
var ctx = Context.Builder(req.UserId).Set("amount", req.Amount).Build();
if (!_flags.GetBoolFlag("enable-stripe", ctx, true))
{
_logger.LogWarning("Stripe disabled via feature flag. Routing to backup.");
return await _backupProcessor.ChargeAsync(req);
}
return await _stripeProcessor.ChargeAsync(req);
}
Pattern 3 — Live Configuration
Stop storing frequently changed values in appsettings.json. Use number flags to adjust rate limits, timeouts, and thresholds in real time — no restart required.
// Adjust this value live from the LaunchDarkly dashboard
int rateLimit = (int)_flags.GetNumberFlag(
"api-rate-limit-per-minute",
Context.New("system"), // system-level context, not per-user
defaultValue: 100
);
Advanced Topics
1. Decorator-Based Caching
Wrap the flag service with a caching decorator to reduce evaluation overhead for extremely hot paths:
public class CachingFeatureFlagService : IFeatureFlagService
{
private readonly IFeatureFlagService _inner;
private readonly IMemoryCache _cache;
public bool GetBoolFlag(string key, Context ctx, bool fallback = false)
{
var cacheKey = $"{key}:{ctx.Key}";
return _cache.GetOrCreate(cacheKey, entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30);
return _inner.GetBoolFlag(key, ctx, fallback);
});
}
// ... other methods
}
2. Notifications as Domain Events
Fire a notification whenever a high-value flag changes state. Useful for audit logging or triggering side effects:
public class FlagChangedNotification : INotification
{
public string FlagKey { get; set; }
public string UserId { get; set; }
public bool NewValue { get; set; }
}
3. Multi-Context for B2B Apps
In B2B applications where both the user and their organisation matter, use Context.NewMulti. This lets you write targeting rules like "if the user is a beta tester AND their organisation is on the Enterprise plan":
var ctx = Context.NewMulti(
Context.Builder(userId)
.Kind(ContextKind.Of("user"))
.Set("betaTester", true)
.Build(),
Context.Builder(orgId)
.Kind(ContextKind.Of("organization"))
.Set("plan", "enterprise")
.Build()
);
Summary
You now have a complete feature flag implementation in ASP.NET Core Web API!
What we achieved:
✅ LaunchDarkly account setup and flag creation
✅ Singleton SDK client with proper initialization
✅ Clean service abstraction behind an interface
✅ Boolean, string, number, and JSON flag types
✅ User targeting with context attributes
✅ Percentage rollouts for gradual releases
✅ Unit testing with mocks — no live connection needed
✅ Production patterns: Strangler Fig, Kill Switch, Live Config
✅ Common pitfalls and how to avoid them
Next steps:
- Add the LaunchDarkly Relay Proxy for high-availability deployments
- Wire up LaunchDarkly Experimentation to measure flag impact on real metrics
- Integrate with your CI/CD pipeline to automatically archive flags after full rollout
- Explore persistent flag stores (Redis) for zero-downtime SDK restarts
