Error Handling & Result¶
The template converges "countless low-level exceptions" into "the business layer only handles one exception type, AppException", then uses Result<T> to make the three terminal states of an async operation (success / failure / cancel) explicit. This page explains how to use this packaging.
Related files: core/error/, core/result/, core/storage/base_repository.dart.
1. Exception Normalization: AppException¶
Low-level DioException, JSON parse errors, business-status-code failures … are all unified into subclasses of AppException; the business layer no longer imports dio.
Each exception carries:
message: a human-readable text suitable for display (usually from the server and already translated; may benull).code: HTTP status code (401/404/500…) or an internal sentinel code (seeAppErrorCodes).originalError: the raw low-level exception that triggered it.
abstract class AppException implements Exception {
ToastEffect toToastEffect() => ToastEffect(message: message, code: code);
}
Built-in subclasses (core/error/app_exception.dart):
| Subclass | Trigger scenario |
|---|---|
NetworkException |
No network / DNS failure (connectionError) |
ServerException |
HTTP 4xx/5xx (badResponse) |
AuthException |
401 / 403 |
TimeoutException |
Connect / send / receive timeout (falls back to HTTP 408) |
ParseException |
JSON parse failure or unexpected structure |
BusinessException |
HTTP 200 but business-status-code failure (e.g. {code:10001, message:"out of stock"}) |
UnknownException |
Other errors that cannot be classified |
AppExceptionis abstract, not sealed: any library canextends AppExceptionto add custom business exceptions, without modifying the framework.
2. The Single Conversion Point: ErrorHandler¶
All exception mapping goes through ErrorHandler, guaranteeing a consistent experience. BlocErrorHandlerMixin already holds it internally; the business layer usually doesn't touch it directly.
final handler = ErrorHandler();
try {
await fetch();
} on Object catch (e, stackTrace) {
final ex = handler.handle(e); // AppException? — returns null on cancel
if (ex != null) showToast(ex.message ?? 'error');
}
- Server message extraction: by default it reads in order
message/error/errorMsg/msg. Different backend field names? Pass them at construction: - Cancel recognition:
handler.isCancelled(error)—DioExceptionType.cancelreturnstrue, and should be silently ignored (no Toast, no state change). - No hard-coded text: timeout / no-connection / unknown errors carry no hard-coded text; the
codeis left to the frontend to localize viaAppErrorCodes(see section 5).
3. Three-State Result: Result<T>¶
core/result/result.dart uses a sealed class to make async terminal states explicit, replacing try/catch/if-cancelled boilerplate:
sealed class Result<T> { ... }
final class Success<T> extends Result<T> { final T value; }
final class Failure<T> extends Result<T> { final AppException exception; }
final class Cancel<T> extends Result<T> { const Cancel(); }
Extension methods:
result.when(
success: (value) => emit(state.copyWith(data: value)),
failure: (ex) => emitEffect(ex.toToastEffect()),
cancel: () {/* active cancel, usually do nothing */},
);
result.valueOrNull; // value on success, else null
result.isSuccess / isFailure / isCancel;
Cancelis a separate branch, notFailure: an active user cancel should not be treated as an "error" and show a Toast.
4. Using It in the BLoC: BlocErrorHandlerMixin¶
The most concise writing (replaces hand-written try/catch):
final result = await runToResult(() => repository.fetch());
result.when(
success: (items) => emit(state.copyWith(items: items)),
failure: (ex) => emitEffect(ex.toToastEffect()),
cancel: () {},
);
Capability list:
| Method | Purpose |
|---|---|
runToResult<T>(action) |
Wrap an async action → Result<T>; cancel → Cancel, other exceptions → Failure(AppException) |
runWithErrorHandling(action, onSuccess, onError) |
Callback style; returns null on error/cancel |
handleError(error) |
Any exception → AppException? (null on cancel) |
isCancelled(error) |
Check whether it is an active cancel |
errorHandler (overridable) |
Inject a custom ServerMessageExtractor |
The resolution priority of ex.toToastEffect() (see Effect & Notifiers): display message directly → fall back to code via AppErrorCodes → if it carries an l10nCode, it must be translated by the business handle.
5. Error Codes & Localization: AppErrorCodes¶
core/error/app_error_codes.dart defines internal codes (a negative namespace, to avoid colliding with HTTP) and common HTTP code constants:
| Category | Constant |
|---|---|
| Internal | unknown=-1 / parseFromJson=-2 / parseNullData=-3 / parseWrongType=-4 / noConnection=-5 |
| 4xx | badRequest=400 … tooManyRequests=429 |
| 5xx | internalServerError=500 … gatewayTimeout=504 |
defaultToastHandle maps these codes to localized text (e.g. error404 → "The requested resource does not exist"); unlisted codes fall back to unknownErrorCode. So even if the backend returns no text, the user still sees a readable error prompt.
6. Using It in the Repository: BaseRepository¶
The repository extends BaseRepository, so you don't write JSON parsing and error checks by hand.
Flat responses: parseList / parseSingle / parseResponse¶
class UserRepository extends BaseRepository {
Future<List<User>> fetchUsers({CancelToken? cancelToken}) async {
final res = await client.get<List<dynamic>>('/users', cancelToken: cancelToken);
return parseList(res, User.fromJson); // data is an array
// or parseSingle(res, User.fromJson); // data is a single object
// or parseResponse<ApiResp>(res, ApiResp.fromJson); // nested structure to freezed
}
}
A type mismatch automatically throws ParseException (with the corresponding AppErrorCodes).
Business status code: parseBusinessResponse¶
HTTP 200 but code != 0 in the body means a business failure. The framework does not guess field names; you provide the parsing and extraction logic, and it throws BusinessException after confirming 2xx:
Future<String> login({required String username, required String password}) async {
final res = await client.post('/login', data: {...});
return parseBusinessResponse<String, LoginResp>(
res,
parseBody: LoginResp.fromJson,
isSuccess: (b) => b.code == 0, // business success judgment
extractCode: (b) => b.code, // optional: error code
extractMessage: (b) => b.message, // optional: server text
extractData: (b) => b.data, // data extracted on success
);
}
- Non-2xx response → immediately throws
ServerException(fallback by HTTP code inErrorHandler). - 2xx but
isSuccessis false → throwsBusinessException(extractMessage, code: extractCode). - Pure client-side validation (e.g. empty username) can also directly
throw const BusinessException('Username or password cannot be empty').
Full chain¶
Repository throws AppException
↓ (DioException already converted in ErrorHandler; BusinessException thrown by you)
BlocErrorHandlerMixin.runToResult
↓
Result<T>.when(success / failure / cancel)
↓
failure → emitEffect(ex.toToastEffect())
↓
EffectListener responsibility chain → Toast (message directly / code via AppErrorCodes / l10nCode business handle)
7. Quick Reference: How a Feature Handles Errors¶
- Repository:
extends BaseRepository, usesparseBusinessResponseetc.; throwsBusinessExceptionon business failure. - Bloc:
with BlocErrorHandlerMixin,runToResult(() => repo.xxx()),result.whenhandles three states. - Success:
emit(state.copyWith(...)). - Failure:
emitEffect(ex.toToastEffect())(auto-fallback by message / code, or a customl10nCodegoes through the business handle). - Cancel:
cancel: () {}silently.
No try/catch, no dio import, no hand-written error-text mapping — all taken over by the packaging.