Feature flag and A/B test management in the iOS app
In bigger apps, there is often a need for using feature flags. As responsible developers, we don't want to depend directly on a third party for this, so we create abstractions on top of the remote data provider. The one we were using wasn't up to the task, so we set out to create a better one.
Hammers and nails
There are many reasons to like programming, but for me, one of the main ones is the ability to model basically anything. I believe this to be the same for many (if not all) programmers — not even the sky is the limit here, just the imagination. That said, it is my experience that programmers tend to overuse certain features regardless of what they work on. This is known as the law of the instrument, — or in common terms, a variation of “If you only have a hammer, everything looks like a nail.” Swift has many such potential hammers, but the one I see overused the most are enums.
Enums in Swift
The reason it’s so tempting to use enums for everything is just how powerful they are. As with many languages with that concept, Swift’s enums are sum types; they can define multiple possible values, but only one can be selected at a time:
enum SearchType {
case oneWay
case roundTrip
case multicity
case nomad
}
let selectedSearchType: SearchType = .multicityThe first feature that makes Swift’s version more interesting is the ability to associate arbitrary values with each case. For example, here is the entirety of the JSON format in 9 lines:
enum JSON {
case null
case bool(Bool)
case int(Int)
case double(Double)
case string(String)
case array([JSON])
case object([String: JSON])
}Next is that enums are fully-fledged types; they can be generic, nested, define methods, derived properties, and satisfy protocols (interfaces).
indirect enum LoadingState<Resource, LoadingError: Error> {
case initial
case loading(previousState: LoadingState)
case loaded(Resource)
case error(LoadingError)
var movedToLoadingState: LoadingState<Resource, LoadingError> {
.loading(previousState: self)
}
var data: Resource? {
switch self {
case .loaded(let resource):
return resource
default:
return nil
}
}
}
extension LoadingState: Equatable where Resource: Equatable, LoadingError: Equatable {}The final great feature of enums in Swift is exhaustivity checking. That is, if you switch on an enum, it is a compiler error to not consider a case. Given the following definition…
enum ItinerarySegmentVisibility {
enum Reason {
case trueHiddenCity
case throwAwayTicket
}
case visible
case hidden(Reason)
}
let visibility: ItinerarySegmentVisibility = .hidden(.throwAwayTicket)… trying to switch only on a subset of possible values will be marked as an error:
switch visibility { // error: Switch must be exhaustive
case .visible:
print("show it!")
}This won't compile.
This error prevents us from forgetting to take a case into account.
switch visibility {
case .visible:
print("show it!")
case .hidden(.trueHiddenCity):
print("hide it because it's a hidden city")
case .hidden(.throwAwayTicket):
print("hide it because it's a throwaway ticket")
}Now it works.
The old feature management system
It is the exhaustivity checking that mainly appeals to programmers. The feature management system started out with good intentions:
final class FeatureConfiguration {
enum Feature: String, CaseIterable {
case combinedDatePicker
}
...It makes sense — when you add a new case to Feature, the compiler forces you to fill in all the switches that are now incomplete, such as…
extension FeatureConfiguration.Feature {
var defaultAvailability: Bool {
switch self {
case .combinedDatePicker: return false
}
}
var isRemotelyConfigurable: Bool {
switch self {
case .combinedDatePicker: return false
}
}
}
extension RemoteConfigurationKey {
init?(feature: FeatureConfiguration.Feature) {
switch feature {
case .combinedDatePicker: return nil
}
}
}… though that middle one is questionable, as it can be derived from the last.
The problem
Over time, the requirements on feature configuration have grown. In particular, in addition to feature flags, we added A/B tests, which are not simple true/false values, but can have multiple different variants. As if that was not enough, we also added simple remote configuration values to this setup (e.g., number of days until expiration). And so, FeatureConfiguration sprouted new limbs, maybe even tentacles…
final class FeatureConfiguration {
enum Feature: String, CaseIterable {
...
case geofenceBannerVariant
...
}
...
}
extension FeatureConfiguration.Feature {
...
var isABTest: Bool {
switch self {
...
case .geofenceBannerVariant: return true
}
}
var abTestEnabledValues: [String] {
switch self {
...
case .geofenceBannerVariant: return ["ab_geofenceBannerVariant1", "ab_geofenceBannerVariant2"]
}
}
}
extension RemoteConfigurationKey {
...
var abTestDefaultValue: String? {
switch self {
...
case .geofenceBannerVariant: return nil
}
}
}That's a lot of switches…
… and that wasn’t even all, but it’s enough to illustrate the point. It was at this moment we realized that this system is getting very hard to maintain. Consider this: those new properties added for A/B tests have no relevance to plain feature flags, yet all must still be added due to the aforementioned exhaustivity checking!
The solution
Fixing this situation was the most requested item on the Core team’s agenda (for iOS of course). Since Swift is a strongly-typed language, the solution turned out to be predictable — make those features into types! This way, all the values related to a single feature could be gathered in a single place and any customization would be done there too, instead of a giant switch statement.
As is often the case in Swift (though often unnecessarily so — the overuse of this technique is another hammer), we created several protocols to describe all possible kinds of features.
ConfigurableValue simply stipulates that this type has some value and that there is a known default.
protocol ConfigurableValue {
associatedtype Value
static var defaultValue: Value { get }
}FeatureFlag is a ConfigurableValue where we know that the value is either true or false.
protocol FeatureFlag: ConfigurableValue where Value == Bool {}ABTest is a ConfigurableValue where the value can be represented by a more primitive (raw) type (usually a String).
protocol ABTest: ConfigurableValue where Value: RawRepresentable {}Finally, RemotelyConfigurable adds the ability to source values from remote config — Firebase in our case — by defining the actual remote key and methods to turn a primitive value into the type we set for the feature.
protocol RemotelyConfigurable {
associatedtype RemoteConfigPrimitiveValue
associatedtype Value
static var remoteConfigurationKey: String { get }
static func extractPrimitive(
from remoteConfigValue: RemoteConfiguration.RemoteValueWrapper
) -> RemoteConfigPrimitiveValue?
static func value(from remoteValue: RemoteConfigPrimitiveValue) -> Value?
}These protocols give us a set of constraints to express simple values, feature flags and A/B tests, all of which may or may not be fed by remote config (local values can be useful during development if remote config is not yet set up, or just for debugging).
But describing features with just this would require a lot of repetitive code. Luckily, we can utilize some nice Swift features to get around that.
First, we can eliminate the need for implementing the extractPrimitive(from:) method, given that we know what types we can expect from remote config. In our app, for all the dozens of features we have, it’s always been one of these four — a string, a boolean, an integer, or a double — indeed, these are the only values we extract from Firebase:
extension RemoteConfiguration {
struct RemoteValueWrapper {
var stringValue: String?
var boolValue: Bool
var intValue: Int?
var doubleValue: Double?
}
}A missing bool is always false
The only thing this method needs to do is to pick which one fits the specified types. We can do this by using a conditional conformance combined with a default implementation in a protocol extension:
extension RemotelyConfigurable where RemoteConfigPrimitiveValue == String {
static func extractPrimitive(
from remoteConfigValue: RemoteConfiguration.RemoteValueWrapper
) -> RemoteConfigPrimitiveValue? {
remoteConfigValue.stringValue
}
}Now all RemotelyConfigurable types whose primitive value is String will be able to use this implementation instead of having to write this code for each one.
Using the same technique, we can conditionally provide an implementation of the second method, provided the final value and the primitive one is the same by passing through the remote value:
extension RemotelyConfigurable where Value == RemoteConfigPrimitiveValue {
static func value(from remoteValue: RemoteConfigPrimitiveValue) -> Value? {
remoteValue
}
}For ABTest, we have another default implementation that uses the RawRepresentable conformance to convert the value:
extension RemotelyConfigurable where Self: ABTest, Value.RawValue == RemoteConfigPrimitiveValue {
static func value(from remoteValue: RemoteConfigPrimitiveValue) -> Value? {
Value(rawValue: remoteValue)
}
}This is getting too theoretical, so let’s see what we can now do.
First up, a simple value:
struct PartnerParameterExpirationHours: ConfigurableValue, RemotelyConfigurable {
static let defaultValue = 24
static let remoteConfigurationKey = "partner_parameter_expiration_hours"
}This is all the code needed to describe this value that will either come from feature config, or default to 24 hours.
Feature flags are similarly short:
struct IsSignInWithAppleEnabled: FeatureFlag, RemotelyConfigurable {
static let defaultValue = true
static let remoteConfigurationKey = "is_sign_in_with_apple_enabled"
}Finally, let’s talk about A/B tests. Earlier I mentioned that they are different from other features because they can have multiple different variants. We have already explored such a construct in Swift — the enum. We can use an enum to describe all these values, such as in this example with our A/B test on card scanning, where we’re evaluating whether a third party library performs better than a native solution:
struct BookingCardScanningVariant: ABTest, RemotelyConfigurable {
enum Value: String, CaseIterable {
case none = "ab_bookingCardScanningNone"
case native = "ab_bookingCardScanningNative"
case sdk = "ab_bookingCardScanningSdk"
}
static let defaultValue: Value = .none
static let remoteConfigurationKey = "ab_bookingCardScanning"
}That’s all there is to defining features in this new system!
Using the values
Having the features defined as all well and good, but it says nothing about how these types are actually used. We need a way to store the current value — the default, the value from remote config, or even a local override.
The solution, as always, is more types!
Actually just one — some kind of storage for the value connected with the feature itself. We use Swift’s property wrappers to accomplish this with the final result looking something like this:
final class FeatureConfiguration {
@FeatureToggle<BookingCardScanningVariant> var bookingCardScanningVariant
...
}In order for the wrapper to determine the current value, it needs a way to access the remote configuration. We could somehow assign it during initialization, or even afterward, but both of those options would force us to write ugly and repetitive code. Instead, we can use the unofficial static subscript on property wrappers that allows us to access the type the wrapper is in. This is what FeatureToggle looks like:
@propertyWrapper
final class FeatureToggle<Feature> where Feature: ConfigurableValue & RemotelyConfigurable {
enum State {
case local(Feature.Value)
case remote
}
@available(*, unavailable)
var wrappedValue: Feature.Value {
fatalError("Unreachable")
}
var projectedValue: FeatureToggle {
self
}
private(set) var state: State = .remote
static subscript<EnclosingSelf: RemoteConfigurationAccessing>(
_enclosingInstance observed: EnclosingSelf,
wrapped _: KeyPath<EnclosingSelf, Feature.Value>,
storage storageKeyPath: KeyPath<EnclosingSelf, FeatureToggle>
) -> Feature.Value {
observed[keyPath: storageKeyPath].value(in: observed)
}
func setToLocal(_ value: Feature.Value) {
state = .local(value)
}
func setToRemote() {
state = .remote
}
func value<Wrapper: RemoteConfigurationAccessing>(in wrapper: Wrapper) -> Feature.Value {
switch state {
case .local(let value): return value
case .remote: return wrapper.remoteConfigValue(for: Feature.self)
}
}
}The wrapper sets the state to either remote or local, where an override is provided. When set to remote, the enclosing type is queried for that value, otherwise, the local override is returned.
The final part is the conformance of FeatureConfiguration to RemoteConfigurationAccessing:
extension FeatureConfiguration: RemoteConfigurationAccessing {
func remoteConfigValue<Feature>(
for _: Feature.Type
) -> Feature.Value where Feature: ConfigurableValue, Feature: RemotelyConfigurable {
let remoteValueWrapper = remoteConfiguration.remoteConfigValue(forKey: Feature.remoteConfigurationKey)
guard let primitiveValue = Feature.extractPrimitive(from: remoteValueWrapper),
let finalValue = Feature.value(from: primitiveValue)
else {
return Feature.defaultValue
}
return finalValue
}
}Here you can see the methods from RemotelyConfigurable being used to convert to the final value. Accessing the property itself returns the current state:
let configuration = FeatureConfiguration()
let variant = configuration.bookingCardScanningVariant // BookingCardScanningVariant.Value.nativeAnd through the property wrapper, the value can be changed manually:
configuration.$bookingCardScanningVariant.setToLocal(.sdk)Conclusion
There are other parts of this system, like logging and the integration with our debug menu, but this is all for today.
While it may look like the new system is more complicated, it’s not that difficult to understand. Even better, a developer wanting to simply add or remove a feature doesn’t need to know most of the implementation — they just need to worry about one type.