Feature Flags en .NET: Controla tu Código Sin Hacer Deploy
Las feature flags son un magnífico feature 🙌 aprénde como hacerlas bien

Hey dev, ¿alguna vez necesitaste hacer un deploy solo para activar una funcionalidad? ¿O peor, tuviste que hacer rollback porque algo falló en producción? Los Feature flags, o también conocidos como feature toggles, existen para resolver exactamente esto. Vamos por partes y cucharadas.

¿Qué es un Feature Flag?

Un Feature Flag (o Feature toggle) es una variable que controla si una funcionalidad está activa o no, sin necesidad de cambiar código ni hacer deploy. Es como un interruptor que puedes encender o apagar en cualquier momento.

Escenario Real: TechStore E-commerce

Imagina que trabajas en TechStore, un e-commerce de tecnología. El equipo está desarrollando una nueva vista de productos premium (recién adquiridos), pero aún no está lista para todos los usuarios. ¿Qué haces?

Sin Feature Flags: esperas a que esté perfecta y pierdes tiempo. Con Feature Flags: Despliegas hoy, activas mañana cuando esté lista.

Otro ejemplo: Amazon

Amazon usa feature flags para activar ofertas especiales solo para ciertos usuarios. Tu permiso de "usuario prime" activa features que otros no ven. Todo sin deploys adicionales.

Tipos de Feature Flags

Release Flags: Permiten desplegar código que aún no está listo para todos. Despliegas hoy, activas mañana.

Experiment Flags: Para pruebas A/B. Activas una funcionalidad para un porcentaje de usuarios.

Ops Flags: Para controlar características que impactan el rendimiento o la infraestructura.

Permission Flags: Para activar funcionalidades según el plan del usuario (freemium vs premium).

Esta info la he recopilado en base a mi experiencia y research, casi nadie te lo explica, así que enhorabuena que tú lo estás aprendiendo! ¡Valóralo, eh! 🤣🐿️💪

Implementación en .NET

Bueno, bueno, ahora estarás diciendo vamos a la práctica, y sí es el estilo de este blog, aquí que vamos!

Esta es la estructura del proyecto

Primero, instala los siguientes paquetes:

  • Microsoft.FeatureManagement
  • Microsoft.FeatureManagement.AspNetCore
  • Swashbuckle.AspNetCore

Las features, según las buenas prácticas, debes tenerlas como constantes, así que pon esa clase en Configuration/FeatureFlags.cs

namespace FeatureFlagsDemo.Configuration;

public static class FeatureFlags
{
    public const string NewProductView = "NewProductView";
    public const string EnhancedSearch = "EnhancedSearch";
    public const string CreateProduct = "CreateProduct";
}

Ahora vamos a definir una entidad, esto iría en Domain/Product.cs

namespace FeatureFlagsDemo.Domain;

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Category { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public bool IsPremium { get; set; }
}

Como la finalidad aquí es aprender feature flags no voy a ahondar en detalles de implementación como base de datos ni EF, así que crearé una clase para almacenar datos y manejarlos en memoria, esto va en Data/InMemoryData.cs

using FeatureFlagsDemo.Domain;

namespace FeatureFlagsDemo.Data;

public static class InMemoryData
{
    public static List<Product> StandardProducts { get; } = new()
    {
        new Product { Id = 1, Name = "Laptop", Category = "Electronics", Price = 999.99m, IsPremium = false },
        new Product { Id = 2, Name = "Mouse", Category = "Accessories", Price = 29.99m, IsPremium = false },
        new Product { Id = 3, Name = "Keyboard", Category = "Accessories", Price = 79.99m, IsPremium = false }
    };

    public static List<Product> PremiumProducts { get; } = new()
    {
        new Product { Id = 1, Name = "Laptop Pro", Category = "Electronics", Price = 1499.99m, IsPremium = true },
        new Product { Id = 2, Name = "Mouse Elite", Category = "Accessories", Price = 89.99m, IsPremium = true },
        new Product { Id = 3, Name = "Keyboard Mechanical", Category = "Accessories", Price = 149.99m, IsPremium = true }
    };

    public static List<Product> SearchResults { get; } = new()
    {
        new Product { Id = 101, Name = "Gaming Laptop X1", Category = "Gaming", Price = 1999.99m, IsPremium = true },
        new Product { Id = 102, Name = "Wireless Mouse Pro", Category = "Accessories", Price = 59.99m, IsPremium = true },
        new Product { Id = 103, Name = "RGB Keyboard", Category = "Gaming", Price = 129.99m, IsPremium = true }
    };
}

Ahora Crearemos los DTO y servicios:

Application/DTOs/ProductDtos.cs

namespace FeatureFlagsDemo.Application.DTOs;

public record ProductDto(int Id, string Name, string Category, decimal Price, bool IsPremium);
public record ProductsResponse(string Source, IEnumerable<ProductDto> Products);
public record SearchResponse(string Query, IEnumerable<ProductDto> Results);
public record CreateProductRequestDto(string Name, string Category, decimal Price);
public record CreateProductResponse(int Id, string Name, string Category, decimal Price, bool Created);

Application/Services/IProductService.cs

using FeatureFlagsDemo.Application.DTOs;

namespace FeatureFlagsDemo.Application.Services;

public interface IProductService
{
    Task<ProductsResponse> GetAllProductsAsync();
    Task<SearchResponse> SearchAsync(string query);
    Task<CreateProductResponse> CreateProductAsync(CreateProductRequestDto request);
}

Application/Services/ProductService.cs

using Microsoft.FeatureManagement;
using FeatureFlagsDemo.Application.DTOs;
using FeatureFlagsDemo.Configuration;
using FeatureFlagsDemo.Data;
using FeatureFlagsDemo.Domain;

namespace FeatureFlagsDemo.Application.Services;

public class ProductService(IFeatureManager featureManager, ILogger<ProductService> logger) : IProductService
{
    private readonly List<Product> _products = new();
    private int _nextId = 100;

    public async Task<ProductsResponse> GetAllProductsAsync()
    {
        var usePremiumView = await featureManager.IsEnabledAsync(FeatureFlags.NewProductView);
        logger.LogInformation("Feature {Feature} is {Status}", FeatureFlags.NewProductView, usePremiumView ? "ON" : "OFF");

        var products = usePremiumView ? InMemoryData.PremiumProducts : InMemoryData.StandardProducts;
        var productDtos = products.Select(p => new ProductDto(p.Id, p.Name, p.Category, p.Price, p.IsPremium));

        return new ProductsResponse(usePremiumView ? "premium" : "standard", productDtos);
    }

    public async Task<SearchResponse> SearchAsync(string query)
    {
        logger.LogInformation("Searching for: {Query}", query);

        var results = InMemoryData.SearchResults
            .Where(p => p.Name.Contains(query, StringComparison.OrdinalIgnoreCase) ||
                        p.Category.Contains(query, StringComparison.OrdinalIgnoreCase))
            .Select(p => new ProductDto(p.Id, p.Name, p.Category, p.Price, p.IsPremium));

        return new SearchResponse(query, results);
    }

    public async Task<CreateProductResponse> CreateProductAsync(CreateProductRequestDto request)
    {
        var product = new Product
        {
            Id = _nextId++,
            Name = request.Name,
            Category = request.Category,
            Price = request.Price,
            IsPremium = true
        };

        _products.Add(product);
        logger.LogInformation("Product created: {ProductName} with Id {ProductId}", product.Name, product.Id);

        return new CreateProductResponse(product.Id, product.Name, product.Category, product.Price, true);
    }
}

Ahora vamos al controller, créalo en Controllers/ProductsController.cs

using Microsoft.AspNetCore.Mvc;
using Microsoft.FeatureManagement.Mvc;
using FeatureFlagsDemo.Application.DTOs;
using FeatureFlagsDemo.Application.Services;
using FeatureFlagsDemo.Configuration;

namespace FeatureFlagsDemo.Controllers;

[ApiController]
[Route("api/[controller]")]
public class ProductsController(IProductService productService) : ControllerBase
{
    [HttpGet]
    public async Task<IActionResult> GetProducts()
    {
        var response = await productService.GetAllProductsAsync();
        return Ok(response);
    }

    [HttpGet("search")]
    [FeatureGate(FeatureFlags.EnhancedSearch)]
    public async Task<IActionResult> Search([FromQuery] string searchTerm)
    {
        var response = await productService.SearchAsync(searchTerm);
        return Ok(response);
    }

    [HttpPost]
    [FeatureGate(FeatureFlags.CreateProduct)]
    public async Task<IActionResult> CreateProduct([FromBody] CreateProductRequestDto request)
    {
        var response = await productService.CreateProductAsync(request);
        return CreatedAtAction(nameof(GetProducts), new { id = response.Id }, response);
    }
}

Tu Program.cs luciría así:

using Microsoft.FeatureManagement;
using FeatureFlagsDemo.Application.Services;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddFeatureManagement();
builder.Services.AddScoped<IProductService, ProductService>();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.MapControllers();

app.Run();

En appsettings.json o appsettings.development.json tendrías esto:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "FeatureManagement": {
    "NewProductView": true,
    "EnhancedSearch": false,
    "CreateProduct": false
  }
}

Y tu launchSettings.json luce así (cambia el puerto si está ocupado):

{
  "$schema": "https://json.schemastore.org/launchsettings.json",
  "profiles": {
    "https": {
      "commandName": "Project",
      "dotnetRunMessages": true,
      "launchBrowser": true,
      "launchUrl": "https://localhost:5113/swagger",
      "applicationUrl": "https://localhost:5113",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    }
  }
}

Allí ya puedes correr el proyecto y verás Swagger:

Ejemplo real: TechStore E-commerce

Imagina que eres el developer backend de TechStore y necesitas:

Escenario 1: Nueva vista de productos

El equipo de diseño creó una nueva vista premium con productos mejores. Pero no está lista para todos.

Configuración inicial:

En tu appsettings actualizarías:

"FeatureManagement": {
    "NewProductView": false
}

Resultado para usuarios:

Cuando el CEO dice "lancemos la nueva vista":

Cambias el valor el appsettings:

"FeatureManagement": {
    "NewProductView": true
}

El resultado es este:

Sin restart, sin deploy. Solo cambias un valor y listo, crack!

Escenario 2: Búsqueda avanzada

El equipo de search está probando un nuevo algoritmo. Solo lo activas para usuarios beta.

Activas para usuarios específicos:

"FeatureManagement": {
    "EnhancedSearch": true
}

Si seteas:

"FeatureManagement": {
    "EnhancedSearch": false
}

Gracias al tag [FeatureGate(FeatureFlags.EnhancedSearch)] que usaste en el controlador para ese endpoint no tienes acceso a él

Escenario 3: Nuevo formulario de creación

El equipo de productos quiere probar un nuevo formulario de registro de productos.

Seteas:

"FeatureManagement": {
    "CreateProduct": false
}

Y obtendrías nuevamente un 404.

Mientras tanto, la funcionalidad se desarrolla y cuando esté lista activa ese feature flag, y ahora sí podrías insertar un nuevo producto.

El proyecto está disponible aquí, no te olvides de seguirme el Github y darle estrella! 🐿️

Mejores prácticas aplicadas

Aquí te resumo, crack, todas las best practices aplicadas, ya que, como siempre te digo, la filosofía de este blog es pragmática y de calidad de producción:

Feature Flags como constantes: Evito strings hardcoded. Usé FeatureFlags.NewProductView en vez de "NewProductView".

[FeatureGate] attribute: El controller usa [FeatureGate] para controlar acceso. No hay lógica de feature flags en el controller, solo decoración.

Clean Architecture en carpetas: Domain para entidades, Application para servicios y DTOs, Controllers para presentación, no usé infrastructure para evitar añadir complejidad innecesario a esta demo.

DTOs separados: Los modelos (Product) no se exponen. Se mapean a DTOs.

Logging en el Service: El controller no tiene ILogger. El ProductService maneja todo el logging.

Async: Todos los métodos del service son async Task.

Primary constructors: Usamos los constructores primarios/principales, una característica de C# 12, aquí hablo sobre esto 😉.

¿Por qué [FeatureGate] es mejor que checks manuales? El atributo [FeatureGate] internamente lanza un FeatureNotFoundException cuando la feature está deshabilitada, manejado por el middleware para retornar 404 automáticamente. El controller solo sabe "este endpoint existe y hace X", no "este endpoint verifica si la feature está activa".

Consejos Prácticos y conclusiones

Usa nombres descriptivosNewProductView es mejor que Feature1, Aunque podrías hacerlo más descriptivo aún.

No abuses: Un flag o toggle por cada feature es manejable. Cientos se vuelven imposibles de mantener.

Elimina flags obsoletos: Una vez que la feature está activa para todos, elimínala del código y de la configuración.

Documenta cada feature: En un spreadsheet o wiki, anota para qué sirve cada feature flag.

Como has podido ver, los Feature Flags te dan control granular sobre tu aplicación sin tocar código. Despliega cuando quieras, activa cuando necesites.

Ahora ya puedes hacer deploy de la vista premium el viernes y activarla el lunes cuando el equipo de marketing esté listo.

Es una herramienta simple pero poderosa que todo developer .NET debería tener en su arsenal y que aquí aprendiste!

Así que ya lo sabes crack, aplica esto en tu próximo proyecto y me cuentas cómo te fue. Ah y no te olvides de lo más importante... comparte este artículo! 🐿️❤️

Créditos de imagen de portada: Foto de Savelie Antipov en Unsplash

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *