How do you use feature flags: compile-time versus runtime, ownership, and modularisation by team?

16.5.2 · L5 · Interview

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…

← How do resources and localisation work inside a module…