Guidelines for creating and maintaining command providers, which handle mutations and side effects in Flutter/Riverpod.
Commands are use cases that handle mutations and side effects. They connect business rules with infrastructure and have triple states: loading, success, and error.
Path: lib/features/<feature>/providers/<action>_command.dart
All commands must use CommandMixin<T> from lib/shared/providers/command_provider_base_mixin.dart.
| Method | Purpose |
|---|---|
invalidState() | Returns error state for initial build |
emitLoading() | Sets loading state with check |
ref.mountedemitData(T data) | Sets success state, returns Ok(data) |
emitError<E>(E error, [StackTrace?]) | Sets error state, returns Err(error) |
emitResult(Result<T>, [StackTrace?]) | Handles Result pattern automatically |
// lib/shared/providers/command_provider_base_mixin.dart
import 'package:odu_core/odu_core.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
AsyncValue<T> invalidState<T>() => AsyncError<T>(
StateError('Not called yet'),
StackTrace.current,
);
mixin CommandMixin<T> on $Notifier<AsyncValue<T>> {
Result<T> emitError<E extends Exception>(E error, [StackTrace? stackTrace]) {
if (ref.mounted) {
state = AsyncError(error, stackTrace ?? StackTrace.current);
}
return Err(error, stackTrace ?? StackTrace.current);
}
Result<T> emitData(T data) {
if (ref.mounted) {
state = AsyncData(data);
}
return Ok(data);
}
void emitLoading() {
if (ref.mounted) {
state = const AsyncLoading();
}
}
Result<T> emitResult(Result<T> result, [StackTrace? stackTrace]) {
if (ref.mounted) {
state = switch (result) {
Ok<T>(value: final data) => AsyncData(data),
Err<T>(value: final error) => AsyncError(
error,
stackTrace ?? StackTrace.current,
),
};
}
return result;
}
}
@riverpod
class AddExercise extends _$AddExercise with CommandMixin<Exercise> {
@override
AsyncValue<Exercise> build() {
return invalidState();
}
Future<void> call(DailyTraining training, Exercise exercise) async {
emitLoading();
final repository = ref.read(trainingRepositoryProvider);
training.addExercise(exercise);
final result = await repository.store(training).map((_) => exercise);
emitResult(result);
}
}
| Element | Convention | Example |
|---|---|---|
| File name | *_command.dart | add_exercise_command.dart |
| Class name | Action verb | AddExercise, UpdateUser |
| Method | Always call() | Future<void> call(...) |
Future<void> - Fire and ForgetFuture<void> call(DailyTraining training, Exercise exercise) async {
emitLoading();
final result = await repository.store(training).map((_) => exercise);
emitResult(result);
}
FutureResult<T> - Caller Needs ResultFutureResult<Exercise> call(
DailyTraining training, {
required PositionedExercise exercise,
}) async {
emitLoading();
training.setExercise(exercise);
final result = await ref
.read(trainingRepositoryProvider)
.store(training)
.map((_) => exercise.value);
return emitResult(result);
}
@riverpod
class MergeExercises extends _$MergeExercises with CommandMixin<Unit> {
@override
AsyncValue<Unit> build() => invalidState();
Future<void> call(
DailyTraining training, {
required List<PositionedExercise> exercises,
}) async {
emitLoading();
training.mergeExercises(exercises);
final result = await ref
.read(trainingRepositoryProvider)
.store(training)
.map((_) => unit);
emitResult(result);
}
}
When modifying existing commands:
emitLoading() is called at the start and emitResult() (or equivalent) at the end.call method signature, ensure all call sites are updated.ref.read() within the call method, not ref.watch().// BAD: Validation belongs in entity
Future<void> call(DailyTraining training, Exercise exercise) async {
if (exercise.name.isEmpty) {
emitError(ValidationException('Name required'));
return;
}
// ...
}
// BAD: One action per provider
class ExerciseManager extends _$ExerciseManager with CommandMixin<void> {
Future<void> add(...) async { ... }
Future<void> update(...) async { ... }
Future<void> delete(...) async { ... }
}
// BAD: Missing mixin and invalidState()
class AddExercise extends _$AddExercise {
@override
AsyncValue<Exercise> build() => AsyncData(Exercise.empty());
}
// BAD: Use emitResult() instead
state = switch (result) {
Ok() => AsyncData(exercise),
Err(value: final err) => AsyncError(err, StackTrace.current),
};
| Smell | Solution |
|---|---|
| Business logic in provider | Move to entity domain methods |
| Multiple actions per provider | Create separate commands |
| Missing loading state | Call emitLoading() |
ref.watch() in methods | Use ref.read() |
AsyncData in build | Use invalidState() |
| Manual state switching | Use emitResult() |
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'your_command.g.dart';
dart run build_runner build -d