· 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:

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