Navigating Hyrum’s Law: Managing Changes in APIs in Android App Development
Introduction
In the fast-paced world of software development, APIs play a pivotal role in enabling seamless communication between different components of the app. When crafting these interfaces, developers often strive to provide reliable contracts and well-documented functionalities to their consumers. However, as the number of consumers grows, Hyrum’s Law comes into play. In this blog post, we will explore the implications of Hyrum’s Law and discuss best practices for managing changes in APIs in Android app development.
The Law of Unintended Dependencies
Hyrum’s Law, coined by Hyrum Wright, an engineer at Google, highlights an important aspect of software engineering.
With a sufficient number of users of an API, it does not matter what you promise in the contract: all observable behaviors of your system will be depended on by somebody
As APIs and interfaces gain popularity and attract a large user base, developers come to rely on specific observable behaviors, even those unintended or not initially documented. While developers may have had good intentions while creating the interfaces, future changes to these widely-used components can lead to unexpected consequences for those who have come to depend on certain behaviors. Breaking these dependencies might disrupt existing implementations, cause crashes, or introduce hard-to-diagnose issues.
Best Practices for Managing Changes
I’d like to illustrate this with a real-world example from an app I’ve been working on, which is deployed across over 50+ countries. Each country has its own configuration parameters, such as language, version, API endpoint, etc. Initially, I implemented a solution using SharedPreference in an interface called AppConfiguration
, located in a common module with several other functionalities.
Regrettably, the AppConfiguration
interface is now considered legacy code, as it was written quite a while ago. Over time, due to pressing deadlines, new features were hastily added to the interface without proper consideration for future scalability.
The interesting twist is that the interface is now used in over 20 sections of the app, with some features even rendering based on the details fetched from it. Eventually, complaints about the AppConfiguration interface started to surface. To be precise, we were faced with the following issues:
- The current interface violates the Single Responsibility Principle. Our team aims to modularize the entire app for better maintainability and scalability.
- All configuration details have been designated as nullable. This strays from the YAGNI (You Aren’t Gonna Need It) principle as the nullability was not a requirement here, causing developers to write custom nullability checks unnecessarily.
- There’s a bug in the method we use to extract one of the configurations.
Here’s where Hyrem’s Law comes into play: What happens when we try to address these issues and change the existing AppConfiguration? Since this legacy interface has been the standard for other working parts of the app, any changes we make could potentially introduce regression risks.
So, how do we approach this conundrum?
In line with Hyrem’s Law, we need to make changes incrementally and cautiously, ensuring to thoroughly test at each step. We may also consider deprecating the existing interface while developing a new, robust solution that can coexist with the old one during a transition period. This approach allows us to gradually shift the app components from the old interface to the new one, mitigating the risks of immediate, large-scale changes.
Versioning
One effective strategy to mitigate unintended dependencies is versioning. When introducing changes to an API or interface, consider creating a new version while maintaining the old one. This allows existing implementations to continue using the old version while developers have the option to migrate to the new version gradually. Versioning provides a clear separation of functionality and allows developers to choose the version that best suits their needs.
So, Coming back to our problem, I decided to add a new version of the country configuration through a new Module called :country-config
.
I’m following the abstraction over modularization for the basic setup.
Here is how the domain layer in the :api
looks like:
data class CountryConfiguration(
val language: String,
val endpoint: String,
...
)
interface GetCountryConfig {
operator fun invoke(): CountryConfiguration
}
interface GetSpecificRequirement {
operator fun invoke(): SpecificRequirement
}
So, basically, we have a new version of the API for getting the country config. The important part here is that we don’t touch the legacy AppConfiguration
interface.
Deprecation
If you need to remove or modify a method from an interface, mark the old method as deprecated and provide a clear message to developers about migrating to the new method. This ensures a grace period for adaptation and minimizes abrupt disruptions. By deprecating methods rather than immediately removing them, developers have time to update their codebases, and existing applications can continue functioning without interruptions.
In our case, we make the AppConfiguration
deprecated
@Deprecated
interface AppConfiguration {
... bunch of legacy features.
}
Grace Period
Allow a grace period for developers to adapt to the changes before removing deprecated methods or features completely. This period provides them with sufficient time to update their codebases and adapt to the new version. During this period, developers can plan their updates, test their code thoroughly, and address any potential issues that may arise during migration.
Making presentations in the Guild, creating Technical discussions in platforms like GithubDiscussions
will be a good way to start the grace period.
Testing and Continuous Integration
Implement automated testing for both the old and new versions of the interface. Automated tests help catch any regressions and ensure that both versions continue to work as expected. Continuous integration ensures that code changes are continuously tested, making it easier to identify and fix compatibility issues.
Logging
Logging is the practice of recording events, actions, or messages in your application to help developers track and understand the application’s behavior during runtime. It provides valuable insights into the flow of your code and aids in debugging and troubleshooting. In our example, we will log the addition of tasks with their corresponding priorities.
In the country configuration example, we add the logs to both the legacy and new implementation.
Here is how the example looks like in the implementation
// V2 code
class GetCountryConfig @Inject internal constructor(
private val logger:Logger,
private val repository: Repository,
private val rawDataToCountryConfigMapper: Mapper
){
override operator fun invoke(): CountryConfig {
return repository.fechRawData().fold {
onSuccess = { countryConfig ->
logger.logCountryConfigFetchSuccess()
countryConfig
},
onFailure = {
logger.logCountryConfigFetchFailure()
// Will be explained in the next section.
}
}
}
}
// Legacy code
class AppConfigurationImpl : AppConfiguration{
@Inject
lateinit var logger:Logger
fun getCountryConfig() : Config {
logger.logLegacyFetchSuccess()
...
}
}
Error handling
Error handling is the process of managing and responding to exceptions or errors that occur during the execution of your application. Proper error handling ensures that your app gracefully handles unexpected situations and provides meaningful feedback to users when things go wrong.
When considering the example of the country config, I would consider that, without this configuration, the app should not be working. I would always go for an offensive coding approach here by crashing the app. This will help the engineers make the process finding the issue, discovering the root cause and fixing them much faster.
class GetCountryConfig @Inject internal constructor(
private val logger:Logger,
private val repository: Repository,
private val rawDataToCountryConfigMapper: Mapper
){
override operator fun invoke(): CountryConfig {
return repository.fechRawData().fold {
onSuccess = { countryConfig ->
logger.logCountryConfigFetchSuccess()
countryConfig
},
onFailure = { exception->
logger.logCountryConfigFetchFailure()
// Error handling
throw exception
}
}
}
}
Conclusion
As Android app developers, we must be mindful of the impact of changes to the API contracts. By employing versioning, deprecation, effective documentation, a grace period, and testing with continuous integration, we can effectively navigate Hyrum’s Law and minimize disruptions to existing implementations. Adhering to these best practices ensures a smoother experience for developers, promoting a thriving developer community and a more robust app ecosystem. Let’s approach software development with foresight and careful planning to create better, more resilient software solutions that stand the test of time. Embracing Hyrum’s Law allows us to adapt and evolve while maintaining a stable and successful developer ecosystem.
Happy coding!! 🚀