DEV Community

Sebastien Lato
Sebastien Lato

Posted on

SwiftUI Feature Flags & Remote Config Architecture (Production-Grade)

Every serious app needs the ability to:

  • turn features on/off remotely
  • ship incomplete work safely
  • run A/B tests
  • kill broken features instantly
  • stage rollouts gradually

If your answer is:

“We just use if statements”

…you are one production incident away from pain.

This post shows how to design clean, testable, scalable feature flag architecture in SwiftUI — without polluting your UI or ViewModels.


🧠 The Core Principle

Feature flags are configuration, not logic.

Your app should react to flags, not be built out of flags.


🧱 1. Define Feature Flags as a Typed Model

Never use raw strings:

if flags["new_home"] == true { }
Enter fullscreen mode Exit fullscreen mode

Instead:

enum FeatureFlag: String, CaseIterable {
    case newHome
    case profileRedesign
    case aiSearch
    case experimentalCharts
}
Enter fullscreen mode Exit fullscreen mode

Type safety matters.


📦 2. Feature Flag Service

protocol FeatureFlagService {
    func isEnabled(_ flag: FeatureFlag) -> Bool
    func refresh() async
}
Enter fullscreen mode Exit fullscreen mode

Concrete implementation:

final class FeatureFlagServiceImpl: FeatureFlagService {
    private var flags: [FeatureFlag: Bool] = [:]

    func isEnabled(_ flag: FeatureFlag) -> Bool {
        flags[flag] ?? false
    }

    func refresh() async {
        let remote = try? await fetchRemoteFlags()
        flags = remote ?? flags
    }
}
Enter fullscreen mode Exit fullscreen mode

🌐 3. Remote Config Layer

Remote config should:

  • fetch on launch
  • cache locally
  • refresh in background
  • fail gracefully
func fetchRemoteFlags() async throws -> [FeatureFlag: Bool] {
    // API call here
}
Enter fullscreen mode Exit fullscreen mode

Never block app launch.


💾 4. Local Fallback & Persistence

Always persist last-known flags:

func persist(_ flags: [FeatureFlag: Bool]) {
    let data = try? JSONEncoder().encode(flags)
    UserDefaults.standard.set(data, forKey: "feature_flags")
}
Enter fullscreen mode Exit fullscreen mode

On launch:

func loadPersisted() -> [FeatureFlag: Bool] {
    guard
        let data = UserDefaults.standard.data(forKey: "feature_flags"),
        let flags = try? JSONDecoder().decode([FeatureFlag: Bool].self, from: data)
    else {
        return [:]
    }
    return flags
}
Enter fullscreen mode Exit fullscreen mode

This gives:

  • instant startup
  • offline safety
  • consistent UX

🧩 5. Inject Flags into the Dependency Graph

Feature flags belong in AppContainer:

final class AppContainer {
    let featureFlags: FeatureFlagService
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Inject into environment:

.environment(\.featureFlags, appContainer.featureFlags)
Enter fullscreen mode Exit fullscreen mode

Views consume — they never create.


🎯 6. How Views Should Use Flags

Bad:

if featureFlags.isEnabled(.newHome) {
    NewHomeView()
} else {
    OldHomeView()
}
Enter fullscreen mode Exit fullscreen mode

Good:

HomeContainer()
Enter fullscreen mode Exit fullscreen mode

Where HomeContainer resolves internally:

struct HomeContainer: View {
    @Environment(\.featureFlags) var flags

    var body: some View {
        if flags.isEnabled(.newHome) {
            NewHomeView()
        } else {
            OldHomeView()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Flags are isolated at boundaries, not everywhere.


🧠 7. ViewModel Usage

ViewModels should:

  • receive flags via init
  • expose derived behavior
final class HomeViewModel {
    let showAI: Bool

    init(flags: FeatureFlagService) {
        self.showAI = flags.isEnabled(.aiSearch)
    }
}
Enter fullscreen mode Exit fullscreen mode

No flag lookups inside business logic.


🧪 8. Testing Becomes Trivial

struct MockFeatureFlags: FeatureFlagService {
    let enabled: Set<FeatureFlag>

    func isEnabled(_ flag: FeatureFlag) -> Bool {
        enabled.contains(flag)
    }

    func refresh() async { }
}
Enter fullscreen mode Exit fullscreen mode
let flags = MockFeatureFlags(enabled: [.newHome])
let vm = HomeViewModel(flags: flags)
Enter fullscreen mode Exit fullscreen mode

No hacks. No globals.


🔁 9. Kill Switches (Critical)

Every risky feature must be:

  • behind a flag
  • default off
  • remotely controllable

This allows:

  • instant rollback
  • no App Store resubmission
  • no panic deploys

This is not optional in production.


⚠️ 10. Flag Lifecycle Management

Flags should have:

  • owners
  • descriptions
  • removal plans

Dead flags are technical debt.

Track:

  • creation date
  • rollout status
  • removal date

❌ 11. Common Anti-Patterns

Avoid:

  • flags inside low-level components
  • flags inside models
  • flags in networking layer
  • stringly-typed flags
  • feature flags as permanent code paths
  • flags controlling business rules

Flags control presentation & availability, not logic correctness.


🧠 Mental Model

Think of feature flags as:

Configuration Layer
   
Feature Selection
     
Normal App Flow
Enter fullscreen mode Exit fullscreen mode

They are gates, not forks.


🚀 Final Thoughts

A proper feature flag system gives you:

  • safer releases
  • faster iteration
  • controlled experiments
  • instant rollback
  • happier product teams
  • calmer developers

Once you build this, you’ll never ship “big bang” features again.

Top comments (0)