Что такое feature toggles
Feature toggles (фича-флаг) — это механизм, позволяющий включать или выключать определенную функциональность без изменения кода и повторного деплоя. Применяется почти везде: от обычных приложений до целых подходов к разработке.
В веб-приложениях фича-флаги обычно решают бизнес-задачи: показать новую кнопку только 10% пользователей, включить экспериментальный функционал для бета-тестеров, выключить сломавшуюся функцию без отката всего релиза. Тут у нас есть доступ и к файлам конфигурации, и к базе данных, и к контексту пользователей, поэтому реализация может быть самая разная.
А в разработке существует подход, называемый Trunk Based Development, при котором все разработчики работают в одной основной ветке. Здесь фича-флаги — необходимость.
Представьте: вы работаете над большой фичей, которая займет две недели. При классическом подходе с feature-ветками вы бы создали отдельную ветку и мержили ее при завершении задачи. Но что, если за это время основная ветка уйдет далеко вперед? Merge hell обеспечен.
С Trunk Based Development вы мержите код каждый день, но скрываете незаконченную функциональность за флагом. Код уже в продакшене, но пользователи его не видят. Когда фича готова — просто включаете флаг. Никаких сложных мержей, конфликтов и долгих релизов.
Подходы к фича-флагам в обычных приложениях
Прежде чем погружаться в библиотеки, проведем небольшой обзор того, как это делается в приложениях.
Своя реализация с хранением в БД
Многие команды пишут свою систему флагов с хранением состояния в базе данных. Это удобно, когда нужна гибкость и привязка к доменным сущностям: конкретным пользователям, ролям, тенантам или чему-то специфичному для вашего бизнеса.
Microsoft.FeatureManagement
Для ASP.NET Core приложений Microsoft предлагает готовую библиотеку Microsoft.FeatureManagement.AspNetCore. Она интегрируется с системой конфигурации, поддерживает фильтры (по времени, проценту пользователей, таргетингу) и даже предоставляет атрибут [FeatureGate] для контроллеров.
// Регистрация в Startup
services.AddFeatureManagement();
// Использование в контроллере
[FeatureGate("BetaFeature")]
public IActionResult BetaEndpoint() => Ok("Welcome to beta!");
// Или программно
if (await _featureManager.IsEnabledAsync("BetaFeature"))
{
// Делаем что-то экспериментальное
}
Конфигурация задается в appsettings.json:
{
"FeatureManagement": {
"BetaFeature": true,
"GradualRollout": {
"EnabledFor": [
{
"Name": "Percentage",
"Parameters": { "Value": 50 }
}
]
}
}
}
Это отличное решение для приложений, но для библиотек оно не подходит: слишком много зависимостей и предположений об инфраструктуре.
Подходы в библиотеках и фреймворках
Когда вы разрабатываете библиотеку, у вас совсем другие ограничения:
- Хочется сделать что-то максимально простое и без лишних зависимостей.
- Зависимость от DI в некоторых случаях допустима, а вот зависимость от конфигурации, JSON-файлов и БД — уже перебор.
- Нужно думать об обратной совместимости и версионировании.
Options и булевые флаги
Самый простой и очевидный подход — создать класс с настройками и булевыми свойствами. Именно так, например, делает System.Text.Json:
var options = new JsonSerializerOptions
{
AllowOutOfOrderMetadataProperties = true, // появилось в .NET 9
RespectNullableAnnotations = true,
RespectRequiredConstructorParameters = true
};
JsonSerializer.Deserialize<MyType>(json, options);
Каждый флаг — это просто свойство в классе опций. Потребитель библиотеки создает экземпляр, настраивает нужные флаги и передает в методы.
Плюсы:
- Простота и очевидность — никакой магии.
- Нет зависимостей.
- Хорошо работает с IntelliSense.
- Легко документировать.
Минусы:
- Флагов со временем может стать очень много.
- Нужен строгий контроль над версиями — старые флаги надо удалять, но это ломает обратную совместимость.
- Все флаги всегда видны.
Параллельный API
Другой подход — не менять поведение существующих методов, а добавлять новые. Старые методы помечаются как [Obsolete], а новые предлагают улучшенное поведение.
// Старый метод
[Obsolete("Use ProcessDataAsync instead")]
public void ProcessData(Data data) { /* старая реализация */ }
// Новый метод с улучшенным поведением
public Task ProcessDataAsync(Data data, CancellationToken ct = default)
{
/* новая реализация */
}
Это не совсем фича-флаг в классическом понимании, но решает похожую задачу — позволяет постепенно мигрировать на новое поведение.
Плюсы:
- Полная обратная совместимость.
- Четкий путь миграции.
- Компилятор подсказывает, что пора обновиться.
Минусы:
- API разрастается.
- Не подходит для изменений в глубине реализации.
AppContext.SetSwitch
.NET предоставляет глобальный механизм переключателей через AppContext.SetSwitch, и некоторые библиотеки его активно используют.
Делал я как-то пет-проект, и там был функционал оповещений — нужно было хранить время в PostgreSQL. Использовал Npgsql через EF Core, и всё работало… пока не обновил Npgsql до версии 6.0.
После обновления посыпались ошибки:
Cannot write DateTime with Kind=Unspecified to PostgreSQL type 'timestamp with time zone', only UTC is supported.
Оказалось, в Npgsql 6.0 кардинально изменили логику работы с временными типами. Раньше библиотека делала неявные конверсии часовых поясов, теперь требует явного указания UTC. Изменение правильное, с ним поведение более предсказуемое, но оно ломает существующий код.
И вот тут на помощь приходит AppContext.SetSwitch:
// Добавляем в самом начале приложения, до любых операций с Npgsql
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
Одна строчка — и библиотека работает по-старому. Это дало мне время спокойно мигрировать код на новое поведение.
Внутри библиотеки проверка выглядит примерно так:
private static bool EnableLegacyTimestampBehavior
{
get
{
if (AppContext.TryGetSwitch("Npgsql.EnableLegacyTimestampBehavior", out var enabled))
return enabled;
return false; // по умолчанию — новое поведение
}
}
// Где-то в коде обработки timestamp
if (EnableLegacyTimestampBehavior)
{
// Старая логика с неявными конверсиями
}
else
{
// Новая строгая логика
}
Переключатель можно задать не только в коде, но и через конфигурацию в .csproj:
<ItemGroup>
<RuntimeHostConfigurationOption
Include="Npgsql.EnableLegacyTimestampBehavior"
Value="true" />
</ItemGroup>
Плюсы:
- Нет зависимостей — AppContext есть в .NET из коробки.
- Глобальный переключатель — не надо пробрасывать настройки через весь код.
- Можно задать через конфигурацию или переменные окружения.
- Хорошо подходит для breaking changes при обновлении мажорной версии.
Минусы:
- Глобальность — это и плюс, и минус. Нельзя использовать разное поведение в разных частях приложения.
- Неочевидность — нужно знать, что такой переключатель существует.
- Magic strings — опечатка в названии переключателя не вызовет ошибку компиляции.
- IDE не подскажет, какие переключатели доступны.
Feature Interface (коллекция фич)
Это мой любимый подход для сложных случаев. Идея в том, чтобы определить класс или интерфейс для каждой фичи и собрать их в коллекцию.
ASP.NET Core использует похожий паттерн — IFeatureCollection в HttpContext. Только там он нужен для описания возможностей среды выполнения (поддерживает ли сервер WebSockets, HTTP/2 и т. д.), но сама идея отлично подходит и для фича-флагов.
// Базовый интерфейс для всех фич
public interface IFeature
{
string Name { get; }
}
// Конкретные фичи — просто классы, реализующие IFeature
public class UseImmutableModels : IFeature
{
public string Name => nameof(UseImmutableModels);
}
public class StrictValidation : IFeature
{
public string Name => nameof(StrictValidation);
// Фича может содержать дополнительные настройки
public bool ThrowOnFirstError { get; init; } = false;
}
Коллекция фич хранит их по типу:
public class FeatureCollection
{
private readonly Dictionary<Type, IFeature> _features = new();
public void AddFeature<TFeature>(TFeature feature) where TFeature : IFeature
{
_features[typeof(TFeature)] = feature;
}
public bool ContainsFeature<TFeature>() where TFeature : IFeature
{
return _features.ContainsKey(typeof(TFeature));
}
public bool TryGetFeature<TFeature>(out TFeature? feature) where TFeature : class, IFeature
{
if (_features.TryGetValue(typeof(TFeature), out var stored))
{
feature = (TFeature)stored;
return true;
}
feature = default;
return false;
}
}
Для удобства можно добавить методы расширения, чтобы клиенту библиотеки не нужно было думать о том, какие классы фич вообще существуют:
public static class FeatureCollectionExtensions
{
public static FeatureCollection WithStrictValidation(
this FeatureCollection features,
bool throwOnFirstError = false)
{
features.AddFeature(new StrictValidation { ThrowOnFirstError = throwOnFirstError });
return features;
}
}
Теперь использование становится максимально простым и читаемым:
services.AddMyLibrary(options =>
{
options.Features
.WithStrictValidation(throwOnFirstError: true);
});
А если необходимо использовать фичи не глобально, а только при вызове конкретного метода?
public class MyService
{
private readonly FeatureCollection _globalFeatures;
public MyService(IOptions<MyLibraryOptions> options)
{
_globalFeatures = options.Value.Features;
}
public void DoWork(FeatureCollection? overrides = null)
{
// Сначала проверяем переопределения, потом глобальные настройки
var features = overrides ?? _globalFeatures;
if (features.ContainsFeature<UseImmutableModels>())
{
// Используем immutable-модели
}
if (features.TryGetFeature<StrictValidation>(out var validation))
{
// Строгая валидация с учетом настроек
if (validation.ThrowOnFirstError)
{
// ...
}
}
}
}
// Вызов с переопределением
var localFeatures = new FeatureCollection()
.WithStrictValidation(throwOnFirstError: false); // переопределяем глобальную настройку
service.DoWork(localFeatures);
Плюсы:
- Очень гибко — фичи могут содержать не только флаги, но и настройки.
- Строгая типизация — компилятор проверит правильность использования.
- Можно переопределять на разных уровнях: глобально и для конкретного вызова.
- Чистый API благодаря extension-методам — пользователь просто вызывает WithSomeFeature().
- Нет ограничения на количество — добавление новой фичи не меняет существующий API.
- Хорошо интегрируется с DI, но не требует его.
Минусы:
- Сложнее в реализации, чем простые флаги.
- Может быть избыточным для простых случаев.
Рекомендации при разработке библиотек
- Для простых случаев используйте класс опций с флагами. Если у вас 2–3 настройки и они вряд ли будут сильно расти — не усложняйте.
- Для breaking changes используйте AppContext.SetSwitch. Это позволит пользователям плавно мигрировать на новое поведение. Назовите переключатель понятно, включите версию MyLibrary.V2.EnableStrictMode. Используйте константы, чтобы был единый список фич.
- Для сложных сценариев используйте Feature Collection. Особенно если вам нужно переопределение на разных уровнях или фичи содержат больше, чем просто bool.
- Документируйте всё. Какие флаги есть, что они делают, какое поведение по умолчанию, когда стоит включать/выключать. Помните мой пример с Npgsql: я нашел решение только потому, что оно было в сообщении об ошибке.
- Планируйте жизненный цикл флагов. Флаг не должен жить вечно. Определите, когда вы удалите legacy-поведение, и сообщите об этом пользователям через [Obsolete] или release notes.
- Минимизируйте зависимости. Если можно обойтись без внешних пакетов — обойдитесь. Ваши пользователи скажут спасибо.
Благодарю за прочтение статьи! Кроме того, я веду телеграм-канал Flexible Coding, где делюсь своими инженерными инсайтами. Подписывайтесь, буду рад!