How do you use feature flags: compile-time versus runtime, ownership, and modularisation by team?
Predict first
A compile-time flag is defined only for one build. What does each build print?
// === Package.swift ===
// swift-tools-version: 6.0
import PackageDescription
let package = Package(name: "Cfg", targets: [.executableTarget(name: "T")])
// === Sources/T/main.swift ===
#if FEATURE_X
print("feature on")
#else
print("feature off")
#endif
- feature off, then feature on
- feature on, then feature on
- feature off, then feature off
Predict first
A runtime flag provider falls back to “off” when its config is missing. What does the app do for an unknown or unloaded flag?
struct Flags {
var values: [String: Bool]
func isEnabled(_ name: String) -> Bool { values[name] ?? false }
}
let loaded = Flags(values: ["newCheckout": true])
let notLoaded = Flags(values: [:])
print(loaded.isEnabled("newCheckout"), notLoaded.isEnabled("newCheckout"), loaded.isEnabled("unknown"))
- true false false
- true true true
- false false false
Mental model. A compile-time flag is deciding at the factory whether to put a button on the product. A runtime flag is a switch on the finished product that you can flip from headquarters, with a cover to hide it until ready.
A feature flag lets you separate deploying code from turning behaviour on. Two kinds, with different jobs. Compile-time flags (#if FEATURE_X, set with -D FEATURE_X or swiftSettings: [.define("FEATURE_X")] and per configuration) include or exclude code from the binary: the same source prints “feature off” normally and “feature on” when built with -DFEATURE_X. Use them for things that must not ship at all (unreleased or internal-only features, debug tools, an enterprise-only build), for removing dead code from release binaries, and where you need the compiler to drop a dependency. Limits: you need a new build to change it, and the disabled branch is not type-checked unless you build both. Runtime flags (a remote configuration service, or a local override) are values read at run time: ship the code dark, enable it for employees, then a percentage of users, watch metrics, and roll back instantly without a release. They also serve kill switches for risky or third-party-dependent features, experiments (A/B tests), and entitlement or regional behaviour. Limits: the code ships in every binary (so do not put secrets or unreleased marketing details behind a runtime flag), every flag doubles the states to test, and flags that are never removed turn into a tangle.
Good hygiene. Evaluate flags in one place: a small FeatureFlags protocol injected where needed (not if Remote.bool("x") scattered through views), so tests can force each value and the code reads flags.isEnabled(.newCheckout). Give every flag an owner, a description and an expiry, and remove it (and the old code path) once the rollout is finished; track the number of live flags. Define a safe default for when the config has not loaded or is unreachable (usually “off”), cache the last good values, and decide when flags are read: at launch only, so behaviour does not change mid-session, or live. Keep risk in mind: a flag combination matrix can hide untested paths, so test the on and off paths of every shipped flag, and prefer a few well-named flags over a flag per tiny change. Modularisation by team works well with flags: each feature module is owned by a team, merged to main continuously behind its flag, and wired in by the app target only when enabled (a module registry that lists enabled features), which supports trunk-based development, avoids long-lived branches and lets a feature ship when ready rather than when its branch is merged.
The code for the flag is only compiled in when the -D condition is set, so a normal build prints the off branch.
A flag is on only when it is explicitly set; any missing value defaults to off, which is the safe state.
Common traps
- Leaving flags in forever. Dead branches and untested combinations pile up; give each flag an owner and an expiry and delete it.
- Hiding secrets or unreleased details behind a runtime flag. The code ships in the binary; use a compile-time flag or keep it server-side.
They might follow up with
- When do you choose a compile-time flag? When the code must not ship at all, or you want the compiler to remove it.
- What is the safe default for a runtime flag? Off, when the configuration is missing or unreachable.
Related questions
- 16.4.1 What are build configurations and build settings, how do xcconfig files layer, and how does #if DEBUG work?
- 16.5.1 How do resources and localisation work inside a module: Bundle.module, process versus copy, and strings…
- 16.6.1 A CI pipeline takes hours on a large modular app. How do you analyse and cut it: affected builds, caching…