Aug 31, 2022

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:

The 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:

Next is that enums are fully-fledged types; they can be generic, nested, define methods, derived properties, and satisfy protocols (interfaces).

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…

… trying to switch only on a subset of possible values will be marked as an error:

This won’t compile.

This error prevents us from forgetting to take a case into account.

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:

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…

… 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…

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.

FeatureFlag is a ConfigurableValue where we know that the value is either true or false.

ABTest is a ConfigurableValue where the value can be represented by a more primitive (raw) type (usually a String).

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.

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:

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:

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:

For ABTest, we have another default implementation that uses the RawRepresentable conformance to convert the value:

This is getting too theoretical, so let’s see what we can now do.

First up, a simple value:

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:

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:

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:

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:

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:

Here you can see the methods from RemotelyConfigurable being used to convert to the final value. Accessing the property itself returns the current state:

And through the property wrapper, the value can be changed manually:


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.

Featured articles
Generating SwiftUI snapshot tests with Swift macros
Don’t Fix Bad Data, Do This Instead