Playing around with the clean architecture, I often found myself rewriting the generic code of my usecases.
These class enable you to encapsulate your logic in an atomic elements that you can then inject and use throughout your application.
- Simple and easy to use API
- Fully tested (100% coverage)
- Fully documented
Available usecase types:
Usecase<Input, Output>
NoParamsUsecase<Output>
StreamUsecase<Input, Output>
NoParamsStreamUsecase<Output>
Let's say you want to add two numbers together, you can create a usecase like this:
class AdditionUsecase extends Usecase<int, int> {
const AdditionUsecase();
@override
FutureOr<int> execute(int params) async => params + params;
}
The execute
method is the one that will be called when you call the call
method on your usecase.
final addition = AdditionUsecase();
await addition(2).then(print, onError: print); // 4
You can use a stream usecase to return a Stream
instead of a raw value:
class GeneratorUsecase extends NoParamsStreamUsecase<int> {
const GeneratorUsecase();
@override
Stream<int> execute() async* {
for (int i = 0; i < 10; i++) {
await Future<void>.delayed(const Duration(seconds: 1));
yield i;
}
}
}
You can then use it like this:
final generator = GeneratorUsecase();
final stream = generator();
stream.listen(
print,
onError: print,
onDone: () => print('Done'),
);
You can add a precondition check to your usecase, which will be executed before the execute
method:
class DivisionUsecase extends Usecase<(int, int), double> {
const DivisionUsecase();
@override
FutureOr<ConditionsResult> checkPreconditions((int, int)? params) {
if (params == null) {
return ConditionsResult(isValid: false, message: 'Params is null');
}
if (params.$2 == 0) {
return ConditionsResult(isValid: false, message: 'Cannot divide by 0');
}
return ConditionsResult(isValid: true);
}
@override
FutureOr<double> execute((int, int) params) async => params.$1 / params.$2;
}
You can also add a postcondition check to your usecase, which will be executed after the execute
method:
class AdditionUsecase extends Usecase<int, int> {
const AdditionUsecase();
@override
FutureOr<int> execute(int params) async => params + params;
@override
FutureOr<ConditionsResult> checkPostconditions(int? result) {
if (result == null) {
return ConditionsResult(isValid: false, message: 'Result is null');
}
if (result < 0) {
return ConditionsResult(isValid: false, message: 'Result is negative');
}
return ConditionsResult(isValid: true);
}
}
You can catch exceptions thrown by your usecase by overriding the onException
method:
class AdditionUsecase extends Usecase<int, int> {
const AdditionUsecase();
@override
FutureOr<int> execute(int params) async => params + params;
@override
FutureOr<int> onException(Object e) {
print(e); // Prints the exception
return super.onException(e);
}
}
This method will be called when an exception is thrown by the execute
method. It will also be called when a precondition or postcondition check fails.
By assembling the previous examples, you can create a usecase that returns a Result
object. By catching exceptions and checking preconditions and postconditions, you can return a Result
object that will be either a Success
or a Failure
:
This example uses the sealed_result package.
class DivisionResultUsecase extends Usecase<(int, int), Result<double, Failure>> {
const DivisionResultUsecase();
@override
FutureOr<ConditionsResult> checkPreconditions((int, int)? params) {
if (params == null) {
return ConditionsResult(isValid: false, message: 'Params is null');
}
if (params.$2 == 0) {
return ConditionsResult(isValid: false, message: 'Cannot divide by 0');
}
return ConditionsResult(isValid: true);
}
@override
FutureOr<Result<double, Failure>> execute((int, int) params) async =>
Result.success(params.$1 / params.$2);
@override
FutureOr<Result<double, Failure>> onException(Object e) {
if (e case UsecaseException _) {
return Result.failure(Failure(e.message ?? ''));
}
if (e case Exception || Error) {
return Result.failure(Failure(e.toString()));
}
return Result.failure(Failure(''));
}
}