return_success_or_error 2.0.0
return_success_or_error: ^2.0.0 copied to clipboard
Usecase abstraction returning success or error from a call made by the data source.
return_success_or_error #
A pure Dart package that abstracts and simplifies usecases, datasources, parameters and
error handling following the Clean Architecture principles popularized by Uncle Bob. The
result of every call is wrapped in a sealed ReturnSuccessOrError<T>, so success and error
must always be handled explicitly.
Pure Dart: it has no Flutter dependency and runs in any Dart project (CLI, server, backend) as well as in Flutter apps.
The Problem #
In apps built with Clean Architecture, each feature follows the flow datasource → usecase → UI. Two recurring pain points emerge from this model:
-
Heavy processing blocks the UI. When the usecase needs to parse large payloads, aggregate thousands of rows, or transform complex data structures, the work runs on the main thread (event loop). In Flutter, that means dropped frames and frozen interfaces — the user sees the app "hanging" while the CPU is busy.
-
Datasources can't simply be moved to a background isolate. A datasource often holds native resources — database connections, sockets, platform channels — that are not serializable and cannot cross the isolate boundary. Sending the entire usecase (including its datasource) to
Isolate.runbreaks at runtime. -
Errors leak between layers. Without a standardized result type, exceptions thrown by the datasource propagate up unhandled, mixing infrastructure failures with business-logic errors and making the code fragile and hard to debug.
The solution #
return_success_or_error solves all three by design:
- Sealed result type (
ReturnSuccessOrError<T>) — every call returns eitherSuccessReturn<T>orErrorReturn<T>. The compiler forces exhaustive handling viaswitch; no exception can silently leak. - Fetch/process separation — the base class orchestrates the datasource call
(fetch) on the main isolate, keeping native resources safe. Only the pure
processing function (
process) can optionally run on a background isolate viarunInIsolate: true. Sinceprocessis a static function, it never capturesthisnor the datasource — only the raw data and the result cross the isolate boundary. - Automatic short-circuit — if the fetch fails, the error is returned immediately and the processing phase is never executed, avoiding unnecessary work.
- Feature-based organization — the package naturally guides the separation of each
feature into well-defined layers (
datasources/,domain/model/,domain/parameters/,domain/usecase/). Each piece has a single responsibility: the datasource handles only I/O, the model carries the processed data, the parameters define the typed input, and the usecase contains exclusively the business rule — with no coupling between them.
Concrete example: Sales Report
The sales_report example illustrates the full cycle. A datasource queries the
database and returns 50 thousand raw sale rows (fetch phase, async, on the main
isolate). The usecase receives those already-loaded rows and aggregates total revenue,
average ticket, and best-selling product into a SalesReport object (process phase,
CPU-bound, optionally on a background isolate). The UI never freezes:
sales_report/
datasources/
fake_sales_datasource.dart ← I/O: queries the database, returns List<Map>
domain/
model/
sales_report.dart ← Processed object (immutable, sendable)
parameters/
sales_report_parameters.dart ← Typed input (month, year, AppError)
usecase/
gerar_sales_report_usecase.dart ← Business rule: parsing + aggregation
With runInIsolate: true, the heavy processing runs on a background isolate and the
UI stays fluid. With monitorExecutionTime: true, you can compare direct vs. isolate
timing and decide which path pays off for each data volume.
Why use it #
- One return type for everything. Every call resolves to
ReturnSuccessOrError<T>— eitherSuccessReturn<T>orErrorReturn<T>. No exceptions leaking across layers. - Errors can't be ignored. Because the result is a sealed type, the compiler forces
you to handle both cases via an exhaustive
switch. - Clear separation of concerns. The business rule (usecase) is decoupled from the external call (datasource); the datasource is encapsulated and reached through a single bridge.
- Optional background processing. Any usecase can run its processing on a background
isolate by constructing it with
runInIsolate: true, keeping the app responsive during heavy work — while the datasource stays safely on the main isolate.
Core concepts #
| Type | Role |
|---|---|
ReturnSuccessOrError<T> |
Sealed result type: either SuccessReturn<T> or ErrorReturn<T>. |
SuccessReturn<T> |
Holds the success value, accessed via .result (type T). |
ErrorReturn<T> |
Holds the failure, accessed via .result (type AppError). |
UsecaseBase<T> |
Pure business rule, without any external call. |
UsecaseBaseCallData<T, D> |
Business rule that consumes a Datasource<D> and returns T. |
Datasource<D> |
Abstraction for the external call; returns D or throws parameters.error. |
ParametersReturnResult |
Carries the call data; must expose an AppError error. |
AppError / ErrorGeneric |
Immutable error contract / default implementation. |
NoParams |
Ready-made ParametersReturnResult for calls without extra parameters. |
Unit / unit |
Represents void as a result. |
Nil / nil |
Represents null as a result. |
Installation #
dependencies:
return_success_or_error: ^2.0.0
import 'package:return_success_or_error/return_success_or_error.dart';
How the flow works #
A feature flows from the usecase, optionally through a datasource, back into a
ReturnSuccessOrError:
caller
│ usecase(parameters) // call(parameters) — positional
▼
UsecaseBaseCallData.call
│ PHASE 1 — fetch: _datasource(parameters) // private, on the main isolate
│ └► throw parameters.error (failure) → ErrorReturn<D> (Cod. 02-1)
│ └► raw value D (success) → SuccessReturn<D>
▼
│ PHASE 2 — short-circuit: if ErrorReturn, return the error (process is NOT called)
▼
│ PHASE 3 — process(D, parameters) // static function, direct or in isolate
▼
ReturnSuccessOrError<T> → switch (exhaustive pattern matching)
Key points:
- The base orchestrates everything: it calls the datasource, short-circuits on error, and
only then calls
processwith the raw, already-loaded data. The subclass implements only theprocessgetter (a static function) — it never touches the (private) datasource. - The datasource signals failure by throwing
parameters.error; the base catches it and returns anErrorReturnwhose message is enriched (viacopyWith) with the catch context (Cod. 02-1) — the original error type is preserved. - The fetch (phase 1) always runs on the main isolate, so datasources holding native
resources (database connection, socket) work normally. With
runInIsolate: true, onlyprocess(phase 3) runs on a background isolate — see Running on a background isolate.
Usage, step by step #
1. Define the error — AppError / ErrorGeneric #
AppError is the immutable error contract (it implements Exception). Use the default
ErrorGeneric, or implement your own. To add context as the error bubbles up, never mutate
it — create a copy with copyWith:
const error = ErrorGeneric(message: "Connection error");
final enriched = error.copyWith(message: "Connection error - timeout");
A custom error keeps the same contract:
final class ApiError implements AppError {
@override
final String message;
final int statusCode;
const ApiError({required this.message, required this.statusCode});
@override
ApiError copyWith({String? message}) =>
ApiError(message: message ?? this.message, statusCode: statusCode);
}
Since
AppErroris an interface used withimplements, it only enforcesmessageandcopyWith— there is no behavior inheritance. Value equality (==/hashCode) and a readabletoStringdo not come for free: override them in your custom error when you want to compare it by value (handy in tests) or print it in a friendly way, likeErrorGenericdoes.
2. Define the parameters — ParametersReturnResult / NoParams #
ParametersReturnResult is a pure interface: the only requirement is to expose the AppError returned on failure. Add whatever data your API/database call or processing needs:
final class SalesReportParameters implements ParametersReturnResult {
final int mes;
final int ano;
@override
final AppError error;
const SalesReportParameters({
required this.mes,
required this.ano,
required this.error,
});
}
When the call does not need extra data, you can use the NoParams helper provided by the library:
final params = NoParams(error: const ErrorGeneric(message: "Unexpected error"));
3. Define the datasource — Datasource<D> #
Type it with the raw data it returns (for example, List<Map<String, dynamic>> from the database or raw JSON). Wrap the logic in a try/catch and throw parameters.error on failure (the usecase's fetch phase automatically captures it and triggers short-circuit):
final class FakeSalesDatasource implements Datasource<List<Map<String, dynamic>>> {
const FakeSalesDatasource();
@override
Future<List<Map<String, dynamic>>> call(covariant SalesReportParameters parameters) async {
try {
// Simulates database query (async I/O that doesn't block the UI)
await Future.delayed(const Duration(milliseconds: 100));
return [
{'produto': 'Produto A', 'quantidade': 10, 'valor_unitario': 50.0},
{'produto': 'Produto B', 'quantidade': 5, 'valor_unitario': 100.0},
];
} catch (e) {
throw parameters.error.copyWith(message: "$e");
}
}
}
4. Define the usecase #
a) With an external datasource — UsecaseBaseCallData<TypeUsecase, TypeDatasource>
TypeUsecase is the type of the processed domain object returned by the usecase; TypeDatasource is the raw type returned by the datasource. The datasource is forwarded through the constructor with a super parameter ({required super.datasource}) and kept private in the base class.
The subclass only implements the process getter, pointing to a static function that receives the raw data already loaded (the base handles the fetch and the short-circuit on fetch error):
final class GerarSalesReportUsecase
extends UsecaseBaseCallData<SalesReport, List<Map<String, dynamic>>> {
GerarSalesReportUsecase({
required super.datasource,
super.runInIsolate,
super.monitorExecutionTime,
});
@override
ProcessData<SalesReport, List<Map<String, dynamic>>> get process => _process;
// Static function: essential not to capture "this" so it can run in an Isolate.
static ReturnSuccessOrError<SalesReport> _process(
List<Map<String, dynamic>> linhas,
ParametersReturnResult parameters,
) {
if (linhas.isEmpty) {
return ErrorReturn(
error: parameters.error.copyWith(message: "No sales in this period"),
);
}
var faturamento = 0.0;
var itens = 0;
for (final row in linhas) {
final quantidade = row['quantidade'] as int;
faturamento += quantidade * (row['valor_unitario'] as double);
itens += quantidade;
}
return SuccessReturn(
success: SalesReport(
totalItens: itens,
faturamentoTotal: faturamento,
),
);
}
}
processmust be static (or top-level): it is what runs in the background isolate whenrunInIsolate: true. An instance function would implicitly capturethis— dragging the entire datasource (and its native resources like DB drivers or network connections) into the isolate, causing runtime or compilation errors. If you need specific fields from the parameters, castparametersto your concrete type inside_process.
b) Business rule only — UsecaseBase<TypeUsecase>
When there is no external call, extend UsecaseBase and implement process taking just the parameters. For example, calculating sales commission based on total sales revenue:
final class CalcularComissaoParameters implements ParametersReturnResult {
final double valorTotal;
@override
final AppError error;
const CalcularComissaoParameters({required this.valorTotal, required this.error});
}
final class CalcularComissaoUsecase extends UsecaseBase<double> {
const CalcularComissaoUsecase({super.runInIsolate});
@override
ProcessPure<double> get process => _process;
static ReturnSuccessOrError<double> _process(ParametersReturnResult parameters) {
final params = parameters as CalcularComissaoParameters;
return SuccessReturn(success: params.valorTotal * 0.05); // 5% commission
}
}
5. Call the usecase #
Instantiate it and invoke it passing the concrete parameters:
final usecase = GerarSalesReportUsecase(
datasource: const FakeSalesDatasource(),
runInIsolate: true, // Heavy processing will run in a background Isolate!
);
final data = await usecase(
SalesReportParameters(
mes: 6,
ano: 2026,
error: const ErrorGeneric(message: "Failed to generate sales report"),
),
);
6. Handle the result #
ReturnSuccessOrError<T> is a sealed class, ensuring you handle all scenarios exhaustively using a switch:
switch (data) {
case SuccessReturn<SalesReport>():
print("Revenue: ${data.result.faturamentoTotal}"); // success value (SalesReport)
case ErrorReturn<SalesReport>():
print(data.result.message); // AppError
}
You can also use Dart 3 destructuring patterns for a more concise syntax:
final message = switch (data) {
SuccessReturn(:final result) => 'Success! Revenue: ${result.faturamentoTotal}',
ErrorReturn(:final result) => 'Failure: ${result.message}',
};
7. Running on a background isolate #
Both base classes accept runInIsolate: true in the constructor. When enabled, only
process runs on a background isolate via Isolate.run; when disabled (the default), it runs
inline. In UsecaseBaseCallData, the datasource fetch always runs on the main isolate —
only the processing (phase 3) goes to the isolate. To measure and log the elapsed time (via
dart:developer, with a (Direct)/(Isolate) suffix), also enable
monitorExecutionTime: true — off by default, keeping production cost at zero:
final usecase = GerarSalesReportUsecase(
datasource: const FakeSalesDatasource(),
runInIsolate: true,
monitorExecutionTime: true,
);
final result = await usecase(parameters);
Since
processis static, it does not capture the datasource. Only the raw data (input) and the result (output) cross the isolate boundary — both must be sendable. That's why the datasource can hold non-sendable resources (sockets, database connections): they stay on the main isolate and never go to the worker.When to enable:
Isolate.runhas a fixed cost (spawn + serializing the input/output), which scales with the data size. It pays off for heavy processing (parsing large lists, aggregations); for light transforms the overhead outweighs the gain — keeprunInIsolate: false. UsemonitorExecutionTimeto compare both paths.
8. Results without a value — Unit / Nil #
For usecases that succeed without producing a value, use the shared singletons unit
(stands for void) or nil (stands for null):
final class LogoutUsecase extends UsecaseBase<Unit> {
const LogoutUsecase();
@override
ProcessPure<Unit> get process => _process;
static ReturnSuccessOrError<Unit> _process(ParametersReturnResult parameters) {
// ... perform side effect ...
return const SuccessReturn(success: unit);
}
}
Suggested feature hierarchy #
lib/
features/
sales_report/
datasources/
fake_sales_datasource.dart
domain/
model/
sales_report.dart
parameters/
sales_report_parameters.dart
usecase/
gerar_sales_report_usecase.dart
main.dart
Example #
The example/ directory contains a pure Dart (CLI) example demonstrating
the package without Flutter:
check_connection— aUsecaseBaseCallDataconsuming aDatasource(success, business error and a captured exception).fibonacci— aUsecaseBaserunning on a background isolate viarunInIsolate: true.sales_report— demonstrates the fetch → process flow: the datasource returns 50k raw sale rows (fetch phase, main isolate) and theprocess(static function) aggregates them into aSalesReportobject (processing phase, background isolate). IncludesmonitorExecutionTimefor comparing direct vs. isolate execution times.
Run it with dart run bin/example.dart and the tests with dart test.
Environment #
- Dart SDK
^3.12.0(uses Dart 3 features: sealed classes, pattern matching, class modifiers, and Dart 3.12 private named parameters). - Depends only on
package:meta(for@protected/@immutable) — no Flutter.