← Back to list

How I Refactored a Production Android SDK to MVVM Without Breaking a Single Publisher Integration

2,200+ files. 37,000 net lines deleted. Zero publisher migrations required.

Madhur Lalit · 2026-05-02 23:35 · 0 claps · 5.4 min read
#android #software-architecture #sdk-development #mobile-engineering #mvvm
Open on Medium ↗
Wiki topics: 🏛️ · Architecture

How I Refactored a Production Android SDK to MVVM Without Breaking a Single Publisher Integration

2,200+ files. 37,000 net lines deleted. Zero publisher migrations required.

Every engineer has refactored code. Almost none have done it under this constraint: the codebase you’re changing is running inside hundreds of apps you don’t control, and a regression means someone else’s production app crashes at a time you’ll never know about, in a way you can’t reproduce.

That is the reality of SDK engineering. And it is why the MVVM refactor I led on a production Android ad SDK is the hardest architectural work I have done in 11 years of Android development.

What we were working with

The SDK before the refactor had no formal architecture. It was a mixture of accumulated design patterns ad loading, state management, event dispatching, and rendering concerns all tangled together in the same classes. There was no separation between what the SDK knew about an ad’s state and how it communicated that state to publishers. The classes that handled network responses were the same classes that fired publisher callbacks. Business logic lived next to UI logic. Tests were sparse because testing any single concern required constructing most of the system.

The codebase was not bad because engineers were careless. It was the natural result of years of incremental feature additions on top of a foundation that was never designed for the complexity it eventually carried.

The refactor was not optional. The architecture had become a ceiling on what the team could ship safely.

The constraint that changed everything

Internal app refactors have a safety net: you control the deployment. You can break something, push a fix, and most users will never know. SDK engineers don’t have that safety net.

The SDK was integrated into hundreds of publisher apps worldwide. Every public class, every public method, every callback interface was a contract. If a class moved packages, that was a compile error in a publisher’s app. If a method signature changed, that was a build failure at their worst possible moment. If a callback stopped firing in a scenario we hadn’t tested, that was a revenue-impacting bug in their production release one we might only hear about days later through a support ticket.

The constraint was absolute: the refactor had to be invisible to every existing publisher integration. Not mostly invisible. Completely invisible.

The architectural decision: a new internal layer, a frozen external surface

The solution was to introduce a dedicated MVVM foundation module an entirely new internal package containing the new architecture, completely hidden behind the existing public API surface.

This module introduced three core abstractions:

Base ViewModel the parent ViewModel for every ad format. Because an SDK has no Activity or Fragment to provide Android's standard ViewModelStore, we couldn't use ViewModelProvider. Instead, we used a delegate pattern: the ViewModel's lifecycle was scoped to the ad unit object that the publisher already held a reference to. When the publisher's ad object was destroyed, the ViewModel was destroyed with it. No custom lifecycle owner required the existing SDK object graph provided the scope naturally.

Repository layer the data abstraction separating network operations, cache, and configuration from the ViewModel. Previously, ad loading logic reached directly into network and storage layers from wherever it needed them. The Repository became the single point of truth for ad data, independently testable and independently replaceable.

Ad state machine a sealed class representing every stage of an ad’s lifecycle: idle, loading, loaded, rendering, shown, clicked, expired, failed. Previously, ad state was tracked through scattered boolean flags and implicit ordering of callback calls. The state machine made every possible state explicit, exhaustive, and auditable. Invalid state transitions became impossible by construction.

The bridge: mapping new state to old callbacks

The hardest engineering problem was not building the new MVVM layer. It was wiring it to the existing public API without exposing any of the new structure to publishers.

The solution was an internal listener interface and an event notification bridge per ad format. Every ViewModel emitted state changes through this internal interface. A thin facade layer the existing public ad objects that publishers already held references to implemented this interface and translated each state change into the appropriate existing public callback.

From a publisher’s perspective, nothing changed. onAdLoaded() still fired when it always fired. onAdFailedToLoad() still received the same error codes. The objects they had instantiated still behaved identically. Underneath, every one of those callbacks was now driven by a clean state machine rather than imperative code scattered across a God class.

The facade pattern was the architectural keystone. It was also the most painstaking part of the refactor every existing callback path had to be mapped, tested, and verified to fire under exactly the same conditions as before.

Why video was the hardest format

Every ad format received its own ViewModel banner, interstitial, rewarded, and native each had accompanying repository and test classes. But video required the most work by a significant margin.

VAST video ads carry state that no other format has. The skip button appears after a configurable delay that can be overridden by remote config. Companion Ads must overlay the video player at specific timestamps. Quartile tracking events 25%, 50%, 75%, completion must fire exactly once per viewing, survive configuration changes, and not double-fire if the video is paused and resumed. The native video player emits state through a listener interface that doesn’t map cleanly to a ViewModel’s event model.

The video ViewModel introduced a VAST state validator a utility that enforced valid state transitions and ensured the state machine couldn’t enter invalid combinations. Skip button visibility became a discrete state in the ad state machine rather than a timer firing a direct UI call. Companion Ad overlay timing was decoupled from the video player listener and driven instead by position observations flowing through the ViewModel. Every quartile event became an idempotent state transition rather than an imperative callback call so replaying state, surviving configuration changes, and testing exhaustively all became straightforward.

The result in numbers

The refactor shipped across multiple SDK versions. When the dust settled:

2,200+ files changed. This was not a localised improvement it touched every module, every format, every test.

37,000 net lines deleted. More code was removed than added. The new architecture was not just cleaner it was smaller. Separation of concerns eliminated duplication that had accumulated invisibly for years.

Full test coverage alongside every new ViewModel. A ViewModel test class was written in parallel with every production ViewModel, each covering the state machine exhaustively. The old code was hard to test because concerns were tangled. The new code was testable by design.

Zero publisher migrations. Not a single existing integration required a code change. The refactor was invisible to everyone outside the SDK which was the entire point.

What I would do differently

The delegate-based ViewModel scoping worked, but it required discipline to ensure no ViewModel outlived its owning ad object. A more explicit lifecycle contract even a simple interface would have made this clearer to engineers joining the codebase later.

I would also design the state machine first. It was one of the last pieces formalized, when it should have been the first. Defining the complete state machine upfront forces every subsequent design decision to be evaluated against it and surfaces edge cases before they become production bugs.

The broader lesson

The constraint that made this refactor hard the frozen public API, the invisible migration is also what made the result meaningful. Any engineer can refactor a codebase they fully control. The discipline of changing everything internally while guaranteeing nothing changes externally forces architectural clarity that no code review checklist can replicate.

You cannot hide complexity behind a leaky abstraction when the abstraction is the only thing standing between your refactor and hundreds of broken publisher apps.

The new architecture was not just cleaner code. It was a system that could be understood, tested, and extended without fear by engineers who hadn’t written it, on devices they couldn’t test on, in apps they’d never seen.

That is what good SDK architecture is for.

Madhur Lalit is a Staff Android Engineer with 11 years of experience in Android SDK engineering. He specialises in mobile SDK architecture, ad tech infrastructure, and privacy-first mobile solutions. Published on Medium · github.com/Madroid2/apex-ad-sdk-android


메타데이터
post_id
01fa6ad8baf8
slug
how-i-refactored-a-production-android-sdk-to-mvvm-without-breaking-a-single-publisher-integration-01fa6ad8baf8
url
https://medium.com/@madhur.lalit2409/how-i-refactored-a-production-android-sdk-to-mvvm-without-breaking-a-single-publisher-integration-01fa6ad8baf8
canonical_url
https://medium.com/@madhur.lalit2409/how-i-refactored-a-production-android-sdk-to-mvvm-without-breaking-a-single-publisher-integration-01fa6ad8baf8
author_url
https://medium.com/@madhur.lalit2409
status
ok
fetched_at
2026-06-27 18:20:27