A Feature Flags System in Swift


Greetings, traveler!

Feature flags are a foundational tool for modern mobile development. They allow teams to control behavior at runtime, roll out features gradually, run experiments, and decouple deployment from release. In iOS applications, a well-designed feature flag system also becomes a central coordination mechanism between product, QA, and engineering.

This article walks through a type-safe and thread-safe feature flag implementation in Swift. It explains the design decisions behind the approach, the problems it solves, and how to integrate it into a real application.

Why Feature Flags Matter

Feature flags address several recurring challenges:

  • Gradual rollouts. A feature can be enabled for a subset of users without requiring a new build.
  • Safe releases. Risky changes can be hidden behind a flag and enabled only after validation.
  • Experimentation. A/B testing becomes straightforward when behavior can be toggled dynamically.
  • Environment-specific behavior. Debug tools, logging, and mocks can be enabled in non-production environments.

In iOS, these needs often evolve into a mix of hardcoded booleans, environment checks, and scattered configuration. Over time, this becomes difficult to maintain. A centralized, type-safe solution brings consistency and predictability.

Design Goals

The implementation is built around several principles:

  • Type safety. Features are defined as Swift types rather than string keys scattered across the codebase.
  • Deterministic resolution. When multiple sources define a value, the system resolves conflicts using explicit priorities.
  • Thread safety. Reads are synchronous and safe to call from UI code.
  • Extensibility. New sources can be added without changing the core resolver.
  • Separation of concerns. Feature definition, value storage, and resolution are independent.

Defining Features

Each feature conforms to a simple protocol:

public protocol Feature: Sendable {
    var key: String { get }
    var description: String { get }
    var defaultValue: Bool { get }
}

public extension Feature where Self: RawRepresentable, RawValue == String {
    var key: String { rawValue }
    var description: String { rawValue }
}

A common pattern is to use string-backed enums:

enum AppFeature: String, Feature {
    case newCheckout
    case debugMenu

    var defaultValue: Bool {
        switch self {
        case .newCheckout: false
        case .debugMenu: false
        }
    }
}

The key and description can be derived automatically from the raw value. This keeps definitions concise while preserving clarity.

Representing Feature State

Feature values are expressed through a typed container:

public struct FeatureState<FeatureType: Feature>: Sendable {
    public let feature: FeatureType
    public let isEnabled: Bool
}

Helper functions make configuration more readable:

public func enable<FeatureType: Feature>(_ feature: FeatureType) -> FeatureState<FeatureType>
public func disable<FeatureType: Feature>(_ feature: FeatureType) -> FeatureState<FeatureType>

These are primarily used in configuration DSLs.

Feature Sources and Priority

A feature flag system typically pulls values from multiple sources. This implementation models each source explicitly:

public protocol FeatureFlagSource: Sendable {
    var priority: FeatureFlagPriority { get }
    func value(forKey key: String) -> Bool?
}

Each source returns either:

A boolean value when it knows the feature, or nil when it has no information about the key. A missing value does not override a lower-priority source.

Priorities are defined as:

public enum FeatureFlagPriority: Int, Comparable {
    case business = 0
    case testing = 100
    case simulator = 200
    case localOverrides = 300
    case forced = 400
}

Higher values take precedence. Resolution always walks sources in descending priority order.

The Resolver

The central component is FeatureFlags:

public final class FeatureFlags {
    private let lock = NSLock()
    private var sources: [any FeatureFlagSource]

    public func isEnabled<FeatureType: Feature>(_ feature: FeatureType) -> Bool {
        let snapshot = snapshotSources()

        for source in snapshot {
            if let value = source.value(forKey: feature.key) {
                return value
            }
        }

        return feature.defaultValue
    }
}

The resolver creates a snapshot of sources under a lock and then iterates without holding the lock. This keeps reads fast and avoids unnecessary contention.

The resolution algorithm is straightforward:

  1. Iterate sources by priority.
  2. Return the first value found.
  3. Fall back to the feature’s default value.

This guarantees deterministic behavior across environments.

In-Memory Source

A simple dictionary-backed source covers many use cases:

public final class DictionaryFeatureFlagSource: FeatureFlagSource {
    private var values: [String: Bool]

    public func value(forKey key: String) -> Bool? {
        values[key]
    }
}

It can be constructed from raw values:

let source = DictionaryFeatureFlagSource(
    values: ["newCheckout": true],
    priority: .business
)

Or from typed states:

let source = DictionaryFeatureFlagSource(
    states: [
        enable(AppFeature.newCheckout),
        disable(AppFeature.debugMenu)
    ],
    priority: .testing
)

This source is useful for business defaults, test configurations, and simulator setups.

Persistent Overrides

Local overrides are often required during development and QA. A dedicated source persists values in UserDefaults:

public final class PersistentOverrideFeatureFlagSource: FeatureFlagSource {
    private let defaults: UserDefaults
    private var values: [String: Bool]

    public func setOverride(_ isEnabled: Bool, forKey key: String) {
        values[key] = isEnabled
        defaults.set(values, forKey: storageKey)
    }
}

This allows toggling features at runtime and preserving those changes between launches.

Declarative Configuration

The system includes a result builder for defining configurations:

@resultBuilder
public enum FeatureFlagBuilder { ... }

This enables a compact DSL:

let config = FlagConfiguration<AppFeature> {
    enable(.newCheckout)

    if isInternalBuild {
        enable(.debugMenu)
    }
}

The builder supports conditionals, loops, and arrays, which makes configurations expressive and maintainable.

Environment-Based Composition

Feature flags often vary by environment. The configurator composes sources based on context:

public struct FeatureFlagsConfigurator<FeatureType: Feature> {
    public let environment: FeatureFlagsEnvironment
    public let isSimulator: Bool
    public let localOverridesSource: PersistentOverrideFeatureFlagSource?
}

In production:

Only business configuration is used.

In non-production:

Testing, simulator, local overrides, and forced configurations are added.

Example:

let featureFlags = FeatureFlagsConfigurator(
    environment: .nonProduction,
    localOverridesSource: PersistentOverrideFeatureFlagSource(),
    businessConfiguration: businessConfig,
    testingConfiguration: testingConfig,
    simulatorConfiguration: simulatorConfig
).makeFeatureFlags()

This centralizes environment rules and prevents accidental leakage of debug behavior into production.

Example Usage

if featureFlags.isEnabled(AppFeature.newCheckout) {
    showNewCheckout()
} else {
    showLegacyCheckout()
}

With overrides:

overrides.setOverride(true, forKey: AppFeature.newCheckout)

This immediately affects resolution without restarting the app.

Integrating Feature Flags with SwiftUI

To make feature flags easily accessible across a SwiftUI application, the resolver can be injected into the environment. This allows any view in the hierarchy to read feature values without passing dependencies through initializers or view models. The approach keeps the view layer clean and aligns with SwiftUI’s data flow model.

By extending EnvironmentValues, a shared FeatureFlags instance becomes available to all descendant views. A custom modifier is then used to inject the resolver at the root of the hierarchy, typically in the App or Scene definition. Views can access it using the standard @Environment property wrapper.

Example

struct ContentView: View {
    @Environment(\.featureFlags) private var featureFlags

    var body: some View {
        VStack {
            if featureFlags.isEnabled(AppFeature.newCheckout) {
                Text("New Checkout Flow")
            } else {
                Text("Legacy Checkout Flow")
            }
        }
    }
}

Injection at the application level:

@main
struct MyApp: App {
    private let featureFlags = FeatureFlagsConfigurator(
        environment: .nonProduction,
        businessConfiguration: FlagConfiguration<AppFeature> {
            disable(.newCheckout)
        },
        testingConfiguration: FlagConfiguration<AppFeature> {
            enable(.newCheckout)
        }
    ).makeFeatureFlags()

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .featureFlags(featureFlags)
    }
}

This setup ensures that all views within the scene share the same resolver. The environment acts as a lightweight dependency container for feature flags, making it straightforward to toggle behavior at runtime while keeping the UI layer declarative and focused on rendering.

Building a Local Feature Toggle Screen

A useful addition to this system is a small internal screen where developers and QA can change local feature overrides directly from the app. This kind of screen is especially helpful in non-production builds, where testing different states should be fast and repeatable.

The example below defines a dedicated feature set for the screen and groups flags into two categories: technical flags and business flags. Technical flags control implementation-level behavior such as debug logging or verbose networking, while business flags control user-facing flows such as onboarding or checkout.

enum FeatureToggleExampleFlag: String, CaseIterable, Feature {
    case debugLogging
    case verboseNetworking
    case newOnboarding
    case redesignedCheckout

    var description: String {
        switch self {
        case .debugLogging:
            "Enables extra debug logs."
        case .verboseNetworking:
            "Prints network requests and responses."
        case .newOnboarding:
            "Enables the new onboarding flow."
        case .redesignedCheckout:
            "Enables the redesigned checkout flow."
        }
    }

    var defaultValue: Bool {
        false
    }
}

The flags can then be grouped for presentation:

enum FeatureToggleCategory: String, CaseIterable {
    case technical = "Technical Flags"
    case business = "Business Flags"

    var features: [FeatureToggleExampleFlag] {
        switch self {
        case .technical:
            [.debugLogging, .verboseNetworking]
        case .business:
            [.newOnboarding, .redesignedCheckout]
        }
    }
}

The screen uses a persistent override source backed by UserDefaults. Each toggle reads the effective value from FeatureFlags and writes changes into the local override source:

private func binding(for feature: FeatureToggleExampleFlag) -> Binding<Bool> {
    .init(
        get: { flags.isEnabled(feature) },
        set: { overrideSource.setOverride($0, for: feature) }
    )
}

The complete SwiftUI screen remains small:

struct FeatureFlagOverrideScreenExample: View {
    private let flags: FeatureFlags
    private let overrideSource: PersistentOverrideFeatureFlagSource

    init(
        flags: FeatureFlags = featureToggleExampleFlags,
        overrideSource: PersistentOverrideFeatureFlagSource = featureToggleOverrideSource
    ) {
        self.flags = flags
        self.overrideSource = overrideSource
    }

    var body: some View {
        NavigationStack {
            List {
                ForEach(FeatureToggleCategory.allCases, id: \.rawValue) { category in
                    Section(category.rawValue) {
                        ForEach(category.features, id: \.rawValue) { feature in
                            Toggle(feature.rawValue, isOn: binding(for: feature))
                        }
                    }
                }
            }
            .navigationTitle("Feature Flags")
        }
    }

    private func binding(for feature: FeatureToggleExampleFlag) -> Binding<Bool> {
        .init(
            get: { flags.isEnabled(feature) },
            set: { overrideSource.setOverride($0, for: feature) }
        )
    }
}

This gives the app a simple internal control panel for changing local behavior without rebuilding, changing code, or touching remote configuration. The same pattern can be expanded with descriptions, search, reset actions, environment badges, or separate sections for product, QA, and engineering flags.

Advantages of This Approach

The implementation brings several benefits.

  • Strong typing ensures that feature keys are defined once and used consistently.
  • Priority-based resolution makes behavior predictable even with multiple sources.
  • Thread safety allows usage directly from UI code without async overhead.
  • Environment-aware composition avoids accidental configuration drift.
  • The DSL improves readability of feature configurations.

The system remains extensible. New sources can be introduced, such as remote config or experiment frameworks, without modifying the resolver.

Closing Thoughts

Feature flags tend to start as a simple boolean toggle and gradually evolve into a critical part of application infrastructure. Investing in a structured approach early pays off as the codebase grows.

This implementation provides a solid foundation. It remains lightweight while covering the essential needs of a production-ready feature flag system.

Available on my GitHub.