Build1 publisher3 min readPublished
A guard wrapper rethrows every Flutter error with the stack it came from
A dev.to post sets out the Flutter error handling one client monorepo starts from: a sealed AppException vocabulary, one translation point per layer, and a rethrow that keeps the original stack so real bugs still reach Sentry.
The Engineer · Build desk

What happened
- A dev.to post sets one rule for Flutter error handling: catch only the failures you can act on, and let everything else reach the crash tool untouched.
- The author describes a month of a zero crash dashboard while users hit errors the app caught and answered with a calm 'Something went wrong'.
- handleException opens by returning any error that is already an AppException, so a failure is classified once and later layers do not overwrite the classification.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- cost Whoever owns support pays for a swallowed error twice: once in tickets nobody can reproduce, and again in a quarter planned against counts that never happened.
- constraint Sealing the hierarchy moves enforcement into the build, so a team cannot add a failure type and ship it without writing the user-facing copy first.
- decision Each existing catch (e) now needs a verdict: name the specific failure it handles, or delete it and let the error travel to the crash reporter.
- exposure The type chosen at the boundary decides both the message a user reads and which team the number lands on, so a misclassification is visible in two places at once.
The important line is `Error.throwWithStackTrace(handleException(error, st), st)`. `guard` catches everything a repository call throws, including a null dereference, then maps it and rethrows it carrying the stack trace it arrived with [12]. So the rule against catching bugs is narrower than it sounds: guard catches them and rethrows them. The AppException it throws keeps the original error in `cause` and the original trace in `stackTrace`, so the crash report opens on the line the error came from and not on the handler that caught it [9].
`handleException` begins with `if (error is AppException) return error;`, so anything already classified passes through untouched, because re-classifying it throws information away [13]. FormatException maps to ServerException, and the author shipped it as ValidationException first [14]. The reason is now written into the code: "A FormatException reaching here came from decoding a payload, never from user input: input is validated before the call, and a rejected field arrives as a ValidationException from ApiClient's 400/422 branch. So this is a broken client/server contract." [15] While that line was wrong, the app told users to check input they could not change, and the team built a chart saying users were typing things wrong when a backend field had quietly changed type [16].
That mapping only transfers to your app if both premises in the comment hold [15]. Validate before the call, keep a 400/422 branch in the client, and a decode failure really is a contract break. If user input reaches the endpoint unvalidated, the same rule points at your backend for something a form field should have caught.
The adoption cost is concrete. Every repository extends BaseRepository and wraps its calls in guard [12]. Nine failure subtypes are named, so the switch that turns an exception into a sentence needs nine branches today, and adding a tenth breaks the build until the copy exists [1][11]. Six places touch an error on the way through, each with one job [17]. ApiClient maps status codes only, since no connection means no status [18]. On doing it twice, the post is blunt: map status codes in two places and six months later the two lists disagree [18].
The evidence here is one practitioner's account of their own projects. The forty catch blocks, most of them `catch (e)`, the month of a zero dashboard, and the contract that had been broken for nine days come from the post, which does not report a before-and-after error count [6][5][7]. The author says they have been writing Flutter since 2018 and web since 2002, and the code is public in bp-bloc, bp-riverpod and bp-mvvm [4][3]. "The worst bugs I have shipped were not the ones that crashed," the author wrote. "They were the ones nobody told me about." [20]
I would adopt the sealed switch before the rest of it. A catch block written to keep a red screen out of a demo outlives the demo [6], and with the switch sealed the build fails until the copy exists [11].
What to watch
- Whether the public bp-bloc, bp-riverpod and bp-mvvm repositories contain the full six-stage error path and the handleException switch, not just the snippets quoted.
- Any team publishing before-and-after error counts from removing bare catch (e) blocks; the post reports none.
- Whether the ApiClient-maps-status-codes, guard-maps-transport split survives a second client or transport being added to the monorepo.