I’ve been using feature gates for a long time. Having the ability to conditionally alter the behavior of an app unlocks so many benefits that I wouldn’t go back.

I love being able to have a consistent release train by keeping in-progress features locked behind a gate, being able to validate the utility and impact of the features I work on using A/B testing, and being able to mitigate the risk of rolling out new features. But feature gates also come with downsides, especially as a codebase and organization ages.

Feature gates are temporary

If I’m replacing v1 with v2 and want the benefits that feature gates offer that means that while v2 is being rolled out both versions of the code need to exist simultaneously in my codebase. Once the roll out is complete that old code needs to be removed, along with the gating logic that determines which variant to use, or it will quickly become a liability, introducing risk and slowing down the team.

The first, obvious solution to ensuring this old code is actually removed is by planning for this code cleanup as part of the development lifecycle and prioritizing it, but that’s not always easy. Since the rollout phase is often spent monitoring and measuring, by the time the rollout is complete and v1 can be cleaned up the engineer has often moved on to other things. Or perhaps there’s been a reorg within the team and ownership has changed.

While I’ve never found a way to make this kind of code cleanup fun or exciting, the best way I’ve found to increase the liklihood of this work getting done is by making it easier.

Hard to remove gates

At their core, feature gates allow you to put conditional statements in your code. A common approach that I’ve seen in production code is to simply add if/else statements wherever the behavior needs to be different.

class RealAccountRepository(
  val oldAccountStore: OldAccountStore,
  val newAccountStore: NewAccountStore,
  val featureGates: FeatureGates,
) : AccountRepository {
  override fun getAllAccounts(): Flow<List<Account>> {
    val flow = if (featureGates.isNewLoginEnabled()) {
      ...
    } else {
      ...
    }
    return flow.map { ... }
  }

  override suspend fun updateAccount(id: String, name: String) {
    validate(...)
    doStuff(...)

    if (featureGates.isNewLoginEnabled()) {
      ...
    } else {
      ...
    }
  }
}

While this might seem like the obvious way to change behavior based on a feature gate, once the rollout is complete this gate can be really hard to clean up. Removing the legacy implementation requires an understanding of potentially complex interaction between multiple components, adding risk.

When I’m writing or reviewing feature gated code, I look for ways to hide the feature gate as much as possible. I want the code to look like the code I would write if there wasn’t any gating, making it as close as possible to the code that I want after the gate is no longer needed. This often means looking for way to have the fewest number of branch statements in the code.

These kinds of decisions about which version of a thing should be used are exactly what interfaces and Dagger modules are made for! Even when using tools like Anvil, which help abstract away some of the common, boiler-plate use cases for Dagger modules, making decisions about which implementation of a dependency to use is a perfect use case.

In this example, we can copy, paste and alter our code so that we have two distinct implementations which each contain no feature gating logic, and we can use a Dagger module to decide which implementation to use.

class OldAccountRepository(
  val oldAccountStore: OldAccountStore,
) : AccountRepository {
  override fun getAllAccounts(): Flow<List<Account>> {
    ...
  }

  override suspend fun updateAccount(id: String, name: String) {
    validate(...)
    ...
  }
}

class NewAccountRepository(
  val newAccountStore: NewAccountStore,
) : AccountRepository {
  override fun getAllAccounts(): Flow<List<Account>> {
    ...
  }

  override suspend fun updateAccount(id: String, name: String) {
    validate(...)
    ...
  }
}

@Module
@ContributesTo(AppScope::class)
class AccountModule {
  @Provide fun provideAccountRepo(
    oldRepo: Provider<OldAccountRepository>,
    newRepo: Provider<NewAccountRepository>,
    featureGates: FeatureGates,
  ): AccountRepository = if (featureGates.isNewLoginEnabled()) {
    newRepo.get()
  } else {
    oldRepo.get()
  }
}

Not only is this code easier to read, but this kind of Dagger module is extremely easy to test since it’s just a factory function. More importantly, when this feature is finished rolling out the legacy code is extremely easy to remove, since it amounts to deleting a class and it’s references.

What about the view layer

While this real-world example appears simple, partly because it’s confined to the data layer, this same approach can work in the view layer of an app. For non-trivial iteration or feature work, putting branching logic within the view layer often leads to code that is hard to reason about and gets complex quickly.

I often employ a trick I learned from a 2018 talk from Jesse Wilson, Writing Code That Lasts Forever, and start by simply copying and pasting the existing Activity, Fragment, View, Composable or whatever, and putting the feature gate check in navigational or creation logic.

I walk up the call stack that leads to the screen in question looking for the place that will allow me to reduce the feature gate check to a single branch, then copy and paste the bits underneath to implement the new code. While this does result in temporary duplication of code, it makes it easy to simply delete the old code when the feature gate is no longer needed, increasing the chances of that work getting completed.

Make things easy

This is just one example of something I’ve encountered at work recently, but the underlying principle isn’t confined to feature gates. Ultimately, the idea is simply that easier things are more likely to get done. There are other approaches to ensuring that the boring work gets done, but I prefer to start by asking the question “How can this be easier.”