FeatureFlagsKit is a lightweight Swift package for defining, composing, and resolving feature flags in iOS applications.
It is designed for local and in-app feature management, with explicit source precedence, environment-aware configuration, persistent local overrides, and SwiftUI integration.
- Typed feature definitions via Swift enums
- Per-feature default values
- Clear source precedence model
- Environment-based composition for production and non-production builds
- In-memory and persistent override sources
- Compact DSL for declaring flag states
- SwiftUI environment integration
- Support for QA and developer-facing override screens
- iOS 13.0+
- SwiftUI helpers are available where SwiftUI can be imported
View.featureFlags(_:)andScene.featureFlags(_:)require iOS 14.0+
Add the package to your project using Swift Package Manager:
dependencies: [
.package(url: "https://github.com/Livsy90/FeatureFlagsKit.git", from: "1.0.0")
]Then add FeatureFlagsKit to your target dependencies.
Each feature conforms to Feature. In most cases, a String-backed enum is the simplest option.
import FeatureFlagsKit
enum AppFeature: String, Feature {
case newOnboarding
case redesignedCheckout
case debugLogging
var description: String {
switch self {
case .newOnboarding:
"Enables the new onboarding flow"
case .redesignedCheckout:
"Enables the redesigned checkout flow"
case .debugLogging:
"Enables additional debug logging"
}
}
var defaultValue: Bool {
switch self {
case .newOnboarding, .redesignedCheckout, .debugLogging:
false
}
}
}For String-backed enums, FeatureFlagsKit provides default implementations for:
keydescription
You only need to override description if you want something more readable than the raw value.
FeatureFlags is the main read API:
let flags = FeatureFlags()
if flags.isEnabled(AppFeature.newOnboarding) {
// Show new onboarding
}If no configured source contains a value for the feature, FeatureFlags falls back to feature.defaultValue.
You can also use the shared resolver when global access is more convenient:
FeatureFlags.shared.replaceSources(with: configurator.makeSources())
if FeatureFlags.shared.isEnabled(AppFeature.newOnboarding) {
// Show new onboarding
}The instance-based API is still recommended for tests, previews, and dependency injection.
A feature can be resolved from one or more sources. Each source has a priority. Higher-priority sources win.
Built-in priorities:
| Priority | Meaning |
|---|---|
business |
Base app configuration |
testing |
Non-production test configuration |
simulator |
Simulator-only configuration |
localOverrides |
Local developer / QA overrides |
forced |
Non-overridable final value |
Resolution is deterministic: sources are sorted by descending priority, and the first source that knows a key wins.
Use FlagConfiguration with the result-builder DSL:
let businessConfiguration = FlagConfiguration<AppFeature> {
enable(.newOnboarding)
disable(.redesignedCheckout)
}
let testingConfiguration = FlagConfiguration<AppFeature> {
enable(.redesignedCheckout)
}You can convert a configuration into a source explicitly:
let source = businessConfiguration.makeSource(priority: .business)
let flags = FeatureFlags(sources: [source])Use AnyFlagConfiguration when you want to keep flags grouped by feature area instead of accumulating every case in one enum.
enum OnboardingFeature: String, Feature {
case newWelcomeFlow
var defaultValue: Bool { false }
}
enum CheckoutFeature: String, Feature {
case expressCheckout
var defaultValue: Bool { false }
}
let businessConfiguration = AnyFlagConfiguration {
enable(OnboardingFeature.newWelcomeFlow)
disable(CheckoutFeature.expressCheckout)
}
let configurator = AnyFeatureFlagsConfigurator(
environment: .nonProduction,
businessConfiguration: businessConfiguration,
testingConfiguration: AnyFlagConfiguration {
enable(CheckoutFeature.expressCheckout)
}
)
let flags = configurator.makeFeatureFlags()Typed configurations can also be erased and combined:
let onboarding = FlagConfiguration<OnboardingFeature> {
enable(.newWelcomeFlow)
}
let checkout = FlagConfiguration<CheckoutFeature> {
disable(.expressCheckout)
}
let combined = AnyFlagConfiguration {
onboarding
checkout
}FeatureFlagsConfigurator composes the source list for you.
let localOverrides = PersistentOverrideFeatureFlagSource(
defaults: .standard,
storageKey: "feature_flags.local_overrides"
)
let configurator = FeatureFlagsConfigurator<AppFeature>(
environment: .nonProduction,
isSimulator: true,
localOverridesSource: localOverrides,
businessConfiguration: businessConfiguration,
testingConfiguration: testingConfiguration,
simulatorConfiguration: .empty,
forcedConfiguration: FlagConfiguration<AppFeature> {
disable(AppFeature.debugLogging)
}
)
let flags = configurator.makeFeatureFlags()To configure the shared resolver at app startup, replace its sources with the configurator output:
FeatureFlags.shared.replaceSources(with: configurator.makeSources())In .production, only the businessConfiguration is applied.
This means:
- no
testingConfiguration - no
simulatorConfiguration - no
localOverridesSource - no
forcedConfiguration
In .nonProduction, the configurator applies:
businessConfigurationtestingConfigurationsimulatorConfigurationwhenisSimulator == truelocalOverridesSourcewhen providedforcedConfiguration
That order matches the priority rules in the package.
An in-memory source for static configuration, tests, and ad hoc overrides.
let source = DictionaryFeatureFlagSource(
states: [
enable(.newOnboarding),
disable(.redesignedCheckout)
],
priority: .business
)You can also write values directly:
source.setValue(true, for: AppFeature.debugLogging)A UserDefaults-backed source intended for developers, QA, and local device testing.
let overrides = PersistentOverrideFeatureFlagSource(
defaults: .standard,
storageKey: "feature_flags.local_overrides"
)
overrides.setOverride(true, for: AppFeature.debugLogging)
overrides.removeOverride(for: AppFeature.debugLogging)Overrides persist across launches and participate in normal priority resolution.
func runUsageExample() {
let businessConfiguration = FlagConfiguration<AppFeature> {
enable(.newOnboarding)
disable(.redesignedCheckout)
}
let testingConfiguration = FlagConfiguration<AppFeature> {
enable(.redesignedCheckout)
}
let localOverrides = PersistentOverrideFeatureFlagSource(
defaults: .standard,
storageKey: "feature_flags.local_overrides"
)
let configurator = FeatureFlagsConfigurator<AppFeature>(
environment: .nonProduction,
isSimulator: true,
localOverridesSource: localOverrides,
businessConfiguration: businessConfiguration,
testingConfiguration: testingConfiguration,
simulatorConfiguration: .empty,
forcedConfiguration: FlagConfiguration<AppFeature> {
disable(AppFeature.debugLogging)
}
)
let flags = configurator.makeFeatureFlags()
if flags.isEnabled(AppFeature.newOnboarding) {
print("Show new onboarding")
} else {
print("Show legacy onboarding")
}
}When SwiftUI is available, the package adds FeatureFlags to EnvironmentValues.
import SwiftUI
import FeatureFlagsKit
@available(iOS 14.0, *)
struct RootView: View {
let flags: FeatureFlags
var body: some View {
NavigationView {
OnboardingView()
}
.featureFlags(flags)
}
}@available(iOS 14.0, *)
struct OnboardingView: View {
@Environment(\.featureFlags) private var flags
var body: some View {
Group {
if flags.isEnabled(AppFeature.newOnboarding) {
Text("New onboarding")
} else {
Text("Legacy onboarding")
}
}
}
}If you want the same resolver for the full app scene:
@available(iOS 14.0, *)
@main
struct DemoApp: App {
let flags = FeatureFlags()
var body: some Scene {
WindowGroup {
RootView(flags: flags)
}
.featureFlags(flags)
}
}The package includes a SwiftUI example screen that demonstrates a simple internal flag panel:
- one section for technical flags
- one section for business flags
- toggles that write to
PersistentOverrideFeatureFlagSource
This pattern is useful when you want:
- QA to verify flows without a new build
- developers to expose debug-only switches
- non-production builds to support local experimentation
The example lives in:
Sources/FeatureFlagsKit/Examples/FeatureFlagOverrideScreenExample.swift
The included test suite covers the main resolution rules:
- business configuration enables and disables flags correctly
- missing values fall back to
defaultValue - local overrides beat business configuration
- duplicate states use last-write-wins semantics
- forced configuration beats local overrides
- production excludes debug-only sources
- simulator configuration is only applied on simulator
- persistent overrides are loaded from
UserDefaults
This makes the package suitable for deterministic unit testing.
The package is designed for synchronous in-memory reads and uses locking internally for mutable shared state.
Thread-safe types include:
FeatureFlagsDictionaryFeatureFlagSourcePersistentOverrideFeatureFlagSource
This allows flags to be read from UI code while sources are updated from debug tools or test helpers.
- The package is intentionally local-first. It does not include networking or remote config providers.
- Source precedence is explicit and encoded in
FeatureFlagPriority. - Features are typed rather than stringly-typed at call sites.
forcedconfiguration gives you a final safety layer when something must not be overridden.
- rolling out a new onboarding flow
- keeping debug-only instrumentation behind internal switches
- enabling test features only in non-production builds
- exposing local overrides for QA on simulator or device
- forcing a feature off during a risky release