Skip to main content
Marko Engelman
All posts

Feature Flags - Ship Anytime with Trunk-Based Development

  • Clean Architecture
  • Composition
  • iOS
  • Swift
  • Feature Flags

In this article I will show you how we use feature flags to ship an iOS app at any time, without waiting for every feature to pass QA. I will also show you a small piece of design that makes the hardest decision in the whole process - what to do with a flag once its feature is tested - a one-line change.

When AI generates features faster than ever, the question is no longer how quickly we can write code, but how quickly we can safely ship it.

Let’s dive in.

The problem

Let’s say that two teams are working on the same app. One is building a new home screen and the other is working on a new search. Both features live on their own feature branches and both need to pass QA before they can be released.

Now the home screen passes QA, but search doesn’t. What should we do?

We can wait until search is fixed, which means that a finished feature is blocked by an unfinished one. Or we can try to cherry-pick the home screen into a release branch and hope that nothing depends on the code that we left behind. Neither of them is great, and the longer the branches live, the more painful and stressful merging becomes.

What we really want is to be able to release at any time, with whatever is ready.

Feature flags and trunk-based development

The idea behind trunk-based development is simple - everyone merges small changes to main often, and branches are short-lived. For this to work, main must be always releasable, even when it contains code of a feature that is not finished yet.

That is where feature flags come in. A feature flag is a switch in the code that decides if a feature is on or off. If you want to read more about different kinds of them, Martin Fowler’s article is a great place to start.

We merge unfinished work to main switched off. Users never see it, but QA can switch it on and test it, and the rest of the team doesn’t have to wait for them.

AppFeature and AppFeaturePolicy

Before we jump to flags themselves, let’s decide how the rest of the app will ask about them. I suggest that nobody in the app knows where the answer is coming from.

First we define features that our app has, using domain names.

enum AppFeature: CaseIterable {
    case homeScreenRedesign
    case search
}

And a policy that answers one question - is this feature available?

protocol AppFeaturePolicy {
    func isFeatureAvailable(_ feature: AppFeature) -> Bool
}

Using it looks like this. The place where the app decides which home screen to show only asks the policy.

func makeHomeView(policy: AppFeaturePolicy) -> UIViewController {
    if policy.isFeatureAvailable(.homeScreenRedesign) {
        return NewHomeViewController()
    } else {
        return HomeViewController()
    }
}

Notice that the code above has no idea about UserDefaults, remote config or QA. It also doesn’t change for the rest of the feature’s life. Keep that in mind, we will come back to it.

Two sources of answers

Our policy will have two possible sources. The first one is a local switcher, used while the feature is still in QA.

protocol LocalFeatureSwitcher {
    func isEnabled(_ feature: AppFeature) -> Bool
}

An implementation can keep values in UserDefaults and return false for everything in production builds. QA flips the switch on a small debug screen and the feature is on for them, and only for them.

The second one is a remote flag service. In our app we use ConfigCat, but any provider that gives you a value for a key would do.

protocol RemoteFlagService {
    func value(for key: String, defaultValue: Bool) -> Bool
}

Both are combined in the implementation of our policy. Every feature decides in one place which source answers for it.

final class DefaultAppFeaturePolicy: AppFeaturePolicy {
    let local: LocalFeatureSwitcher
    let remote: RemoteFlagService

    init(local: LocalFeatureSwitcher, remote: RemoteFlagService) {
        self.local = local
        self.remote = remote
    }

    func isFeatureAvailable(_ feature: AppFeature) -> Bool {
        switch feature {
        case .homeScreenRedesign:
            /* Still in QA, so only the local switcher can turn it on */
            return local.isEnabled(.homeScreenRedesign)

        case .search:
            /* Released, but controlled in production. If the provider can't be reached the feature stays off */
            return remote.value(for: "isSearchEnabled", defaultValue: false)
        }
    }
}

Since switch has to be exhaustive, adding a new AppFeature forces us to decide where its answer comes from. We can’t forget it.

How to use flags - real examples

A flag is just a question that we can ask anywhere in the app. It doesn’t have to be about big things only - it can swap a whole screen, but it can also change how a small piece of the app behaves. Let’s have a look at three examples.

1. Swapping the UI

This is the example that we already saw. The flag is checked once, where the screen is composed, and decides which whole screen the user gets. It could just as well be an entire flow or a tab.

func makeHomeView(policy: AppFeaturePolicy) -> UIViewController {
    if policy.isFeatureAvailable(.homeScreenRedesign) {
        return NewHomeViewController()
    } else {
        return HomeViewController()
    }
}

Both screens are complete and neither of them knows about the flag. Removing it later means changing this one place.

2. Changing behaviour in a view model

But sometimes the new feature is not a new screen. Let’s say that search should start showing suggestions while the user is typing, instead of only the user’s recent searches. We add searchSuggestions to AppFeature (and the compiler asks us to add it to the policy’s switch as well) and inject the policy into the view model.

final class SearchViewModel {
    private let policy: AppFeaturePolicy
    private let suggestionsLoader: SuggestionsLoader
    private let history: SearchHistory

    @Published private(set) var suggestions: [String] = []

    init(policy: AppFeaturePolicy, suggestionsLoader: SuggestionsLoader, history: SearchHistory) {
        self.policy = policy
        self.suggestionsLoader = suggestionsLoader
        self.history = history
    }

    func queryDidChange(_ query: String) {
        if policy.isFeatureAvailable(.searchSuggestions) {
            /* New behaviour - suggestions based on what the user is typing */
            suggestions = suggestionsLoader.load(for: query)
        } else {
            /* Previous behaviour - recent searches */
            suggestions = history.recentSearches()
        }
    }
}

The UI doesn’t change at all - the view just shows suggestions, and the flag decides where they come from.

3. Choosing a data source in a use case

Flags can also change what the app does, not only what it shows. Let’s say that a use case loads articles from local storage and we are building a new way to load them from the API. Both loaders conform to the same ArticlesLoader protocol, so the use case can decide between them - and we merge the API work to main long before it is ready for users.

final class LoadArticlesUseCase {
    private let policy: AppFeaturePolicy
    private let remoteLoader: ArticlesLoader
    private let localLoader: ArticlesLoader

    init(policy: AppFeaturePolicy, remoteLoader: ArticlesLoader, localLoader: ArticlesLoader) {
        self.policy = policy
        self.remoteLoader = remoteLoader
        self.localLoader = localLoader
    }

    func load() async throws -> [Article] {
        if policy.isFeatureAvailable(.articlesFromAPI) {
            /* New behaviour - fetch articles from the API */
            return try await remoteLoader.load()
        } else {
            /* Previous behaviour - read articles from local storage */
            return try await localLoader.load()
        }
    }
}

Nobody who calls load() knows which source answered, and if the API turns out to have problems, a remote flag can send the users back to local data without a new release.

All of these use the same policy, the same QA switch and the same way of handing the flag to a remote provider. Flags are easy to add anywhere, but the more places that ask about them, the more places we need to clean up later - so I suggest that you remove them as soon as you can.

After QA

Now the interesting part. The home screen passed QA, so we need to decide what to do with its flag. There are only two options and the answer depends on one question - do we need to control this feature in production?

If we don’t, we delete the flag. The new home screen is simply the home screen now. We remove the case from AppFeature, remove the branch from the policy and the compiler shows us every place that still asks about it.

If we do - because we want a gradual rollout, an A/B test or a way to turn it off without releasing a new version - we hand it to the remote provider. And this is where the design from before pays off.

case .homeScreenRedesign:
    return remote.value(for: "isNewHomescreenEnabled", defaultValue: false)

That’s it. One line in the policy and not a single call site changes. makeHomeView doesn’t know that the answer used to come from QA’s switch and now comes from a dashboard. If the feature is disabled in ConfigCat, it is disabled for users, with no new release.

Later, when the rollout is done or the experiment is over, we delete the flag too. Flags that stay forever are just if statements that nobody dares to touch.

Even more important with AI

When a feature takes less time to write, we don’t end up with fewer things to ship - we end up with more of them in parallel. More branches, more changes waiting for review and more features waiting for QA. Writing code stops being the bottleneck and verifying and releasing it takes its place.

Feature flags separate the two moments that used to be one - merged and released. Code can land on main as soon as it is ready, switched off, while each feature is verified and released on its own schedule. With long-lived branches, a faster pace only means bigger merges and bigger stress.

Conclusion

We have seen how feature flags help us keep main releasable and let features ship independently - when one feature passes QA we can release without waiting for the others, and the other way around. We also hid the source of the answer behind a small policy, so the decision after QA - delete the flag or control it remotely - never reaches the rest of the app. It works for a whole new screen as well as for a change of behaviour deep inside a view model or a use case.

For us the result was less stress when merging and much easier parallel work among teams.

I hope that you enjoyed this article and learned something new!