Implementing Features with Feature Flags in .NET
You have been asked to add a new feature. You know feature flags are the way to go, but how exactly do you structure the code? What about database changes? This guide walks through the most common scenarios with .NET examples.
This is Part 1 covering UI toggles and new features with database changes. Part 2 covers advanced patterns like strategy-based refactoring and column migrations.
The Golden Rule
Before diving into patterns, remember this: feature flags are temporary. Every flag you add is technical debt until removed. Design your code so the flag can be deleted cleanly.
This means: do not scatter flag checks throughout your codebase. Centralize the decision point, and make both code paths explicit.
Scenario 1: New UI Feature
The simplest case. You are adding a new dashboard widget, a redesigned page, or a new button. No backend changes needed.
Pattern: Component-Level Toggle
// In your Razor component or controller
public async Task<IActionResult> Dashboard()
{
var showNewWidget = await _flagit.IsEnabledAsync(
"dashboard-analytics-widget",
new { userId = User.GetUserId() }
);
return View(new DashboardViewModel
{
ShowAnalyticsWidget = showNewWidget
});
} <!-- In your Razor view -->
@if (Model.ShowAnalyticsWidget)
{
<partial name="_AnalyticsWidget" />
} Why this works
- Simple to implement and understand
- Easy to remove - delete the condition and the old code path
- No risk to existing functionality
Watch out for
- Do not check the flag in multiple places for the same feature
- Pass the flag result down as a boolean, not the flag service
Scenario 2: New Feature with Database Changes
This is where it gets interesting. You are adding a "notes" feature to orders. You need a new table and new API endpoints.
Step 1: Database Migration (Always First)
Database changes should be deployed before the feature code. This is called the "expand-contract" or "parallel change" pattern.
// Migration: Add the new table
public partial class AddOrderNotes : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "OrderNotes",
columns: table => new
{
Id = table.Column<int>(nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
OrderId = table.Column<int>(nullable: false),
Content = table.Column<string>(maxLength: 2000, nullable: false),
CreatedAt = table.Column<DateTime>(nullable: false),
CreatedBy = table.Column<string>(maxLength: 100, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_OrderNotes", x => x.Id);
table.ForeignKey(
name: "FK_OrderNotes_Orders_OrderId",
column: x => x.OrderId,
principalTable: "Orders",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "OrderNotes");
}
} Key insight: Deploy this migration to production immediately, even though no code uses it yet. The table sits empty until the feature is enabled. This decouples database changes from code deployment.
Step 2: Add the Service Layer
Create the new service, but guard the entry points with feature flags.
public class OrderNotesService : IOrderNotesService
{
private readonly AppDbContext _db;
private readonly IFlagitClient _flagit;
public OrderNotesService(AppDbContext db, IFlagitClient flagit)
{
_db = db;
_flagit = flagit;
}
public async Task<List<OrderNote>> GetNotesAsync(int orderId, ClaimsPrincipal user)
{
// Feature flag check at service boundary
if (!await _flagit.IsEnabledAsync("order-notes", new { userId = user.GetUserId() }))
{
return new List<OrderNote>();
}
return await _db.OrderNotes
.Where(n => n.OrderId == orderId)
.OrderByDescending(n => n.CreatedAt)
.ToListAsync();
}
public async Task<OrderNote?> AddNoteAsync(int orderId, string content, ClaimsPrincipal user)
{
if (!await _flagit.IsEnabledAsync("order-notes", new { userId = user.GetUserId() }))
{
return null; // Or throw, depending on your error handling
}
var note = new OrderNote
{
OrderId = orderId,
Content = content,
CreatedAt = DateTime.UtcNow,
CreatedBy = user.GetUserId()
};
_db.OrderNotes.Add(note);
await _db.SaveChangesAsync();
return note;
}
} Step 3: Add API Endpoints
[ApiController]
[Route("api/orders/{orderId}/notes")]
public class OrderNotesController : ControllerBase
{
private readonly IOrderNotesService _notesService;
public OrderNotesController(IOrderNotesService notesService)
{
_notesService = notesService;
}
[HttpGet]
public async Task<IActionResult> GetNotes(int orderId)
{
var notes = await _notesService.GetNotesAsync(orderId, User);
return Ok(notes);
}
[HttpPost]
public async Task<IActionResult> AddNote(int orderId, [FromBody] AddNoteRequest request)
{
var note = await _notesService.AddNoteAsync(orderId, request.Content, User);
if (note == null)
{
return NotFound(); // Feature not available for this user
}
return CreatedAtAction(nameof(GetNotes), new { orderId }, note);
}
} Why this pattern works
- Database is ready before code ships
- API endpoints exist but return empty/null when disabled
- Flag check is at service boundary - controller stays clean
- When flag is removed, delete the check and the empty-return logic
Common Mistakes
Checking the flag in multiple places
Bad: Flag check in controller, service, and repository.
Good: Check once at the boundary, pass a boolean or use strategy pattern.
Forgetting to handle the disabled case
Bad: New API endpoint that throws 500 when flag is off.
Good: Return 404, empty collection, or gracefully degrade.
Database migrations tied to feature flags
Bad: Running migrations only when flag is enabled.
Good: Migrations run unconditionally. Schema changes are permanent; flags control code paths.
Summary
- UI changes: Component-level toggle, pass boolean down
- New features with DB: Deploy schema first, guard service layer
- Always: Check flag once at the boundary, handle disabled state gracefully
Need to modify existing behavior or add columns to existing tables? Continue to Part 2: Advanced Patterns.