· 4 min read
Swapping Ad Providers in Flutter Without Touching the UI
How an abstract AdService interface let me replace an ad network in a Flutter app by changing one class and one DI binding, with Cubits and UI left untouched.
Changing the ad provider in a Flutter app sounds like a small job until you find the SDK calls spread across screens, state classes and a few helper widgets. In Mapika, my travel map app, I had to swap ad networks after launch. The change touched exactly two places: one new class and one line of dependency injection. This post shows the structure that made that possible.
Why I had to swap ad providers
Mapika started with Google AdMob, the default choice for most Flutter apps. Then I hit a hard wall: AdMob does not serve ads in Russia and most of the CIS, and Mapika is built for that audience too: the store listing is localized into Russian and Ukrainian. For users there, every ad slot stayed empty.
The fix was to move to Unity LevelPlay, which supports a Yandex Ads adapter for that region. A new ad network means a new SDK, new initialization, new callbacks and new ad unit IDs. If AdMob classes had leaked into the UI, this would have been a rewrite of every screen that shows an ad.
They had not leaked, and that was not luck. It was a rule.
The rule: the app talks to an interface, not to an SDK
Every third-party service that might change sits behind an abstract class that the app owns. For ads, that is AdService:
abstract class AdService {
Future<void> initialize();
Future<void> loadInterstitial();
Future<bool> showInterstitial();
Future<void> loadRewarded();
Future<RewardResult> showRewarded();
Stream<AdEvent> get events;
void dispose();
}
Notice what is missing: no AdMob types, no LevelPlay types, no ad unit IDs. The interface describes what the app needs, in the app's own language. RewardResult and AdEvent are small classes defined in the app, not imported from an SDK.
Subscriptions follow the same pattern with a PremiumService interface, so the paywall provider can change the same way.
The state layer only knows the interface
Mapika uses BLoC/Cubit for state. AdsCubit receives an AdService through its constructor and never imports an ad SDK:
class AdsCubit extends Cubit<AdsState> {
AdsCubit(this._ads) : super(const AdsState.initial()) {
_sub = _ads.events.listen(_onEvent);
}
final AdService _ads;
late final StreamSubscription<AdEvent> _sub;
Future<void> start() async {
await _ads.initialize();
await _ads.loadInterstitial();
emit(state.copyWith(ready: true));
}
Future<void> onLevelCompleted() async {
final shown = await _ads.showInterstitial();
if (shown) await _ads.loadInterstitial();
}
void _onEvent(AdEvent event) {
// map provider-neutral events to UI state
}
@override
Future<void> close() {
_sub.cancel();
_ads.dispose();
return super.close();
}
}
The widgets only read AdsState. They have no idea which network is behind it.
The swap itself: one class, one binding
With that in place, moving to LevelPlay meant writing a new implementation:
class LevelPlayAdService implements AdService {
@override
Future<void> initialize() async {
// LevelPlay SDK init, Yandex adapter configured on the native side
}
@override
Future<bool> showInterstitial() async {
// call the LevelPlay API, translate its callbacks into AdEvent
return true;
}
// ...the rest of the interface
}
And changing the binding in the DI setup:
// before
getIt.registerLazySingleton<AdService>(() => AdMobAdService());
// after
getIt.registerLazySingleton<AdService>(() => LevelPlayAdService());
That is the whole diff on the Dart side. AdsCubit, AdsState and every UI file stayed exactly as they were. I made this an explicit rule for the migration: if a change to a Cubit or a widget seemed necessary, it meant the interface was wrong, and the interface had to be fixed instead.
What the interface does not solve
An abstraction does not make the native work disappear. A few things still took real effort:
- Native setup. Mediation adapters live in Gradle and CocoaPods, with their own versions and manifest entries. This is outside Dart and needs its own testing on both platforms.
- Different event models. Each network reports load failures, impressions and rewards slightly differently. The implementation class has to translate them into the app's
AdEventtypes carefully, or analytics will drift. - Behavior, not just API. Fill rates, loading times and frequency limits differ between networks. The interface keeps the code stable, but you still need to watch real behavior after the release.
Is it worth it for a small app?
It costs a little extra at the start: one interface and one class per provider instead of calling the SDK directly. For a solo developer that can feel like ceremony.
My view after this migration: for any service you do not control and might replace, such as ads, payments, analytics or remote config, it is worth it. Those are the parts of a mobile app most likely to change for business reasons, often after launch, when you least want to touch working screens. For code you own, a direct call is usually fine.
Takeaways
- Put every replaceable third-party SDK behind an interface written in your app's own terms.
- Let state classes depend on the interface only; widgets should depend only on state.
- Keep provider-specific types inside the implementation class.
- During a migration, treat "I need to change the UI" as a sign the interface needs fixing.
- Budget separate time for native setup and for checking real ad behavior after release.