
Feature Flags
- 3 installs
- 591 repo stars
- Updated July 24, 2026
- rshankras/claude-code-apple-skills
Generates feature flag infrastructure with typed flags, local/remote/composite providers, SwiftUI integration, and a runtime debug toggle menu.
About
Generates a feature flag system with typed definitions, local and remote providers, an @Observable manager, and SwiftUI environment integration. A developer uses it to add remote toggles, gradual rollouts, A/B testing, or kill switches to an app.
- Local, remote, and composite providers with a debug toggle menu
- SwiftUI environment integration and @Observable manager
Feature Flags by the numbers
- 3 all-time installs (skills.sh)
- +1 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #887 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/rshankras/claude-code-apple-skills --skill feature-flagsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 591 |
| Last updated | July 24, 2026 |
| Repository | rshankras/claude-code-apple-skills ↗ |
What it does
Generates feature flag infrastructure with typed flags, local/remote/composite providers, SwiftUI integration, and a runtime debug toggle menu.
Files
Feature Flags Generator
Generate a complete feature flag infrastructure with typed flag definitions, protocol-based providers (local, remote, composite), SwiftUI environment integration, an @Observable manager, and a debug menu for toggling flags at runtime.
When This Skill Activates
Use this skill when the user:
- Asks to "add feature flags" or "add feature toggles"
- Mentions A/B testing or gradual rollouts
- Asks about Firebase Remote Config or similar remote configuration
- Wants to disable features without shipping an app update
- Mentions "kill switches" or "feature gates"
- Wants to control features remotely for a subset of users
- Asks for a debug menu to toggle features during development
Pre-Generation Checks
1. Project Context Detection
- [ ] Check for existing feature flag implementations
- [ ] Check for Firebase Remote Config or third-party flag SDKs
- [ ] Identify source file locations (Sources/, App/, or root)
- [ ] Verify minimum deployment target (iOS 17+ / macOS 14+ for @Observable)
2. Conflict Detection
Search for existing feature flag code:
Glob: **/*FeatureFlag*.swift, **/*FeatureToggle*.swift, **/*RemoteConfig*.swift
Grep: "FeatureFlag" or "FeatureToggle" or "RemoteConfig" or "isFeatureEnabled"If existing feature flag code is found:
- Ask whether to replace or extend the existing implementation
- Check for flag names or enum cases that could conflict
If a third-party SDK (Firebase, LaunchDarkly, etc.) is detected:
- Ask if the user wants a standalone implementation or a wrapper around the SDK
3. Required Capabilities
Feature flags require:
- iOS 17+ / macOS 14+ deployment target (for @Observable manager)
- Network access entitlement if using remote flags
- No special Info.plist entries needed
Configuration Questions
Ask user via AskUserQuestion:
1. What features do you want to flag? (freeform)
- Examples: new onboarding, premium paywall, experimental UI, dark mode v2
- This determines the flag enum cases and their default values
2. What flag value types do you need?
- Boolean only (feature on/off)
- Boolean + String (on/off plus string configuration)
- Boolean + String + Integer (full typed support)
- Boolean + String + Integer + JSON (for complex configurations)
3. What provider architecture?
- Local only -- UserDefaults-based with compile-time defaults
- Remote only -- JSON endpoint with local caching
- Composite (recommended) -- Local defaults with remote override; remote wins when available
4. Include debug menu?
- Yes -- SwiftUI view for toggling flags at runtime (DEBUG builds only)
- No -- Skip the debug view
5. Include SwiftUI environment integration?
- Yes (recommended) -- Inject the flag manager via SwiftUI Environment
- No -- Use the manager directly
Generation Process
Step 1: Determine File Locations
Check project structure:
- If
Sources/exists -->Sources/FeatureFlags/ - If
App/exists -->App/FeatureFlags/ - Otherwise -->
FeatureFlags/
Step 2: Create Core Files
Generate these files based on configuration answers:
1. `FeatureFlag.swift` -- Flag enum with typed default values 2. `FeatureFlagService.swift` -- Protocol defining provider interface 3. `LocalFeatureFlagProvider.swift` -- UserDefaults-based provider with debug overrides 4. `RemoteFeatureFlagProvider.swift` -- URL-based provider with disk caching (if remote or composite) 5. `CompositeFeatureFlagProvider.swift` -- Combines local + remote; remote overrides local (if composite) 6. `FeatureFlagManager.swift` -- @Observable manager for SwiftUI 7. `FeatureFlagEnvironmentKey.swift` -- SwiftUI Environment integration (if requested) 8. `FeatureFlagDebugView.swift` -- Debug toggle view (if requested)
Step 3: Generate Code from Templates
Use the templates in templates.md and customize based on user answers:
- Replace placeholder flag cases with real feature names
- Set appropriate default values per flag
- Include or exclude remote/composite providers based on architecture choice
- Include or exclude typed value methods (string, int, JSON) based on type selection
- Include or exclude environment key and debug view
Output Format
After generation, provide:
Files Created
Sources/FeatureFlags/
├── FeatureFlag.swift # Flag enum with typed defaults
├── FeatureFlagService.swift # Provider protocol
├── LocalFeatureFlagProvider.swift # UserDefaults-based provider
├── RemoteFeatureFlagProvider.swift # URL-based provider (if remote/composite)
├── CompositeFeatureFlagProvider.swift # Local + remote combiner (if composite)
├── FeatureFlagManager.swift # @Observable manager for SwiftUI
├── FeatureFlagEnvironmentKey.swift # SwiftUI Environment key (if requested)
└── FeatureFlagDebugView.swift # Debug toggle menu (if requested)Integration Steps
1. Initialize the manager in your App struct or entry point:
import SwiftUI
@main
struct MyApp: App {
@State private var featureFlagManager: FeatureFlagManager
init() {
// Local only
let provider = LocalFeatureFlagProvider()
// Or composite (remote overrides local)
// let provider = CompositeFeatureFlagProvider(
// local: LocalFeatureFlagProvider(),
// remote: RemoteFeatureFlagProvider(
// endpoint: URL(string: "https://api.example.com/flags")!
// )
// )
_featureFlagManager = State(initialValue: FeatureFlagManager(provider: provider))
}
var body: some Scene {
WindowGroup {
ContentView()
.environment(featureFlagManager)
}
}
}2. Use flags in your views:
struct ContentView: View {
@Environment(FeatureFlagManager.self) private var flags
var body: some View {
VStack {
if flags.isEnabled(.newOnboarding) {
NewOnboardingView()
} else {
LegacyOnboardingView()
}
}
}
}3. Refresh remote flags (if using remote or composite):
// Refresh on app launch or periodically
Task {
try await featureFlagManager.refresh()
}4. Add debug menu (if generated, DEBUG builds only):
#if DEBUG
NavigationLink("Feature Flags") {
FeatureFlagDebugView()
.environment(featureFlagManager)
}
#endifTesting Instructions
1. Unit test providers independently: Each provider conforms to FeatureFlagService and can be tested in isolation. 2. Mock provider for previews and tests:
final class MockFeatureFlagProvider: FeatureFlagService {
var overrides: [FeatureFlag: Bool] = [:]
func isEnabled(_ flag: FeatureFlag) -> Bool {
overrides[flag] ?? flag.defaultValue
}
// ... implement remaining protocol methods
}3. Debug menu: Run in DEBUG builds, navigate to the debug menu, and toggle flags to verify behavior. 4. Remote provider: Use a local JSON file served via a test server or mock URLProtocol to test remote fetching.
Common Patterns
Boolean Flags (Kill Switches)
The most common pattern. Enable or disable a feature entirely.
if flags.isEnabled(.premiumPaywall) {
PremiumPaywallView()
}String Flags (Copy Variants / A/B Testing)
Use string values to serve different text or configuration strings remotely.
let welcomeMessage = flags.stringValue(.welcomeMessage) ?? "Welcome!"
Text(welcomeMessage)Integer Flags (Thresholds / Limits)
Control numeric parameters like retry counts, page sizes, or rate limits.
let maxRetries = flags.intValue(.maxRetries) ?? 3JSON Flags (Complex Configuration)
For structured configuration that changes server-side.
struct PaywallConfig: Codable {
let title: String
let trialDays: Int
let showTestimonials: Bool
}
if let config: PaywallConfig = flags.jsonValue(.paywallConfig) {
PaywallView(config: config)
}Gradual Rollout
Combine feature flags with user segmentation.
// Server returns different flag values per user segment
// The flag is simply on/off from the client perspective
if flags.isEnabled(.newCheckoutFlow) {
NewCheckoutView()
} else {
LegacyCheckoutView()
}Gotchas
- Stale flags: Always provide sensible local defaults. If the remote fetch fails, the app must still function correctly with local values.
- Flag cleanup: After a feature is fully rolled out, remove the flag enum case, delete related conditional code, and clean up remote configuration. Stale flags accumulate technical debt.
- Thread safety: The generated
FeatureFlagManageris@MainActor-isolated. Access it on the main thread or via@Environmentin SwiftUI views. The providers useSendable-conforming storage. - Testing both paths: When a flag controls a UI branch, write tests (or at least manual test plans) for both the enabled and disabled paths. It is easy to forget the disabled path once a flag has been on for weeks.
- Debug overrides in production: The debug override mechanism uses
#if DEBUGguards. Double-check that debug toggles never leak into release builds. - Cache invalidation: The remote provider caches to disk. Set an appropriate
cacheDuration(default 5 minutes). For time-sensitive flags, callrefresh()explicitly. - UserDefaults key collisions: All flag keys are prefixed with
ff_to avoid collisions with other UserDefaults entries in the app.
References
- templates.md -- Production-ready Swift templates for all generated files
- Feature Toggles (Martin Fowler)
- Firebase Remote Config
Feature Flag Code Templates
Production-ready Swift templates for feature flag infrastructure. All code targets iOS 17+ / macOS 14+ and uses @Observable, modern Swift concurrency, and protocol-based architecture.
FeatureFlag.swift
The flag enum defines every feature flag in the app with typed default values.
import Foundation
/// Defines all feature flags available in the app.
///
/// Each case represents a single feature flag with a remote key and a local default value.
/// Add new flags here and provide defaults so the app functions correctly without a network connection.
enum FeatureFlag: String, CaseIterable, Sendable {
case newOnboarding = "new_onboarding"
case darkModeV2 = "dark_mode_v2"
case premiumPaywall = "premium_paywall"
case experimentalUI = "experimental_ui"
// MARK: - Default Values
/// The default boolean value used when no remote or override value is available.
var defaultValue: Bool {
switch self {
case .newOnboarding: return false
case .darkModeV2: return true
case .premiumPaywall: return false
case .experimentalUI: return false
}
}
/// The default string value, if applicable. Returns nil for flags that are boolean-only.
var defaultStringValue: String? {
switch self {
default: return nil
}
}
/// The default integer value, if applicable. Returns nil for flags that are boolean-only.
var defaultIntValue: Int? {
switch self {
default: return nil
}
}
// MARK: - Display
/// A human-readable label for display in debug menus.
var displayName: String {
switch self {
case .newOnboarding: return "New Onboarding"
case .darkModeV2: return "Dark Mode V2"
case .premiumPaywall: return "Premium Paywall"
case .experimentalUI: return "Experimental UI"
}
}
/// A description for the debug menu explaining what the flag controls.
var flagDescription: String {
switch self {
case .newOnboarding: return "Enables the redesigned onboarding flow"
case .darkModeV2: return "Enables the updated dark mode color palette"
case .premiumPaywall: return "Shows the premium paywall before gated features"
case .experimentalUI: return "Enables experimental UI components"
}
}
}FeatureFlagService.swift
The protocol that all providers conform to. This enables swapping providers for testing, previews, or different environments.
import Foundation
/// Protocol defining the interface for feature flag providers.
///
/// Conform to this protocol to create new flag sources (local, remote, composite, mock).
/// All methods are synchronous reads; async `refresh()` is the only network operation.
protocol FeatureFlagService: Sendable {
/// Returns whether the given flag is enabled.
/// - Parameter flag: The feature flag to check.
/// - Returns: `true` if the flag is enabled, `false` otherwise.
func isEnabled(_ flag: FeatureFlag) -> Bool
/// Returns the boolean value for a flag, or its default if not set.
/// - Parameter flag: The feature flag to query.
/// - Returns: The boolean value.
func boolValue(_ flag: FeatureFlag) -> Bool
/// Returns the string value for a flag, if one exists.
/// - Parameter flag: The feature flag to query.
/// - Returns: The string value, or nil if not set.
func stringValue(_ flag: FeatureFlag) -> String?
/// Returns the integer value for a flag, if one exists.
/// - Parameter flag: The feature flag to query.
/// - Returns: The integer value, or nil if not set.
func intValue(_ flag: FeatureFlag) -> Int?
/// Returns a decoded JSON value for a flag, if one exists.
/// - Parameter flag: The feature flag to query.
/// - Returns: The decoded value, or nil if not set or decoding fails.
func jsonValue<T: Decodable>(_ flag: FeatureFlag) -> T?
/// Refreshes flag values from the source (e.g., network fetch).
/// For local-only providers, this is a no-op.
func refresh() async throws
}
// MARK: - Default Implementations
extension FeatureFlagService {
func boolValue(_ flag: FeatureFlag) -> Bool {
isEnabled(flag)
}
func stringValue(_ flag: FeatureFlag) -> String? {
flag.defaultStringValue
}
func intValue(_ flag: FeatureFlag) -> Int? {
flag.defaultIntValue
}
func jsonValue<T: Decodable>(_ flag: FeatureFlag) -> T? {
nil
}
func refresh() async throws {
// No-op by default for local-only providers.
}
}LocalFeatureFlagProvider.swift
A provider backed by UserDefaults with compile-time defaults. In DEBUG builds, flags can be overridden via the debug menu.
import Foundation
/// A feature flag provider that uses local defaults and UserDefaults overrides.
///
/// In DEBUG builds, the debug menu writes overrides to UserDefaults with the `ff_` prefix.
/// In RELEASE builds, only the compile-time defaults from `FeatureFlag.defaultValue` are used.
final class LocalFeatureFlagProvider: FeatureFlagService, @unchecked Sendable {
// MARK: - Properties
private let defaults: UserDefaults
private let keyPrefix = "ff_"
// MARK: - Initialization
/// Creates a local provider backed by the given UserDefaults suite.
/// - Parameter defaults: The UserDefaults instance to use. Defaults to `.standard`.
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}
// MARK: - FeatureFlagService
func isEnabled(_ flag: FeatureFlag) -> Bool {
#if DEBUG
// Check for a debug override first.
let key = keyPrefix + flag.rawValue
if defaults.object(forKey: key) != nil {
return defaults.bool(forKey: key)
}
#endif
return flag.defaultValue
}
func boolValue(_ flag: FeatureFlag) -> Bool {
isEnabled(flag)
}
func stringValue(_ flag: FeatureFlag) -> String? {
#if DEBUG
let key = keyPrefix + flag.rawValue + "_string"
if let override = defaults.string(forKey: key) {
return override
}
#endif
return flag.defaultStringValue
}
func intValue(_ flag: FeatureFlag) -> Int? {
#if DEBUG
let key = keyPrefix + flag.rawValue + "_int"
if defaults.object(forKey: key) != nil {
return defaults.integer(forKey: key)
}
#endif
return flag.defaultIntValue
}
func jsonValue<T: Decodable>(_ flag: FeatureFlag) -> T? {
#if DEBUG
let key = keyPrefix + flag.rawValue + "_json"
if let data = defaults.data(forKey: key) {
return try? JSONDecoder().decode(T.self, from: data)
}
#endif
return nil
}
// MARK: - Debug Override Management
#if DEBUG
/// Sets a boolean override for a flag. Used by the debug menu.
func setOverride(_ flag: FeatureFlag, enabled: Bool) {
defaults.set(enabled, forKey: keyPrefix + flag.rawValue)
}
/// Removes the override for a flag, reverting to the compile-time default.
func removeOverride(_ flag: FeatureFlag) {
defaults.removeObject(forKey: keyPrefix + flag.rawValue)
defaults.removeObject(forKey: keyPrefix + flag.rawValue + "_string")
defaults.removeObject(forKey: keyPrefix + flag.rawValue + "_int")
defaults.removeObject(forKey: keyPrefix + flag.rawValue + "_json")
}
/// Removes all flag overrides.
func removeAllOverrides() {
for flag in FeatureFlag.allCases {
removeOverride(flag)
}
}
/// Returns whether a debug override exists for the given flag.
func hasOverride(_ flag: FeatureFlag) -> Bool {
defaults.object(forKey: keyPrefix + flag.rawValue) != nil
}
#endif
}RemoteFeatureFlagProvider.swift
A provider that fetches flag values from a JSON endpoint with disk caching.
import Foundation
import os.log
/// A feature flag provider that fetches flag values from a remote JSON endpoint.
///
/// The expected JSON format:
/// ```json
/// {
/// "new_onboarding": true,
/// "dark_mode_v2": false,
/// "welcome_message": "Hello!",
/// "max_retries": 5,
/// "paywall_config": { "title": "Go Premium", "trialDays": 7 }
/// }
/// ```
///
/// Values are cached to disk so the app has recent values available on cold launch
/// before the first network fetch completes.
final class RemoteFeatureFlagProvider: FeatureFlagService, @unchecked Sendable {
// MARK: - Properties
private let endpoint: URL
private let urlSession: URLSession
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "FeatureFlags")
private let cacheURL: URL
private let cacheDuration: TimeInterval
/// Lock-protected storage for the fetched flag values.
private let lock = NSLock()
private var flags: [String: Any] = [:]
// MARK: - Initialization
/// Creates a remote provider.
/// - Parameters:
/// - endpoint: The URL of the JSON endpoint returning flag values.
/// - urlSession: The URLSession to use for fetching. Defaults to `.shared`.
/// - cacheDuration: How long cached values are considered fresh, in seconds. Defaults to 300 (5 minutes).
init(
endpoint: URL,
urlSession: URLSession = .shared,
cacheDuration: TimeInterval = 300
) {
self.endpoint = endpoint
self.urlSession = urlSession
self.cacheDuration = cacheDuration
let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
self.cacheURL = cacheDir.appendingPathComponent("feature_flags_cache.json")
// Load cached values on init so flags are available before first refresh.
loadCache()
}
// MARK: - FeatureFlagService
func isEnabled(_ flag: FeatureFlag) -> Bool {
lock.lock()
defer { lock.unlock() }
return (flags[flag.rawValue] as? Bool) ?? flag.defaultValue
}
func boolValue(_ flag: FeatureFlag) -> Bool {
isEnabled(flag)
}
func stringValue(_ flag: FeatureFlag) -> String? {
lock.lock()
defer { lock.unlock() }
if let value = flags[flag.rawValue] as? String {
return value
}
// Check for a suffixed key (e.g., "flag_name_string").
if let value = flags[flag.rawValue + "_string"] as? String {
return value
}
return flag.defaultStringValue
}
func intValue(_ flag: FeatureFlag) -> Int? {
lock.lock()
defer { lock.unlock() }
if let value = flags[flag.rawValue] as? Int {
return value
}
if let value = flags[flag.rawValue + "_int"] as? Int {
return value
}
return flag.defaultIntValue
}
func jsonValue<T: Decodable>(_ flag: FeatureFlag) -> T? {
lock.lock()
let rawValue = flags[flag.rawValue]
lock.unlock()
guard let rawValue else { return nil }
// If the value is already a dictionary or array, re-serialize and decode.
guard JSONSerialization.isValidJSONObject(rawValue) else { return nil }
guard let data = try? JSONSerialization.data(withJSONObject: rawValue) else { return nil }
return try? JSONDecoder().decode(T.self, from: data)
}
func refresh() async throws {
let (data, response) = try await urlSession.data(from: endpoint)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
logger.error("Feature flag fetch failed with status: \((response as? HTTPURLResponse)?.statusCode ?? -1)")
throw FeatureFlagError.fetchFailed
}
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
logger.error("Feature flag response is not a valid JSON object.")
throw FeatureFlagError.invalidResponse
}
lock.lock()
flags = json
lock.unlock()
// Persist to disk cache.
saveCache(data: data)
logger.info("Feature flags refreshed: \(json.count) flags loaded.")
}
// MARK: - Disk Cache
private func loadCache() {
guard FileManager.default.fileExists(atPath: cacheURL.path) else { return }
// Check cache freshness.
if let attributes = try? FileManager.default.attributesOfItem(atPath: cacheURL.path),
let modDate = attributes[.modificationDate] as? Date,
Date().timeIntervalSince(modDate) > cacheDuration {
// Cache is stale but still usable as fallback. Load it anyway.
logger.info("Feature flag cache is stale but loading as fallback.")
}
guard let data = try? Data(contentsOf: cacheURL),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return
}
lock.lock()
flags = json
lock.unlock()
logger.info("Feature flags loaded from cache: \(json.count) flags.")
}
private func saveCache(data: Data) {
do {
try data.write(to: cacheURL, options: .atomic)
} catch {
logger.warning("Failed to save feature flag cache: \(error.localizedDescription)")
}
}
}
// MARK: - Errors
/// Errors that can occur during feature flag operations.
enum FeatureFlagError: LocalizedError {
case fetchFailed
case invalidResponse
var errorDescription: String? {
switch self {
case .fetchFailed:
return "Failed to fetch feature flags from the remote endpoint."
case .invalidResponse:
return "The feature flag response was not a valid JSON object."
}
}
}CompositeFeatureFlagProvider.swift
Combines a local provider (for defaults and debug overrides) with a remote provider (for server-driven values). Remote values take precedence when available.
import Foundation
/// A composite provider that layers remote values on top of local defaults.
///
/// Resolution order:
/// 1. DEBUG override (if set via debug menu, handled inside LocalFeatureFlagProvider)
/// 2. Remote value (if fetched successfully)
/// 3. Local default (compile-time fallback)
///
/// This ensures the app always has a working value, even without network access.
final class CompositeFeatureFlagProvider: FeatureFlagService, @unchecked Sendable {
// MARK: - Properties
private let local: LocalFeatureFlagProvider
private let remote: RemoteFeatureFlagProvider
// MARK: - Initialization
/// Creates a composite provider.
/// - Parameters:
/// - local: The local provider for defaults and debug overrides.
/// - remote: The remote provider for server-driven values.
init(local: LocalFeatureFlagProvider, remote: RemoteFeatureFlagProvider) {
self.local = local
self.remote = remote
}
// MARK: - FeatureFlagService
func isEnabled(_ flag: FeatureFlag) -> Bool {
#if DEBUG
// Debug overrides always win.
if local.hasOverride(flag) {
return local.isEnabled(flag)
}
#endif
// Remote value takes precedence over local default.
// The remote provider falls back to flag.defaultValue internally
// if no remote value exists, which matches the local default.
return remote.isEnabled(flag)
}
func boolValue(_ flag: FeatureFlag) -> Bool {
isEnabled(flag)
}
func stringValue(_ flag: FeatureFlag) -> String? {
#if DEBUG
if local.hasOverride(flag), let value = local.stringValue(flag) {
return value
}
#endif
return remote.stringValue(flag) ?? local.stringValue(flag)
}
func intValue(_ flag: FeatureFlag) -> Int? {
#if DEBUG
if local.hasOverride(flag), let value = local.intValue(flag) {
return value
}
#endif
return remote.intValue(flag) ?? local.intValue(flag)
}
func jsonValue<T: Decodable>(_ flag: FeatureFlag) -> T? {
#if DEBUG
if local.hasOverride(flag) {
if let value: T = local.jsonValue(flag) {
return value
}
}
#endif
return remote.jsonValue(flag) ?? local.jsonValue(flag)
}
func refresh() async throws {
try await remote.refresh()
}
}FeatureFlagManager.swift
The @Observable manager that SwiftUI views interact with. It wraps a provider and exposes flag state reactively.
import Foundation
import os.log
/// The main entry point for feature flag access in the app.
///
/// This class is `@Observable` so SwiftUI views automatically re-render when
/// flag values change (e.g., after a remote refresh or debug override).
///
/// Usage:
/// ```swift
/// @Environment(FeatureFlagManager.self) private var flags
///
/// if flags.isEnabled(.newOnboarding) {
/// NewOnboardingView()
/// }
/// ```
@MainActor
@Observable
final class FeatureFlagManager {
// MARK: - Properties
private let provider: any FeatureFlagService
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "", category: "FeatureFlagManager")
/// Incremented after each refresh to trigger SwiftUI observation updates.
private(set) var refreshCount: Int = 0
/// The timestamp of the last successful refresh.
private(set) var lastRefreshDate: Date?
/// Whether a refresh is currently in progress.
private(set) var isRefreshing: Bool = false
// MARK: - Initialization
/// Creates a feature flag manager with the given provider.
/// - Parameter provider: The underlying flag provider (local, remote, or composite).
init(provider: any FeatureFlagService) {
self.provider = provider
}
// MARK: - Flag Access
/// Returns whether the given flag is enabled.
func isEnabled(_ flag: FeatureFlag) -> Bool {
// Access refreshCount to establish an observation dependency,
// ensuring views re-evaluate after refresh.
_ = refreshCount
return provider.isEnabled(flag)
}
/// Returns the boolean value for a flag.
func boolValue(_ flag: FeatureFlag) -> Bool {
_ = refreshCount
return provider.boolValue(flag)
}
/// Returns the string value for a flag, if one exists.
func stringValue(_ flag: FeatureFlag) -> String? {
_ = refreshCount
return provider.stringValue(flag)
}
/// Returns the integer value for a flag, if one exists.
func intValue(_ flag: FeatureFlag) -> Int? {
_ = refreshCount
return provider.intValue(flag)
}
/// Returns a decoded JSON value for a flag, if one exists.
func jsonValue<T: Decodable>(_ flag: FeatureFlag) -> T? {
_ = refreshCount
return provider.jsonValue(flag)
}
// MARK: - Refresh
/// Refreshes flag values from the remote source.
///
/// After a successful refresh, all observing SwiftUI views will re-evaluate.
func refresh() async throws {
isRefreshing = true
defer { isRefreshing = false }
do {
try await provider.refresh()
refreshCount += 1
lastRefreshDate = Date()
logger.info("Feature flags refreshed successfully.")
} catch {
logger.error("Feature flag refresh failed: \(error.localizedDescription)")
throw error
}
}
// MARK: - Debug Access
#if DEBUG
/// Provides access to the underlying provider for the debug menu.
/// Returns the local provider if available (directly or inside a composite).
var debugLocalProvider: LocalFeatureFlagProvider? {
if let local = provider as? LocalFeatureFlagProvider {
return local
}
if let composite = provider as? CompositeFeatureFlagProvider {
// Access the local provider via a mirror since it is private.
let mirror = Mirror(reflecting: composite)
return mirror.children.first(where: { $0.label == "local" })?.value as? LocalFeatureFlagProvider
}
return nil
}
/// Forces a re-evaluation of all observing views.
/// Called by the debug menu after toggling an override.
func notifyFlagChanged() {
refreshCount += 1
}
#endif
}FeatureFlagEnvironmentKey.swift
SwiftUI Environment integration so views can access the flag manager via @Environment.
import SwiftUI
// MARK: - Environment Access
/// Provides the FeatureFlagManager through SwiftUI's environment system.
///
/// Injection (at the app root):
/// ```swift
/// ContentView()
/// .environment(featureFlagManager)
/// ```
///
/// Usage (in any descendant view):
/// ```swift
/// @Environment(FeatureFlagManager.self) private var flags
///
/// if flags.isEnabled(.newOnboarding) { ... }
/// ```
///
/// Because `FeatureFlagManager` is `@Observable`, SwiftUI automatically
/// discovers it from the environment when using `@Environment(FeatureFlagManager.self)`.
/// No custom `EnvironmentKey` is needed with the iOS 17+ observation system.
///
/// For convenience, a `View` extension is provided below.
extension View {
/// Injects the feature flag manager into the environment.
///
/// Equivalent to `.environment(manager)` but provides a discoverable API.
func featureFlags(_ manager: FeatureFlagManager) -> some View {
self.environment(manager)
}
}
// MARK: - Preview Support
/// A convenience initializer for SwiftUI previews that creates a manager with local defaults.
extension FeatureFlagManager {
/// Creates a manager backed by a local-only provider for use in previews.
@MainActor
static var preview: FeatureFlagManager {
FeatureFlagManager(provider: LocalFeatureFlagProvider())
}
}FeatureFlagDebugView.swift
A debug-only view that lists all flags with toggles for overriding values at runtime.
#if DEBUG
import SwiftUI
/// A debug menu for viewing and overriding feature flag values at runtime.
///
/// This view is only available in DEBUG builds. It allows developers and QA
/// to toggle flags without recompiling or changing the server configuration.
///
/// Usage:
/// ```swift
/// #if DEBUG
/// NavigationLink("Feature Flags") {
/// FeatureFlagDebugView()
/// }
/// #endif
/// ```
struct FeatureFlagDebugView: View {
@Environment(FeatureFlagManager.self) private var manager
var body: some View {
List {
headerSection
flagsSection
actionsSection
}
.navigationTitle("Feature Flags")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
// MARK: - Sections
private var headerSection: some View {
Section {
if let lastRefresh = manager.lastRefreshDate {
LabeledContent("Last Refresh") {
Text(lastRefresh, style: .relative)
.foregroundStyle(.secondary)
}
}
if manager.isRefreshing {
HStack {
ProgressView()
.controlSize(.small)
Text("Refreshing...")
.foregroundStyle(.secondary)
}
}
} header: {
Text("Status")
}
}
private var flagsSection: some View {
Section {
ForEach(FeatureFlag.allCases, id: \.self) { flag in
FlagRow(flag: flag, manager: manager)
}
} header: {
Text("Flags")
} footer: {
Text("Overrides are stored in UserDefaults and persist across launches. They only apply in DEBUG builds.")
}
}
private var actionsSection: some View {
Section {
Button("Refresh Remote Flags") {
Task {
try? await manager.refresh()
}
}
Button("Reset All Overrides", role: .destructive) {
manager.debugLocalProvider?.removeAllOverrides()
manager.notifyFlagChanged()
}
} header: {
Text("Actions")
}
}
}
// MARK: - Flag Row
/// A single row in the debug flag list showing the flag name, description, and a toggle.
private struct FlagRow: View {
let flag: FeatureFlag
let manager: FeatureFlagManager
var body: some View {
let localProvider = manager.debugLocalProvider
let hasOverride = localProvider?.hasOverride(flag) ?? false
let currentValue = manager.isEnabled(flag)
VStack(alignment: .leading, spacing: 4) {
HStack {
Toggle(flag.displayName, isOn: Binding(
get: { currentValue },
set: { newValue in
localProvider?.setOverride(flag, enabled: newValue)
manager.notifyFlagChanged()
}
))
.font(.body.weight(hasOverride ? .semibold : .regular))
}
Text(flag.flagDescription)
.font(.caption)
.foregroundStyle(.secondary)
HStack(spacing: 8) {
Text("Key: \(flag.rawValue)")
.font(.caption2)
.foregroundStyle(.tertiary)
if hasOverride {
Text("OVERRIDDEN")
.font(.caption2.weight(.bold))
.foregroundStyle(.orange)
}
Text("Default: \(flag.defaultValue ? "ON" : "OFF")")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.padding(.vertical, 2)
.swipeActions(edge: .trailing) {
if hasOverride {
Button("Reset") {
localProvider?.removeOverride(flag)
manager.notifyFlagChanged()
}
.tint(.blue)
}
}
}
}
// MARK: - Preview
#Preview {
NavigationStack {
FeatureFlagDebugView()
.environment(FeatureFlagManager.preview)
}
}
#endifPatterns: Good and Bad
Flag Definitions
// ✅ Good: Descriptive names with clear defaults
enum FeatureFlag: String, CaseIterable, Sendable {
case newCheckoutFlow = "new_checkout_flow"
case premiumTrial = "premium_trial"
var defaultValue: Bool {
switch self {
case .newCheckoutFlow: return false // Off until rollout
case .premiumTrial: return true // On by default
}
}
}
// ❌ Bad: Vague names, no structure
let flag1 = UserDefaults.standard.bool(forKey: "flag1")
let enableThing = trueChecking Flags
// ✅ Good: Check via the manager (reactive, testable)
@Environment(FeatureFlagManager.self) private var flags
if flags.isEnabled(.newOnboarding) {
NewOnboardingView()
}
// ❌ Bad: Read UserDefaults directly (not reactive, not testable)
if UserDefaults.standard.bool(forKey: "new_onboarding") {
NewOnboardingView()
}Provider Architecture
// ✅ Good: Protocol-based, swappable providers
let provider = CompositeFeatureFlagProvider(
local: LocalFeatureFlagProvider(),
remote: RemoteFeatureFlagProvider(endpoint: flagsURL)
)
let manager = FeatureFlagManager(provider: provider)
// ❌ Bad: Hardcoded single implementation with no abstraction
class FeatureFlags {
static let shared = FeatureFlags()
func isEnabled(_ key: String) -> Bool {
UserDefaults.standard.bool(forKey: key)
}
}Refresh Strategy
// ✅ Good: Refresh on launch, handle errors gracefully
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
Task {
do {
try await featureFlagManager.refresh()
} catch {
// Local defaults are already available; log and continue.
logger.warning("Flag refresh failed: \(error.localizedDescription)")
}
}
return true
}
// ❌ Bad: Block app launch on network call
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
let semaphore = DispatchSemaphore(value: 0)
URLSession.shared.dataTask(with: flagsURL) { data, _, _ in
// Parse flags...
semaphore.signal()
}.resume()
semaphore.wait() // Blocks main thread!
return true
}Flag Cleanup
// ✅ Good: Remove flag after full rollout
// 1. Delete the enum case
// 2. Remove all conditional branches
// 3. Delete remote configuration
// 4. Remove debug overrides from UserDefaults
// ❌ Bad: Leave stale flags indefinitely
enum FeatureFlag: String, CaseIterable {
case migrationV1 = "migration_v1" // Rolled out 6 months ago
case onboardingV2 = "onboarding_v2" // Rolled out 1 year ago
case betaFeature2023 = "beta_2023" // What does this even do?
}Thread Safety
// ✅ Good: @MainActor manager, Sendable providers, NSLock for mutable state
@MainActor
@Observable
final class FeatureFlagManager {
private let provider: any FeatureFlagService // Sendable
}
// ❌ Bad: Unprotected mutable state accessed from multiple threads
class FeatureFlagService {
var flags: [String: Any] = [:] // Data race!
func refresh() {
DispatchQueue.global().async {
self.flags = self.fetchFlags() // Written on background thread
}
}
func isEnabled(_ key: String) -> Bool {
flags[key] as? Bool ?? false // Read on main thread
}
}