Breaking Alignment Safely: Handling Partial Hotfixes in Modular SDKs
May 25, 2026 · 5 min read
Mohamed Abdelhakim Hacine
4 min read
In modern Android architecture, we rarely build in isolation. We rely on massive, integrated ecosystems internal SDKs or external platforms like Firebase or Jetpack that bundle multiple services into a single “monorepo.”
To maintain stability, these SDKs often use a Release Train (or Bill of Materials) (BOM) strategy. When version 2.0.0 is cut, every module is aligned and tested together.
But what happens when production reality hits? What if you need a critical hotfix for just one specific service, but the SDK team hasn’t released a full platform update?
If you aren’t careful, trying to patch a single module can lead to a “Frankenstein” build that compiles perfectly but crashes at runtime.
This article explores how Gradle’s dependency resolution actually works, why “BOMs” exist to protect us, and how to surgically — and responsibly — override them when necessary.
The Architecture: The “NexusHome” Ecosystem Let’s imagine we are building a Smart Home application using a fictional internal SDK called NexusHome. This SDK is structured as a suite of sibling modules.
The NexusHome SDK (v2.0.0):
nexus-initializer: The hub that sets up the environment. nexus-security: Controls smart locks (depends on nexus-core). nexus-lighting: Controls bulbs (depends on nexus-core). nexus-core: Shared utilities used by all modules. The Safety Mechanism: The BOM To ensure nexus-security doesn’t try to use a version of nexus-core that nexus-lighting doesn’t support, the Nexus team publishes a Bill of Materials (BOM).
This strictly tells Gradle: “All Nexus modules must run on version 2.0.0.”
The Incident: When Release Trains Break You are running NexusHome 2.0.0 in production. Suddenly, a critical bug is found: users cannot unlock their doors.
The SDK team rushes out a patch: nexus-security:2.0.1. However, due to time constraints, they do not release a full train. nexus-initializer and the BOM remain at 2.0.0.
The Challenge: You need to upgrade only the Security module to 2.0.1 while keeping the rest of the ecosystem stable at 2.0.0.
The “Naive” Approach: The Version Catalog Your first instinct might be to simply update the version in your libs.versions.toml:
Get Mohamed Abdelhakim Hacine’s stories in your inbox Join Medium for free to get updates from this writer.
Enter your email Subscribe
Remember me for faster sign in
code Toml
[libraries] nexus-security = { module = "...", version = "2.0.1" } You sync Gradle. It builds. You ship it. And then the app crashes in production.
The Deep Dive: Why It Crashed To understand the crash, we have to look at how Gradle resolves conflicts when a Platform/BOM is involved.
Your Request: You explicitly asked for nexus-security:2.0.1. Gradle grants this. The Hidden Dependency: nexus-security:2.0.1 was built against — and expects:: nexus-core:2.0.1. The BOM’s Intervention: The Nexus BOM (which you are likely importing via nexus-initializer) dictates that nexus-core must be 2.0.0. The Result: A “Frankenstein” Build Gradle, obeying the strict constraints of the Platform, forces the Core module to stay at 2.0.0.
Security Module: 2.0.1 (New) Core Module: 2.0.0 (Old) At runtime, the Security module calls a new function, biometricUnlock(), which exists in Core 2.0.1. But because the app loaded Core 2.0.0, the JVM throws a NoSuchMethodError.
The Escape Hatch: resolutionStrategy When upstream release coordination fails, we need a way to tell Gradle: “I know the BOM says 2.0.0, but for this specific feature, I am overriding the rules.”
We can use Gradle’s resolutionStrategy. This is not a standard dependency declaration; it is a rule that intervenes in the resolution graph itself.
The Implementation configurations.all { resolutionStrategy.eachDependency { details -> // 1. SAFETY CHECK: Scope the override strictly to the SDK Group if (details.requested.group == "com.nexus") {
// 2. TARGET: The specific feature AND its transitive internals
// We match "security" to catch 'nexus-security', 'nexus-security-core', etc.
if (details.requested.name.contains("security")) {
// 3. OVERRIDE: Force the upgrade, bypassing BOM constraints
details.useVersion("2.0.1")
details.because("Hotfix for smart lock bug - overrides BOM 2.0.0")
}
}
}
} Why this works It overrides the Platform: resolutionStrategy allows you to supersede the constraints set by the BOM. It aligns the siblings: It ensures nexus-security and its hidden dependency nexus-security-core both move to 2.0.1 together. It isolates the change: The rest of the app (Initializer, Lighting) remains locked at 2.0.0. ⚠️ Important: The Trade-off of Responsibility This technique is powerful, but it should not be your default workflow.
In a perfect world, Semantic Versioning (SemVer) dictates that a patch version (2.0.1) should be backward compatible. If nexus-security:2.0.1 strictly requires nexus-core:2.0.1, it implies a breaking change occurred in the internals.
When you use resolutionStrategy, you are:
Breaking the Alignment Guarantee: You are creating a state the SDK team likely did not test (Initializer 2.0.0 + Security 2.0.1). Transferring Liability: If there is an obscure incompatibility between the old Initializer and the new Security module, you own that crash. When to use this: ✅ Emergency Hotfixes: Production is burning, and you can’t wait for a full SDK release. ✅ Staged Rollouts: You are testing a beta version of a specific module. When NOT to use this: ❌ Standard Upgrades: If a new full version (2.1.0) is available, update the BOM/Platform instead. Conclusion Dependency management is usually about following the rules. But in large-scale engineering, sometimes the rules break.
Understanding Gradle’s resolutionStrategy moves you from simply “importing libraries” to engineering your build. It gives you the power to handle partial hotfixes safely — provided you understand that with that power comes the responsibility of verifying compatibility yourself.