Building a simple feature flag system in Swift with Firebase Remote Config

In this post, we will see how we can create a simple feature flag system in Swift leveraging the Firebase Remote Config.

Feature flags (also sometimes called feature gates) are a simple mechanism that allows for enabling/disabling a feature at runtime. Here are some interesting use cases of feature flags:

  • only enable a feature for certain users meeting certain criteria: for example, I want to enable this feature, but only for users who live in the United States or have their phone language set to English
  • develop a feature and merge your code to the main branch freely as you know it will not be available to the user until you are ready to enable the feature
  • disable a feature remotely, without having to re-deploy your clients (and in the case of iOS re-submit a new version and wait for users to update their apps)

Before we begin

The following assumes that you already have Firebase installed and configured in your project, if not, please follow the instructions at https://firebase.google.com/docs/ios/setup).

In the Firebase console, let’s create our first dynamic configuration to represent a feature gate. I’m going to call this one enable_learn_mode_v2. For this, let’s go to the Firebase console, then Remote Config, and click on “Add parameter”. Set the key of the feature flag in the “Parameter name (key)” field, the Data type is going to be Boolean, and the default value false.

Remote configuration

A remote configuration is the first prerequisite to creating a dynamic feature flag system. We are going to create an abstraction that allows us to retrieve a dynamic configuration, and use Firebase Remote Config as its first implementation, this way we can easily migrate to another technology when we need to.

First, let’s create an enum that will encapsulate our different configuration keys:

// Configuration.swift

import Foundation

// MARK: - Configuration
enum Configuration {
    case custom(key: String)
    /* insert future configuration keys here */
    
    var key: String {
        switch self {
        case let .custom(customKey):
            return customKey
        }
    }
}

We can now create a ConfigurationProvider protocol to define how we will want to use the remote configuration:

// ConfigurationProvider.swift

import Foundation

// MARK: - ConfigurationProvider
protocol ConfigurationProvider {
    func boolean(for configuration: Configuration) -> Bool
    func double(for configuration: Configuration) -> Double
    func integer(for configuration: Configuration) -> Int
    func string(for configuration: Configuration) -> String?
    func string(for configuration: Configuration, defaultValue: String) -> String
}

Most components in our app will only need to use this ConfigurationProvider, but the app itself will need to make sure it refreshes the configuration at launch, let’s create a ConfigurationManager protocol for that:

// ConfigurationManager.swift

import Foundation

// MARK: - ConfigurationManager
protocol ConfigurationManager: ConfigurationProvider {
    func refresh() async throws
}

And now that we have all the building blocks, we can create our implementation using Firebase:

// FirebaseConfigurationManager.swift

import Firebase

// MARK: - FirebaseConfigurationManager
final class FirebaseConfigurationManager: ConfigurationManager {
    enum Exception: Error {
        case unknownFetchError
    }
    
    // MARK: - Remote config management
    func refresh() async throws {
        try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
            remoteConfig.fetch { (status, error) in
                switch status {
                case .noFetchYet, .throttled, .success:
                    continuation.resume(returning: ())
                case .failure:
                    continuation.resume(throwing: error ?? Exception.unknownFetchError)
                @unknown default:
                    assertionFailure("Unsupported case when refreshing the Firebase remote configuration")
                }
            }
        }
    }
    
    // MARK: - Value processing
    func boolean(for configuration: Configuration) -> Bool {
        remoteConfig[configuration.key].boolValue
    }
    
    func double(for configuration: Configuration) -> Double {
        remoteConfig[configuration.key].numberValue.doubleValue
    }
    
    func integer(for configuration: Configuration) -> Int {
        remoteConfig[configuration.key].numberValue.intValue
    }
    
    func string(for configuration: Configuration) -> String? {
        remoteConfig[configuration.key].stringValue
    }
    
    func string(for configuration: Configuration, defaultValue: String) -> String {
        string(for: configuration) ?? defaultValue
    }
    
    // MARK: - Dependencies
    private let remoteConfig: RemoteConfig = RemoteConfig.remoteConfig()
}

It’s now time to initialize our configuration manager. The AppDelegate is a good place to initialize and refresh the configuration, and you may only want to really access the app once this step is done:

// AppDelegate.swift

import Firebase
import UIKit

// MARK: - AppDelegate
final class AppDelegate: UIResponder, UIApplicationDelegate {
    private lazy var configurationManager: ConfigurationManager = FirebaseConfigurationManager()
    
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // Initialize Firebase
        FirebaseApp.configure()
        
        // Refresh the remote configuration
        Task {
            do {
                try await configurationManager.refresh()
                // only now the app really ready to start
            } catch {
                print("Error: failed to refresh the remote configuration.")
            }
        }

        return true
    }
    
    ...
}

Providing feature gates

In a similar way, we are going to create an abstraction for the feature flag system that uses our ConfigurationProvider as a data source.

We start with an enum that will encapsulate our feature flag keys. The string associated values will be the configuration keys:

// FeatureGate.swift

import Foundation

// MARK: - FeatureGate
enum FeatureGate: String {
    case enableLearnModeV2 = "enable_learn_mode_v2"
    
    var key: String { rawValue }
}

And as you will see, the feature gate provider implementation is quite simple:

// FeatureGateProvider.swift

import Foundation

// MARK: - FeatureGateProvider
protocol FeatureGateProvider {
    func isGateOpen(_ featureGate: FeatureGate) -> Bool
}

// MARK: - DefaultFeatureGateProvider
final class DefaultFeatureGateProvider: FeatureGateProvider {
    // MARK: - Initializers
    init(configurationProvider: ConfigurationProvider) {
        self.configurationProvider = configurationProvider
    }
    
    // MARK: - Gate management
    func isGateOpen(_ featureGate: FeatureGate) -> Bool {
        configurationProvider.boolean(for: .custom(key: featureGate.key))
    }
    
    // MARK: - Dependencies
    private let configurationProvider: ConfigurationProvider
}

Usage

We now have a FeatureGateProvider class we can inject in our code (view controllers, view models, presenters, etc.) to determine if a feature is enabled or not. Then it’s a simple matter of writing a bit of conditional code:

if featureGateProvider.isGateOpen(.enableLearnModeV2) {
  // new behavior to only be active when the gate is open
} else {
  // default behavior for when the gate is closed
}

Unit testing

Since we have created protocols for most things, it’s going to be very easy to mock our feature flag system in unit tests:

// ConfigurationManagerMock.swift

@testable import FeatureGateSystem

// MARK: - ConfigurationManagerMock
final class ConfigurationManagerMock: ConfigurationManager {
    private(set) var refreshCallCount: Int = 0
    func refresh() async throws {
        refreshCallCount += 1
    }
    
    var booleanOverride: Bool = false
    private(set) var booleanCallCount: Int = 0
    func boolean(for configuration: Configuration) -> Bool {
        booleanCallCount += 1
        return booleanOverride
    }
    
    var doubleOverride: Double = 0.0
    private(set) var doubleCallCount: Int = 0
    func double(for configuration: Configuration) -> Double {
        doubleCallCount += 1
        return doubleOverride
    }
    
    var integerOverride: Int = 0
    private(set) var integerCallCount: Int = 0
    func integer(for configuration: Configuration) -> Int {
        integerCallCount += 1
        return integerOverride
    }
    
    var stringOverride: String? = nil
    private(set) var stringCallCount: Int = 0
    func string(for configuration: Configuration) -> String? {
        stringCallCount += 1
        return stringOverride
    }
    
    private(set) var stringWithDefaultValueCallCount: Int = 0
    func string(for configuration: Configuration, defaultValue: String) -> String {
        stringWithDefaultValueCallCount += 1
        return defaultValue
    }
}
// ConfigurationProviderMock.swift

@testable import FeatureGateSystem

// MARK: - ConfigurationProviderMock
final class ConfigurationProviderMock: ConfigurationProvider {
    var booleanOverride: Bool = false
    private(set) var booleanCallCount: Int = 0
    func boolean(for configuration: Configuration) -> Bool {
        booleanCallCount += 1
        return booleanOverride
    }
    
    var doubleOverride: Double = 0.0
    private(set) var doubleCallCount: Int = 0
    func double(for configuration: Configuration) -> Double {
        doubleCallCount += 1
        return doubleOverride
    }
    
    var integerOverride: Int = 0
    private(set) var integerCallCount: Int = 0
    func integer(for configuration: Configuration) -> Int {
        integerCallCount += 1
        return integerOverride
    }
    
    var stringOverride: String? = nil
    private(set) var stringCallCount: Int = 0
    func string(for configuration: Configuration) -> String? {
        stringCallCount += 1
        return stringOverride
    }
    
    private(set) var stringWithDefaultValueCallCount: Int = 0
    func string(for configuration: Configuration, defaultValue: String) -> String {
        stringWithDefaultValueCallCount += 1
        return defaultValue
    }
}
// FeatureGateProviderMock.swift

@testable import FeatureGateSystem

// MARK: - FeatureGateProviderMock
final class FeatureGateProviderMock: FeatureGateProvider {
    var isGateOpenOverride: [FeatureGate: Bool] = [:]
    private(set) var isGateOpenCallCount: Int = 0
    func isGateOpen(_ featureGate: FeatureGate) -> Bool {
        isGateOpenCallCount += 1
        return isGateOpenOverride[featureGate] ?? false
    }
}


Leave a Reply

Your email address will not be published. Required fields are marked *