The Back Button Showed an Interstitial Sometimes, Not Others — Rewriting Nested ifs Into a List of Independent Guards
Interstitial display on back press was unstable because nested if statements hid the priority between conditions. Here is how I split it into reason-returning guards and generated tests from a decision table.
Right after a user finishes a rewarded video, they sometimes press the back button — and very occasionally an interstitial fires immediately afterward. I learned about this in one of the wallpaper apps I run as a solo developer, not from Crashlytics but from a user review. The reproduction rate was low, and I could never trigger it on my own device.
The cause was not a crash. It was the logic that decided whether to show an ad on back press. Four conditions — frequency cap, cooldown, whether the user had paid, and whether an ad was loaded — were written as nested if statements, and which condition took priority appeared only implicitly in the structure of the code. This is a record of how I reworked that decision into an ordered list of independent guards that each return a reason, generated tests from a decision table, and stopped the regression.
Nested ifs hide priority inside their structure
The problem code looked roughly like this.
fun onBackPressed() { if (!billing.isAdFree) { if (interstitial.isLoaded) { if (System.currentTimeMillis() - lastShownAt > COOLDOWN_MS) { if (backPressCount % SHOW_EVERY == 0) { interstitial.show(activity) lastShownAt = System.currentTimeMillis() } } } } super.onBackPressed()}
At a glance it looks correct, and most of the time it behaves correctly. The trouble was the state right after a rewarded video. There was a path where the rewarded view forgot to update lastShownAt, so the cooldown check could slip through. On top of that, backPressCount was also incremented on other screens, mixing in counts from operations that were not back presses.
In a nested structure, the relationships between conditions are invisible. Whether cooldown or the frequency cap is evaluated first, whether the ad-free check is truly the highest priority — you only know by reading the code line by line. Four conditions are still manageable, but once consent state (UMP) and in-app review prompts start competing for the same moment, the nesting quickly becomes impossible to follow.
Decompose the decision into reason-returning guards
The rework was simple in principle. Instead of expressing "may we show this" as one large boolean, I turned it into a list of small guards that each independently return a block reason. If any single guard blocks, we do not show. Only when every guard passes do we show.
sealed interface AdDecision { data object Allow : AdDecision data class Block(val reason: String) : AdDecision}// Each guard returns the reason it blocks, or null if it passes.private val guards: List<(AdContext) -> String?> = listOf( { ctx -> if (ctx.isAdFree) "ad_free" else null }, { ctx -> if (!ctx.isLoaded) "not_loaded" else null }, { ctx -> if (ctx.sinceLastShownMs < COOLDOWN_MS) "cooldown" else null }, { ctx -> if (ctx.backPressCount % SHOW_EVERY != 0) "frequency" else null },)fun decideOnBackPress(ctx: AdContext): AdDecision { val reason = guards.firstNotNullOfOrNull { it(ctx) } return if (reason == null) AdDecision.Allow else AdDecision.Block(reason)}
The call site can then cleanly separate the decision from the action.
fun onBackPressed() { when (val d = decideOnBackPress(adContext())) { is AdDecision.Allow -> { interstitial.show(activity) lastShownAt = clock.now() } is AdDecision.Block -> log.d("interstitial blocked: ${d.reason}") } super.onBackPressed()}
What I appreciated most about this shape is that the block reason ends up in the logs. Before, all I knew was that "it didn't show." Now cooldown versus frequency is always recorded. A low-repro bug became something I could chase in logs rather than in reviews.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Reworking the back-press interstitial decision from nested ifs into an ordered list of reason-returning guards
✦A concrete way to start from a decision table and have Antigravity generate the parameterized tests
✦Criteria for keeping ad-free state, cooldown, frequency cap, and load readiness as separate responsibilities
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
When the guards are a list, the evaluation order appears directly as the order of listOf(...). This is the decisive difference from nesting. The order still carries meaning, but that meaning is gathered into one place in the code.
I made that order reflect the priority of responsibilities. Ad-free comes first. For someone who paid, we stop ads before any other condition is even considered. Load readiness comes next, because it is a physical "can we" that precedes policy, so it filters out early. After that come the policy guards: cooldown and the frequency cap.
There is a reason I put load readiness ahead of policy. If you mix the two, it becomes ambiguous whether a round where "frequency would have allowed it but the ad wasn't loaded in time" is counted as frequency or not_loaded. With responsibilities separated, you can decide unambiguously that the frequency counter advances only when an ad was actually shown.
Guard
Responsibility
Block reason
Advances counter?
isAdFree
Billing state (highest)
ad_free
No
isLoaded
Physical readiness
not_loaded
No
cooldown
Time policy
cooldown
No
frequency
Count policy
frequency
Only when shown
Start from a decision table and have Antigravity generate the tests
Decomposing into guards made the decision easy to test. Each guard is a pure function that takes an input (AdContext) and returns a reason, so I can verify the decision itself without standing up an Activity or the hard-to-instantiate AdMob SDK.
I handed the decision table straight to Antigravity. The prompt was roughly this.
Write a parameterized test for decideOnBackPress(ctx).Turn each row of this decision table into one case and assert the expected AdDecision.- If isAdFree=true, Block("ad_free") regardless of other conditions- If isLoaded=false, Block("not_loaded")- If sinceLastShownMs < COOLDOWN_MS, Block("cooldown")- If backPressCount is not a multiple of SHOW_EVERY, Block("frequency")- If all of the above pass, AllowInclude "multiple conditions false at once" cases that demonstrate the order is top-to-bottom.
What came back was a JUnit5 @ParameterizedTest. The most valuable part was a case I had overlooked — "ad-free and not loaded" at the same time. Here the expectation must be ad_free. The test pins down that billing state wins before load readiness. If someone later reorders the guards, this case fails and we notice.
When you ask an agent to write tests, fixing the expected behavior as a decision table first gives better results than handing over the code and saying "figure out the tests." The table plays the role of a spec, and the agent can focus on a mechanical transformation of the table. This is not documented as an official technique, but having tried it several times, I find it reliably effective.
It paid off when rolling out to several apps
Because I run several apps in the same family, I extracted this pairing of decideOnBackPress and its decision table into a shared module. What differs per app is the values of COOLDOWN_MS and SHOW_EVERY, and for some apps whether to add one more guard like "show nothing for the first 24 hours after install." Since the guards are a list, adding one is just inserting a single element into listOf. With nesting, I would have agonized each time over which level a new condition belonged to.
When distributing through staged rollout, the block-reason logs themselves became observation points. If the not_loaded ratio is higher than expected early in a release, I can tell that it is a loading-strategy problem (when preloading starts) rather than a policy problem. A decision that carries reasons raises the resolution of the tuning you do after release.
Next step
If your back-button or dialog ad decision is one big boolean today, start by rewriting it into a list of functions that return block reasons. Just separating the show-or-not logic from the side effect of actually showing puts you in a state you can trace through logs. From there, write one decision table and you can hand the tests to the agent.
Ad gating sits right at the seam between revenue and experience. I am still tuning this area myself, but keeping the decision as small parts that each carry a reason is what has helped me most in running these apps over the long term.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.