A Flutter 'save draft' Command1 wraps an action that throws a SocketException instead of returning Result.error; what does the guide's Command do, and how do you fix it?
answer
- try/finally, no catch
- running still resets
- result stays null
- neither error nor completed
- convert at the repository edge
basics
~20 sThe full Command awaits the action in try/finally without a catch, so the exception escapes execute(); running resets but result stays null, leaving error and completed false and the UI silent. Return Result.error from the repository instead.
solid answer
~40 sIn the guide's full `Command`, the private execute routine sets `running`, clears the result, notifies, then does `try { _result = await action(); } finally { running = false; notify; }`. There is **no catch**. If the action throws, the assignment never happens, so `result` stays `null`; `error` (`result is Error`) and `completed` (`result is Ok`) are both false; `running` goes back to false; and the exception propagates out of `execute()`, typically as an uncaught async error from the button's `onPressed`. The screen stops spinning and says nothing. The fix is to keep the contract: the repository or service catches `on Exception catch (e)` and returns `Result.error(e)`. A test with a fake repository that throws catches regressions.
code
dart · 37 linesimport 'result.dart'; // the guide's sealed Result<T>
class Draft {
const Draft(this.id, this.body);
final String id;
final String body;
}
abstract class DraftApiService {
Future<void> putDraft(Draft draft); // throws SocketException offline
}
// Before: the exception escapes, and the Command's result stays null.
class LeakyDraftRepository {
LeakyDraftRepository(this._api);
final DraftApiService _api;
Future<Result<void>> save(Draft draft) async {
await _api.putDraft(draft);
return const Result.ok(null);
}
}
// After: failures come back as values, so saveDraft.error becomes true.
class DraftRepository {
DraftRepository(this._api);
final DraftApiService _api;
Future<Result<void>> save(Draft draft) async {
try {
await _api.putDraft(draft);
return const Result.ok(null);
} on Exception catch (e) {
return Result.error(e); // SocketException implements Exception
}
}
}go deeper
Know that the guide's commands expect actions to return Result.error, not to throw.
Trace execute's try/finally and explain why result stays null when the action throws.
Diagnose a silent failed save from the symptoms, fix it at the repository boundary, and add a test that throws.
Set an error-handling contract per layer and enforce it with lint rules or review checklists across teams.
## The contract the Command relies on The full `Command0` and `Command1` in Flutter's architecture guide type their actions as returning `Future<Result<T>>`. The design assumes failures **come back as values**: the service or repository catches exceptions and returns `Result.error`. The command then only asks which subclass it got. The guide's **simplified** demo `Command` is different: it catches `on Exception` itself and exposes an `Exception? error`. Candidates who learned that version often assume the full one also catches. It does not. ## Tracing the failure In a blogging app, `DraftApiService.putDraft` throws a `SocketException` when the device is offline, and `DraftRepository.save` forgets to catch it. When the user taps 'Save draft': 1. `execute(draft)` passes the guard, sets `running = true`, sets the result to `null` and notifies. The spinner appears. 2. `await action()` completes with the `SocketException`. 3. The assignment to the result never runs, so the result is still `null`. 4. The `finally` block sets `running = false` and notifies. The spinner disappears. 5. The exception leaves `execute()`. Called from `onPressed`, nobody awaits it, so it is reported as an uncaught error. | Getter | Value afterwards | Why | |---|---|---| | `running` | `false` | reset in `finally` | | `result` | `null` | never assigned | | `error` | `false` | `null is Error` is false | | `completed` | `false` | `null is Ok` is false | The user sees the button return to normal with no message, and the draft is not saved. A listener waiting for `error` to show a `SnackBar` never fires. ## The fix Keep failures inside the `Result` contract at the data-layer boundary: - In the repository or service, wrap the call: `try { ... return const Result.ok(null); } on Exception catch (e) { return Result.error(e); }`. - Map non-exception failures, such as an HTTP 500 status, to `Result.error(HttpException(...))` too. - Leave Dart `Error`s, which signal bugs, uncaught so they surface in development. Do **not** 'fix' it by adding a broad `catch` inside the shared `Command` that swallows everything: that hides programming errors and duplicates the repository's job. ## Proving it stays fixed 1. Write a fake `DraftRepository` whose `save` throws `SocketException('offline')`. 2. Wrap it in the real repository logic, or test the real repository with a fake service that throws. 3. Assert that the repository returns an `Error<void>`. 4. In a view model test, execute the command and assert `saveDraft.error` is true and `saveDraft.running` is false. ## Related symptoms with the same cause - A spinner that stops but no error dialog appears. - 'Unhandled exception' lines in the console right after a failed save. - `completed` checks that never fire for some failures and do for others, depending on which layer caught what.
- Why not add a catch-all inside the shared Command class instead?It would also swallow Dart `Error`s that signal bugs, and it duplicates the data layer's job. The guide's design puts the conversion at the service and repository boundary, where the kind of failure is known.
- Where does the escaped exception end up when execute() is called from onPressed?Nothing awaits the future returned by `execute()`, so the error is unhandled and reported by the zone's uncaught-error handling, which in Flutter is typically routed to `PlatformDispatcher.onError` or the console in debug builds.
saying these in an interview costs you the question
- The full Command catches the exception and sets error to true.
- running stays true forever after the action throws.
- result holds an Error after the action throws.
- Wrapping the Command's action in a catch-all for every Object is the proper fix.
- A thrown exception and a returned Result.error look the same to the view.