Working with feature toggles in Android
Working with feature toggles in Android

Working with feature toggles in Android

Tags
Android
Kotlin
Software Development
Advanced
Published
Author
Ümit AYDIN
 

📚 Introduction

Feature toggles, also known as feature flags, are essential mechanisms in modern software development. They empower you to dynamically control the behavior of your Android application without requiring app store updates. This flexibility offers a multitude of advantages:
  • Gradual Rollouts: Progressively introduce new features to a limited user base for testing and feedback before wider deployment.
  • A/B Testing: Experiment with different functionalities or UI variations to determine optimal user experiences.
  • Emergency Disabling: Quickly disable problematic features in production environments to mitigate issues.
  • Configuration Management: Simplify app behavior based on user roles, device types, or other criteria.

🛠️ Setup

 
I used Android Studio Jellyfish Canary 12 at the time of writing. I added build pipeline along with dependabot. Therefore, I’m expecting it to be updated as dependencies upgraded. Clone the project and open in Android Studio.
 
biz.aydin.toggle - domain : I keep business logic - presentation : I keep UI related logic - service : API calls, device functionality in this layer

🖥️ UI

📌 Current state

@Composable fun HomeScreen( message: String, newUIToggle: NewUIToggle ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Greeting(message = message) } }

➕ Adding a feature toggle

Let’s assume your team has decided to use a feature toggle for the upcoming feature. As a good practice, I've also scheduled its removal – remember, not all toggles are permanent.
While if statements offer an easy way to conditionally display UI elements based on the toggle state, overusing them can lead to complex and hard-to-maintain code. Let's be mindful of this potential pitfall and strive for clean, maintainable code.
 
interface FeatureToggle { fun getToggle(key: String, default: Boolean): Boolean } // Prone to error @Composable fun HomeScreen( message: String, toggleService: FeatureToggle ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { val message = if(toggleService.getToggle(key="", default=false)) { "NEW MESSAGE" } else { "OLD MESSAGE" } val color = if(toggleService.getToggle(key="", default=false)) { Color.Green } else { Color.Red } Text( text = message, modifier = modifier, color = color ) } }
 
Removing feature toggles later requires manual verification and a full understanding of the feature's context. Unfortunately, by then, this context might be lost or, even worse, the developer who implemented the UI might no longer be available. This can lead to significant hidden costs, as additional time will be needed to decipher the logic behind the code changes.
 
// Less error prone example // HomeScreen.kt @Composable fun HomeScreen( message: String, newUIToggle: NewUIToggle ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { newUIToggle { on { NewGreeting(message = message) } off { OldGreeting(message = message) } } } } // NewGreetings.kt @Composable fun NewGreeting( modifier: Modifier = Modifier, message: String ) { Text( text = message, modifier = modifier.then(Modifier.testTag("HOME_SCREEN_NEW_GREETINGS")), color = Color.Green ) } // OldGreetings.kt @Composable fun OldGreeting( modifier: Modifier = Modifier, message: String ) { Text( text = message, modifier = modifier.then(Modifier.testTag("HOME_SCREEN_OLD_GREETINGS")), color = Color.Red ) } // Android Test HomeScreenTest.kt @Test fun `Given NewUIToggle is removed when presenting HomeScreen then it displays NewGreetings`() { with(rule) { setContent { BuildHomeScreen() } onNodeWithTag("HOME_SCREEN_NEW_GREETINGS").assertIsDisplayed() onNodeWithTag("HOME_SCREEN_OLD_GREETINGS").assertDoesNotExist() } }
 
This approach addresses the drawbacks mentioned earlier. By implementing automated tests specifically for toggle removal, developers won't need to delve deep into the code's intricacies during this process. This frees up valuable time for them to focus on building new features and incorporating more toggles when needed.

➖ Removing the feature toggle

Removing toggle requires two commits. The first commit, we remove the toggle NewUIToggle from the codebase, and commit.
 
// Less error prone example // HomeScreen.kt @Composable fun HomeScreen( message: String, newUIToggle: NewUIToggle // Removing ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { newUIToggle { // Removing on { // Removing NewGreeting(message = message) } // Removing off { // Removing OldGreeting(message = message) // Removing } // Removing } } } // NewGreetings.kt @Composable fun NewGreeting( modifier: Modifier = Modifier, message: String ) { Text( text = message, modifier = modifier.then(Modifier.testTag("HOME_SCREEN_NEW_GREETINGS")), color = Color.Green ) } // OldGreetings.kt // Removing entire function @Composable fun OldGreeting( modifier: Modifier = Modifier, message: String ) { Text( text = message, modifier = modifier.then(Modifier.testTag("HOME_SCREEN_OLD_GREETINGS")), color = Color.Red ) } // Android Test HomeScreenTest.kt @Test fun `Given NewUIToggle is removed when presenting HomeScreen then it displays NewGreetings`() { with(rule) { setContent { BuildHomeScreen() } onNodeWithTag("HOME_SCREEN_NEW_GREETINGS").assertIsDisplayed() onNodeWithTag("HOME_SCREEN_OLD_GREETINGS").assertDoesNotExist() } }
 
In the second commit, we use Android Studio rename tool to rename NewGreeting into Greeting. You could argue that you can do this in one commit, and I would say don’t trust your brain. Here is why.
 
Video preview
 
// Less error prone example // HomeScreen.kt @Composable fun HomeScreen( message: String ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Greeting(message = message) } } // NewGreetings.kt @Composable fun Greeting( // renamed from NewGreeting to Greeting modifier: Modifier = Modifier, message: String ) { Text( text = message, modifier = modifier.then(Modifier.testTag("HOME_SCREEN_NEW_GREETINGS")), color = Color.Green ) } // Android Test HomeScreenTest.kt @Test fun `Given NewUIToggle is removed when presenting HomeScreen then it displays NewGreetings`() { with(rule) { setContent { BuildHomeScreen() } onNodeWithTag("HOME_SCREEN_NEW_GREETINGS").assertIsDisplayed() onNodeWithTag("HOME_SCREEN_OLD_GREETINGS").assertDoesNotExist() } }
 
This test remains completely unaffected, even after removing the toggle and renaming the function. This demonstrates the effectiveness of our approach, allowing you to confidently proceed with the deletion and renaming steps.
You can run ./gradlew connectedDebugAndroidTest in order to verify changes 👀

🔌 Business Logic / Domain

📌 Current state

class GreetingsViewModel( private val getGreeting: GetGreetingUseCase ) { val message: String get() = getGreeting() }

➕ Adding a new feature toggle

To control the rollout of the upcoming feature update, we'll utilize a feature toggle created on our preferred platform. Following best practices, we'll also schedule its removal later – not all toggles are meant to be permanent.
 
// Error prone example class GetGreetingMessageUseCase( private val featureToggle: FeatureToggle, private val greetings: Greetings ) { operator fun invoke(): String { return if (featureToggle.getToggle(key = "NEW_GREETINGS_UI_FEATURE", default = false)) { "NEW USE CASE MESSAGE " + greetings.getGreetingMessage() } else { "OLD USE CASE MESSAGE " + greetings.getGreetingMessage() } } }
 
While this example demonstrates feature toggles in the business logic layer, it's a simplified case. As complexity grows, the code becomes more prone to errors. Let's explore better approaches for managing feature toggles within business logic.
 
// Less error prone example // GreetingsViewModel.kt class GreetingsViewModel( private val getOldGreeting: GetOldGreetingUseCase, private val getNewGreeting: GetNewGreetingUseCase, private val newMessageToggle: NewMessageToggle ) { val message: String get() = newMessageToggle { on { getNewGreeting() } off { getOldGreeting() } } } // GetNewGreetingUseCase.kt class GetNewGreetingUseCase( private val greetings: Greetings ) { operator fun invoke(): String { return "NEW USE CASE MESSAGE " + greetings.getGreetingMessage() } } // GetOldGreetingUseCase.kt class GetOldGreetingUseCase( private val greetings: Greetings ) { operator fun invoke(): String { return "OLD USE CASE MESSAGE " + greetings.getGreetingMessage() } } // GreetingsViewModelTest.kt class GreetingsViewModelToggleRemovalTest { private lateinit var viewModel: GreetingsViewModel private val getOldGreeting: GetOldGreetingUseCase = mockk() private val getNewGreeting: GetNewGreetingUseCase = mockk() private val featureToggle: FeatureToggle = mockk() @Before fun before() { viewModel = GreetingsViewModel( getOldGreeting = getOldGreeting, getNewGreeting = getNewGreeting, newMessageToggle = NewMessageToggle(featureToggle = featureToggle) ) every { featureToggle.getToggle("NEW_GREETING_MESSAGE", any()) } returns true } @Test fun `Given NewMessageToggle is removed, when getting the message, then it executes GetNewGreetingUseCase`() { every { getNewGreetingUseCase() } returns "MESSAGE" assertThat(viewModel.message).isEqualTo("MESSAGE") verify(exactly = 1) { getNewGreeting() } } }
 
By strategically introducing temporary redundant code and automated tests, we can significantly improve the efficiency of removing feature toggles. This approach clarifies the code logic and boosts confidence throughout the removal process.

➖ Removing the toggle

Similar to UI toggle, first thing is to remove the existing toggle, then commit.
 
// GreetingsViewModel.kt class GreetingsViewModel( private val getOldGreeting: GetOldGreetingUseCase, // Removing private val getNewGreeting: GetNewGreetingUseCase, private val newMessageToggle: NewMessageToggle // Removing ) { val message: String get() = newMessageToggle { // Removing on { // Removing getNewGreeting() } // Removing off { // Removing getOldGreeting() // Removing } // Removing } // Removing } // GetNewGreetingUseCase.kt class GetNewGreetingUseCase( private val greetings: Greetings ) { operator fun invoke(): String { return "NEW USE CASE MESSAGE " + greetings.getGreetingMessage() } } // GetOldGreetingUseCase.kt // Removing class GetOldGreetingUseCase( // Removing private val greetings: Greetings // Removing ) { // Removing operator fun invoke(): String { // Removing return "OLD USE CASE MESSAGE " + greetings.getGreetingMessage() // Removing } // Removing } // Removing // GreetingsViewModelToggleRemovalTest.kt class GreetingsViewModelToggleRemovalTest { private lateinit var viewModel: GreetingsViewModel private val getOldGreeting: GetOldGreetingUseCase = mockk() // Removing private val getNewGreeting: GetNewGreetingUseCase = mockk() private val featureToggle: FeatureToggle = mockk() // Removing @Before fun before() { viewModel = GreetingsViewModel( getOldGreeting = getOldGreeting, // Removing getNewGreeting = getNewGreeting, newMessageToggle = NewMessageToggle(featureToggle = featureToggle) // Removing ) every { featureToggle.getToggle("NEW_GREETING_MESSAGE", any()) } returns true // Removing } @Test fun `Given NewMessageToggle is removed, when getting the message, then it executes GetNewGreetingUseCase`() { every { getNewGreeting() } returns "MESSAGE" assertThat(viewModel.message).isEqualTo("MESSAGE") verify(exactly = 1) { getNewGreeting() } } }
 
As you can see, the test remains identical before and after the code modification. This consistency demonstrates the effectiveness of our approach and allows us to confidently proceed with the next step: renaming the new behavior to maintain a clean codebase.
// GreetingsViewModel.kt class GreetingsViewModel( private val getGreeting: GetGreetingUseCase ) { val message: String get() = getGreeting() } // GetNewGreetingUseCase.kt class GetGreetingUseCase( private val greetings: Greetings ) { operator fun invoke(): String { return "NEW USE CASE MESSAGE " + greetings.getGreetingMessage() } } // GreetingsViewModelToggleRemovalTest.kt class GreetingsViewModelToggleRemovalTest { private lateinit var viewModel: GreetingsViewModel private val getNewGreeting: GetNewGreetingUseCase = mockk() @Before fun before() { viewModel = GreetingsViewModel( getNewGreeting = getNewGreeting ) } @Test fun `Given NewMessageToggle is removed, when getting the message, then it executes GetNewGreetingUseCase`() { every { getNewGreeting() } returns "MESSAGE" assertThat(viewModel.message).isEqualTo("MESSAGE") verify(exactly = 1) { getNewGreeting() } } }

🏭 Service

Similar to the previous example, feature toggles can be effectively utilized within the service layer. The core principle remains the same: we strategically introduce the toggle for controlled rollout and then methodically remove it later to maintain clean code.

📌 Current state

class GreetingsService: Greetings { override fun getGreetingMessage(): String { return "NEW GET SERVICE MESSAGE" } }

➕ Adding a new feature toggle

The first step involved extracting the core logic from the getGreetingMessage() method into a new, private function named oldGet().
To enable flexible control using a feature toggle, the oldGet() function was then duplicated and renamed to newGet().
To ensure safe removal of the toggle, a test is added.
The final code, incorporating these changes, is shown below:
 
class GreetingsService( private val newServiceToggle: NewServiceToggle ) : Greetings { override fun getGreetingMessage(): String { return newServiceToggle { on { newGet() } off { oldGet() } } } private fun oldGet(): String { return "OLD GET SERVICE MESSAGE" } private fun newGet(): String { return "NEW GET SERVICE MESSAGE" } } class GreetingsServiceToggleRemovalTest { private val greetingsService: GreetingsService = GreetingsService( newServiceToggle = NewServiceToggle( object : FeatureToggle { override fun getToggle(key: String, default: Boolean): Boolean { return true } } ) ) @Test fun `Given NewServiceToggle is remove, when getting message, then it returns new message`() { assertThat(greetingsService.getGreetingMessage()).isEqualTo("NEW GET SERVICE MESSAGE") } }

➖ Removing the feature toggle

The first step in toggle removal involves deleting the code. Once complete, commit these changes.
 
class GreetingsService( private val newServiceToggle: NewServiceToggle // Removing ) : Greetings { override fun getGreetingMessage(): String { return newServiceToggle { // Removing on { // Removing newGet() } // Removing off { // Removing oldGet() // Removing } // Removing } // Removing } private fun oldGet(): String { // Removing return "OLD GET SERVICE MESSAGE" // Removing } // Removing private fun newGet(): String { return "NEW GET SERVICE MESSAGE" } } class GreetingsServiceToggleRemovalTest { private val greetingsService: GreetingsService = GreetingsService( newServiceToggle = NewServiceToggle( // Removing object : FeatureToggle { // Removing override fun getToggle(key: String, default: Boolean): Boolean { // Removing return true // Removing } // Removing } // Removing ) // Removing ) @Test fun `Given NewServiceToggle is remove, when getting message, then it returns new message`() { assertThat(greetingsService.getGreetingMessage()).isEqualTo("NEW GET SERVICE MESSAGE") } }
 
Now put the newGet() body back to where it should be (or you can leave as it is, just rename the function into something meaningful).
class GreetingsService: Greetings { override fun getGreetingMessage(): String { return "NEW GET SERVICE MESSAGE" } } class GreetingsServiceToggleRemovalTest { private val greetingsService: GreetingsService = GreetingsService() @Test fun `Given NewServiceToggle is remove, when getting message, then it returns new message`() { assertThat(greetingsService.getGreetingMessage()).isEqualTo("NEW GET SERVICE MESSAGE") } }
 
Once code cleaning is completed, then you can run ./gradlew build connectedDebugAndroidTest. That’s it! 👏🏻 You can release your app again with peace of mind.

📚Conclusion

You've identified my use of redundant code and automated tests. This approach ensures the code remains bug-free even after feature toggle removal.
While feature toggles offer advantages, they can lead to maintenance difficulties if not managed properly. Remember, adding code is usually simpler than maintaining existing code. The real challenge lies in keeping the codebase clean and understandable over time.
If you enjoy reading this, please follow me on LinkedIn