Core philosophy
For predictable failures, do not throw exceptions. Instead, return Result expressed as a type. This forces callers to handle failure cases through the type system, eliminating UI error paths that are missed at runtime.
Exceptions are only used to catch what the framework/platform throwsat the lowest layer (Data) and convert it to Result.error(...) . UseCase, ViewModel, and Screen no longer see try/catch.
Base types (lib/core/domain/error/)
Error marker interface
// lib/core/domain/error/error.dart
abstract interface class Error {}
Every custom error type implements this Error . Result 의 E must be extends Error , so it does not mix with the Dart standard Exception .
Result<D, E>
// lib/core/domain/error/result.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'error.dart';
part 'result.freezed.dart';
@freezed
sealed class Result<D, E extends Error> with _$Result<D, E> {
const factory Result.success(D data) = ResultSuccess;
const factory Result.error(E error) = ResultError;
}
Important: sealed Because it is declared as switch , the Dart compiler enforces all cases in
. If you miss a case, a compile warning appears.
Defining feature-specific errors enum + implements Error Errors are defined as toString() . Each value places the Korean message to show the user in
// lib/core/domain/error/network_error.dart
enum NetworkError implements Error {
requestTimeout,
noInternet,
serverError,
unknown;
@override
String toString() => switch (this) {
NetworkError.requestTimeout => '요청 시간이 초과되었습니다',
NetworkError.noInternet => '인터넷 연결을 확인해 주세요',
NetworkError.serverError => '서버에 문제가 발생했습니다',
NetworkError.unknown => '알 수 없는 문제가 발생했습니다',
};
}
.NetworkError)는 lib/core/domain/error/ Shared errors go in lib/domain/error/<feature>_error.dart , and feature-specific errors go in BookmarkError, NewRecipeError.
. Example: 한 Result Multiple errors are not represented.
contains exactly one error. If you need to communicate multiple conditions at once, that is a domain design problem.
// UseCase 시그니처
Future<Result<List<String>, NetworkError>> execute();
Future<Result<List<Recipe>, BookmarkError>> execute(int recipeId);
- UseCase / Repository return types
DSpecify the success data typeEand the error type - .
NetworkError.unknownIn the Data layer, catch network/DB exceptions and convert them to values such as - . UseCase maps multiple Repository errors to its own feature error and returns it (e.g., when bookmark saving fails,).
BookmarkError.saveFailed).
Consuming in ViewModel
Since it is a sealed type, specify the type parameters and switch pattern match inhome_view_model.dart . This project uses the following form as the standard (see
void _fetchCategories() async {
final result = await _getCategoriesUseCase.execute();
switch (result) {
case ResultSuccess<List<String>, NetworkError>():
_state = state.copyWith(
categories: result.data,
selectedCategory: 'All',
);
notifyListeners();
case ResultError<List<String>, NetworkError>():
switch (result.error) {
case NetworkError.requestTimeout:
case NetworkError.noInternet:
case NetworkError.serverError:
case NetworkError.unknown:
_eventController.add(result.error);
}
}
}
).:
ResultSuccess<D, E>()/ResultError<D, E>()Why use it this wayresult.data/result.errorYou must write- to preserve generics and keep the concrete type of
switch (result.error)alive.
The inner
forces all enum cases to be listed, so when a new error is added, the compiler tells you the missing handling points.
How to deliver errors to the UI StreamController There are two standard patterns.
final _eventController = StreamController<NetworkError>();
Stream<NetworkError> get eventStream => _eventController.stream;
1) A snackbar/toast shown once — eventStream event ScaffoldMessenger.showSnackBar is called. Putting it in state causes it to repeat on every rebuild, so it is emitted as an event.
2) Persistent state (error banner) — State field
If the error screen itself must be drawn, State 에 NetworkError? error place a field and copyWith(error: ...) reflect it with . When the user closes or retries, error: null reset it with .
Data layer — the point where exceptions are converted to Result
Future<Result<List<RecipeDto>, NetworkError>> getRecipes() async {
try {
final raw = await _recipeDataSource.getRecipes();
return Result.success(raw.map(RecipeDto.fromJson).toList());
} on SocketException {
return const Result.error(NetworkError.noInternet);
} on TimeoutException {
return const Result.error(NetworkError.requestTimeout);
} catch (_) {
return const Result.error(NetworkError.unknown);
}
}
Principle:
- The layer where the exception occurs is the layer that catches it. Platform/HTTP exceptions are converted in Data, and domain validation failures in Domain to
Result.error. - Ensure that exceptions never rise up to Presentation. In the ViewModel,
try/catchappearing is a warning sign.
Decision table for which error type to use
| Scenario | Error type | Location |
|---|---|---|
| Network call failure | NetworkError |
core/domain/error/ |
| Local storage/DB failure | LocalError (new if needed) |
core/domain/error/ |
| Feature-specific failure (e.g., bookmark save failure) | BookmarkError same enum |
lib/domain/error/ |
| Repository that groups multiple DataSources | Parent error type (NetworkError or feature error) |
the corresponding feature |
Checklist
- The failure types of the new feature are defined as enum +
implements Errordefined as - The UseCase signature explicitly specifies the type parameter as
Future<Result<D, FooError>>specifies the type parameter as - In the ViewModel,
switch (result)가ResultSuccess<D, E>()/ResultError<D, E>()handle all of them - All values of the error enum are enumerated in the internal
switch (result.error)are listed in - The Data layer catches platform exceptions and converts them
Result.errorconverts to - Nowhere in Presentation does
try/catchfloat around
Anti-pattern
- ❌
Future<List<Recipe>>Return as is and pass the failurethrow→ the caller forgets the failure. - ❌
Result<List<Recipe>, Exception>Like ,Exceptionusing as the error type →Errorthe meaning of the marker disappears. - ❌ 한
ResultPutting multiple errors in a list in → the model becomes complex and UI branching explodes. - ❌
switchIn ,default:lumping together with → throws away the safety net where the compiler warns when a new error value is added.