The Fallback Trap: When Defensive AI Code Hides a Broken App
Defensive programming should make software safer. When an AI agent does it, fallbacks often hide a broken state so the app looks healthy while doing the wrong thing.
Defensive programming is one of the oldest good habits in software. Check your inputs, handle the edge cases, never let a bad value crash the whole app. When an AI agent does it, that same habit quietly becomes one of the most dangerous things in your codebase.
The instinct is right. The default the agent reaches for is wrong. Told to be careful, a coding agent wraps everything in try/catch, adds a fallback for every failure, and returns a safe-looking value when something goes sideways. The app never crashes. It also never tells you it broke. It just keeps running, serving the wrong answer with a straight face, and you find out weeks later when a customer does.
That is the fallback trap. And it is worth naming precisely, because "AI writes bad code" is too vague to act on. This is a specific, checkable failure mode: defensive code that swallows a broken state instead of surfacing it.
#The habit that turns into a hazard
Armin Ronacher, the creator of Flask, wrote a widely discussed post called The Coming Loop about his unease with where agentic coding is heading. In the thread that followed on r/ExperiencedDevs, one line landed hard for a lot of engineers:
They add fallbacks instead of making bad states impossible.
Another put the consequence plainly:
Tons of default/fallbacks that end up masking real problems when they happen.
Read those two sentences together, because between them is the whole trap. Making a bad state impossible means the program refuses to continue in a broken condition. Adding a fallback means the program continues anyway, on a guess. Those are opposite philosophies, and an agent optimizing for "don't crash" will pick the second one every single time.
Here is why this is structural and not a passing model weakness. A defensive fallback is, by design, invisible. Its entire job is to make a failure look like a non-failure. So the better your agent gets at writing plausible, tidy-looking code, the better it gets at burying the failure where you will never see it by reading. This is the same reason the verification gap widens as models improve: capability moves errors from obvious to subtle, and subtle is exactly where a silent fallback lives.
#What the trap looks like in real code
Consider the difference between the two philosophies on the same tiny function: fetch a user's plan so the app can decide what features to show.
The fallback version:
1async function getUserPlan(userId) { 2 try { 3 const plan = await db.plans.findByUser(userId); 4 return plan ?? 'free'; 5 } catch { 6 return 'free'; 7 } 8}
It looks responsible. It handles the missing row, it handles the thrown error, it never crashes. It is also the trap in its purest form. If the database is down, every user silently becomes a free user. Paying customers lose the features they paid for, no error is logged, no alert fires, and the app looks completely healthy on your dashboard. You will find this bug when the support tickets arrive, not when it happens.
The fail-loud version:
1async function getUserPlan(userId) { 2 const plan = await db.plans.findByUser(userId); 3 if (!plan) { 4 throw new Error(`No plan found for user ${userId}`); 5 } 6 return plan.tier; 7}
This one can crash. That is the point. A crash is information. It tells you, immediately and loudly, that a user has no plan and something upstream is wrong. The fallback version traded that information away for the appearance of stability, and the appearance of stability is worth nothing when the app is stable and wrong.
Notice what the reader cannot do here. You cannot look at the first version and know it is a bug, because there is no bug in the code. The bug is in the gap between what the code does (returns 'free' on failure) and what you intended (only free users see the free tier). That gap does not live in the diff. It lives in a requirement nobody wrote down.
#The reframe: a silent fallback is a failed acceptance criterion
The instinct at this point is to tell the agent to stop doing this. Add a rule to your instructions file: no silent fallbacks, fail fast. That helps, and it is worth doing. But a blanket rule is a blunt instrument, because sometimes a fallback is exactly right (a cached value when a non-critical service is slow) and sometimes it is catastrophic (defaulting a paying user to the free tier). The agent cannot tell which is which from a general instruction. Only the intended behavior of that specific feature can.
So the durable fix is not a better rule. It is a written statement of what correct behavior actually is, before the code exists.
Think of it this way. A silent fallback is not a coding-style problem. It is a feature that fails its acceptance criteria without telling you. If the requirement said "when the plan lookup fails, surface an error and do not grant or revoke access," then the fallback version is not defensive code, it is a build that violates its spec. You just could not see the violation by reading, because the spec was never captured anywhere the code could be checked against it.
This is the shift. Stop trying to catch silent fallbacks by reviewing code harder. Start defining the correct behavior up front, so a failure to meet it is a fact the loop can check instead of an opinion you have to form by staring at a green dashboard.
That is where a plan-first workflow changes the outcome. In BrainGrid, before the Builder Agent writes anything, the Planning Agent turns "look up the user's plan" into a requirement with explicit acceptance criteria, including what must happen when the lookup fails. The build is then verified against those criteria with evidence, so a change that silently defaults on error does not pass as done. This is the Verify step of the loop doing its job: the agent is not left to guess whether continuing on a fallback is safe, the criterion already answered that, and the verification step checks the answer. It isn't done until the evidence says it does what you intended.
Loading diagram...
#What this changes for you
If you are shipping features through Claude Code, Cursor, or Codex right now, this means the bugs that hurt you most will not be the ones that crash. They will be the ones that don't. Your agent is competent enough to keep the app running through almost any failure, which sounds like a feature and is actually the problem. The silent-fallback bug is invisible in a demo, invisible in a code review skim, and invisible on your monitoring until real usage hits the failure path you defaulted your way past.
The practical move is small and it is upstream. Before you hand a feature to an agent, decide what the failure paths should do, and write it down as part of the spec, not as an afterthought. "On error, fail and alert" versus "on error, use the cached value" is a product decision, and it is yours to make, not the agent's to guess. Once it is written, it becomes something the build can be checked against. Without it, "handle the error gracefully" collapses into "hide the error," and graceful is the last word you would use for an app that lies to you.
There is a trade-off worth naming honestly. Failing loud means more visible errors during development, and some of them will feel like noise. A system that refuses to continue in a bad state is louder than one that limps along on defaults. But that loudness is the whole value. You are moving the discovery of a failure from your customers to your test run, which is the only trade in software that is always worth making.
#FAQ
#Is defensive programming good?
Defensive programming is good in principle and dangerous in one specific practice: silent fallbacks. Validating inputs, guarding against null, and handling edge cases explicitly all make software more robust. The problem is the version of "defensive" that swallows a failure and continues on a default, because that hides a broken state rather than handling it. The useful distinction is between defending against bad input (good) and defending against your own program ever surfacing that something went wrong (bad). When an AI agent writes defensively, it tends toward the second, so the goal is not to turn defensiveness off but to specify when a failure should stop the program instead of being papered over.
#What is defensive programming?
Defensive programming is the practice of writing code that anticipates and handles things going wrong, so that unexpected inputs or failures do not corrupt data or crash the system. In its healthy form it means checking assumptions explicitly: validate arguments, guard against null, handle the edge cases you can foresee. The failure mode this post is about is a specific overcorrection, where "handle the failure" quietly becomes "hide the failure" by returning a safe-looking default. The line between the two is whether the code makes a bad state visible or invisible.
#What are two common defensive coding techniques?
Two of the most common are input validation and guard clauses. Input validation checks that data is well-formed and within expected bounds before the code acts on it, rejecting or flagging anything that is not. Guard clauses check for invalid conditions at the top of a function and exit early rather than nesting the real logic inside deep conditionals. Both are healthy because they make bad states explicit and stop them early. The technique to be careful with is the catch-all fallback, a try/catch or default value that lets execution continue no matter what failed, because that one hides the bad state instead of surfacing it.
#What is defensive design in programming?
Defensive design is the broader idea of structuring a system so that failures are contained, predictable, and hard to trigger accidentally, rather than left to chance. It includes defensive coding techniques like validation and guard clauses, but it also covers how components fail: whether a failure is loud and isolated or silent and spreading. Good defensive design makes bad states impossible or immediately visible. The anti-pattern, common in AI-generated code, is design that makes bad states survivable but invisible, which trades a crash you would notice for a wrong answer you would not.
BrainGrid plans every feature into acceptance criteria, including what should happen when things fail, then verifies each build against them with evidence, so a silent fallback that hides a broken state never passes as done. Try it at braingrid.ai.
Keep Reading
Ready to build without the back-and-forth?
Turn messy thoughts into engineering-grade prompts that coding agents can nail the first time.
Describe what you want to build