-
Notifications
You must be signed in to change notification settings - Fork 1
Description
command_it API Proposal: v8.1.0 & v9.0.0
Status: Seeking feedback
Breaking Changes: None - all features are additive and opt-in
Highlights
v8.1.0: Hybrid Error Filtering
- Use simple functions for error filtering (alternative to error filter objects)
- Fully backward compatible with existing
errorFilterparameter
v9.0.0: Three Major Features
1. ErrorHandlerRegistry - Declarative type-based error routing
- Route different error types to different handlers
- Priority-based execution (critical → high → normal → low)
- Replaces if/else chains in
globalExceptionHandler
2. Lifecycle Hooks - Intercept command execution at key points
- Block execution based on conditions (guards)
- Validate and transform results
- Recover from errors or override error routing
- Add analytics and logging without coupling to command logic
- Pattern-based routing with per-command control
3. RetryableCommand - Flexible retry decorator
- Wrap any command to add retry capability
- Exponential backoff, batch size reduction, API fallback
- Dynamic command/parameter switching between retries
- Built-in strategies included
All changes are additive - existing code continues to work unchanged.
v8.1.0: Hybrid Error Filtering
Problem
Current errorFilter parameter accepts any callable, making it impossible to catch errors at compile-time when the function signature is wrong.
Solution
Add errorFilterFn parameter with explicit type signature alongside existing errorFilter.
API Changes
// New typedef
typedef ErrorFilterFn = ErrorReaction? Function(
Object error,
StackTrace stackTrace,
);
// Added to all 12 factory methods
Command.createAsync(
func,
initialValue,
{
errorFilter, // Existing - accepts ErrorFilter objects
errorFilterFn, // NEW - accepts functions with type checking
// ... other parameters
}
)Usage
// Before: No compile-time checking
Command.createAsync(
fetchData,
[],
errorFilter: (e) => ..., // Wrong signature compiles!
);
// After: Full type checking
Command.createAsync(
fetchData,
[],
errorFilterFn: (e, s) => e is NetworkException
? ErrorReaction.globalHandler
: null, // ✅ Compile error if signature wrong
);Backward compatibility: Existing code continues to work unchanged. Both parameters are optional and mutually exclusive (assertion enforces).
v9.0.0: Three New Features
1. ErrorHandlerRegistry - Type-Based Error Routing
Problem
Users write repetitive if/else chains in globalExceptionHandler to route different error types to different handlers.
Solution
Declarative registry for type-based error routing with priorities.
API
// Static registry on Command class
class Command {
static final ErrorHandlerRegistry errorRegistry;
}
// Registration
Command.errorRegistry.on<ErrorType>(
void Function(ErrorType error, CommandError context) handler,
{
bool Function(ErrorType error)? when, // Optional predicate
HandlerPriority priority = HandlerPriority.normal,
String? name, // For removal
}
);
// Priorities (higher runs first)
enum HandlerPriority {
critical(1000),
high(100),
normal(50), // default
low(10),
}
// Removal
Command.errorRegistry.remove(String name);
Command.errorRegistry.removeType<ErrorType>();
Command.errorRegistry.clear();Usage Example
void main() {
// Auth errors → show login
Command.errorRegistry.on<AuthException>(
(error, context) => showLoginDialog(),
when: (e) => e.statusCode == 401,
priority: HandlerPriority.high,
);
// Network errors → show snackbar
Command.errorRegistry.on<NetworkException>(
(error, context) => showSnackBar('Connection issue'),
);
// Catch-all → generic error
Command.errorRegistry.on<Object>(
(error, context) => showGenericError(),
priority: HandlerPriority.low, // Runs last
);
runApp(MyApp());
}Key behaviors:
- All matching handlers execute (not exclusive)
- Handlers run in priority order (high to low)
- If any handler matches,
globalExceptionHandleris NOT called - Predicates allow fine-grained control (e.g., specific HTTP status codes)
2. Lifecycle Hooks - Pattern-Based Execution Guards & Transformations
Problem
No way to globally intercept command execution for:
- Guards (block execution imperatively)
- Result validation/transformation
- Error recovery/routing override
- Analytics/logging side effects
Solution
Four hook types with pattern-based routing (first match wins).
API
// Hook types
typedef BeforeExecuteHook = void Function(Command command, dynamic param);
typedef BeforeSuccessHook<T> = CommandHookResult<T>? Function(Command command, T result, dynamic param);
typedef BeforeErrorHook<T> = CommandHookResult<T>? Function(Command command, Object error, StackTrace stackTrace, dynamic param);
typedef AfterExecuteHook = void Function(Command command, CommandResult result);
// Result wrapper (for transformation hooks)
class CommandHookResult<T> {
final T? data;
final ErrorReaction? overrideErrorReaction; // Only for onBeforeError
CommandHookResult(this.data, {this.overrideErrorReaction});
CommandHookResult.success(T data); // Recover error → success
CommandHookResult.swallow(); // Suppress error (ErrorReaction.none)
CommandHookResult.forceGlobal(); // Route to global handler
CommandHookResult.forceLocal(); // Route to local listeners
}
// Pattern matching predicate
typedef HookPredicate = bool Function(Command command);
// Global policy
enum HookPolicy {
call, // Always call (strict, no override)
neverCall, // Never call (strict, no override)
defaultCall, // Call by default (commands can opt-out)
defaultNeverCall, // Skip by default (commands can opt-in)
}
// Static hook registration
class Command {
static HookPolicy hookPolicy = HookPolicy.defaultCall;
static void addBeforeExecuteHook({
required HookPredicate when,
required BeforeExecuteHook hook,
String? name,
});
static void addBeforeSuccessHook<TResult>({
required HookPredicate when,
required BeforeSuccessHook<TResult> hook,
String? name,
});
static void addBeforeErrorHook<TResult>({
required HookPredicate when,
required BeforeErrorHook<TResult> hook,
String? name,
});
static void addAfterExecuteHook({
required HookPredicate when,
required AfterExecuteHook hook,
String? name,
});
}
// Per-command overrides (added to all factory methods)
Command.createAsync(
func,
initialValue,
{
bool? callBeforeExecuteHook, // null = use policy
bool? callBeforeSuccessHook, // null = use policy
bool? callBeforeErrorHook, // null = use policy
bool? callAfterExecuteHook, // null = use policy
// ... other parameters
}
)Usage Examples
Execution Guards:
void main() {
Command.hookPolicy = HookPolicy.defaultCall;
// Admin commands need permission check
Command.addBeforeExecuteHook(
when: (cmd) => cmd.name?.startsWith('admin') ?? false,
hook: (cmd, param) {
if (!authService.isAdmin) throw PermissionDeniedException();
},
);
// Catch-all: Basic auth check (added LAST)
Command.addBeforeExecuteHook(
when: (cmd) => true,
hook: (cmd, param) {
if (!authService.isLoggedIn) throw NotLoggedInException();
},
);
runApp(MyApp());
}Result Validation:
// Validate User results from fetch commands
Command.addBeforeSuccessHook<User>(
when: (cmd) => cmd.name?.startsWith('fetch'),
hook: (cmd, user, param) {
if (user.id.isEmpty) {
throw ValidationException('Invalid user'); // Convert to error
}
// Transform if needed
if (user.name.isEmpty) {
return CommandHookResult.success(user.copyWith(name: 'Anonymous'));
}
return null; // Pass through unchanged
},
);Error Recovery & Routing:
// Recover network errors with cache
Command.addBeforeErrorHook<List<Item>>(
when: (cmd) => cmd.name?.contains('List') ?? false,
hook: (cmd, error, stack, param) {
if (error is NetworkException && cache.hasData(param)) {
return CommandHookResult.success(cache.getData(param)); // Recover
}
return null; // Can't handle
},
);
// Swallow cancellation errors
Command.addBeforeErrorHook<Object>(
when: (cmd) => true,
hook: (cmd, error, stack, param) {
if (error is CancelledException) {
return CommandHookResult.swallow(); // Don't propagate
}
return null;
},
);
// Force critical errors to global handler
Command.addBeforeErrorHook<Object>(
when: (cmd) => cmd.name?.startsWith('critical'),
hook: (cmd, error, stack, param) {
if (error is CriticalException) {
return CommandHookResult.forceGlobal(); // Override errorFilter
}
return null;
},
);Analytics:
Command.addAfterExecuteHook(
when: (cmd) => true,
hook: (cmd, result) {
if (result.hasError) {
analytics.logError(cmd.name, result.error);
sentry.captureException(result.error);
}
},
);Per-command opt-out:
// Most commands use hooks (policy is defaultCall)
final normal = Command.createAsync(fetchData, null);
// Internal command skips hooks
final internal = Command.createAsync(
internalHelper,
null,
callBeforeExecuteHook: false, // Skip guards
callBeforeSuccessHook: false, // Skip validation
callAfterExecuteHook: false, // Skip analytics
);Key behaviors:
- First match wins - Only one hook executes per command
- Registration order matters (specific patterns first, catch-all last)
- Hooks can block execution (throw), transform data, recover errors, or override routing
- Global policy with per-command overrides
- Hook exceptions in
onAfterExecuteare logged but don't affect result
3. RetryableCommand - Flexible Retry Decorator
Problem
No built-in retry capability. Users must manually implement retry logic for each command.
Solution
Decorator that wraps any command to add flexible retry with dynamic strategies.
API
class RetryableCommand<TParam, TResult> extends Command<TParam, TResult> {
RetryableCommand(
Command<TParam, TResult> wrappedCommand,
{
int maxAttempts = 3,
Duration delay = const Duration(seconds: 2),
bool Function(Object error, int attempt)? shouldRetry,
RetryStrategy<TParam, TResult>? onRetry,
}
);
}
// Strategy determines what to do on each retry
typedef RetryStrategy<TParam, TResult> = RetryAction<TParam, TResult> Function(
Object error,
TParam? originalParam,
int attemptNumber,
);
// What action to take
class RetryAction<TParam, TResult> {
RetryAction.simple({Duration? delay}); // Retry same command/param
RetryAction.withParam(TParam? param, {Duration? delay}); // Retry with modified param
RetryAction.withCommand(Command<TParam, TResult> cmd, TParam? param, {Duration? delay}); // Switch command
RetryAction.giveUp(); // Stop retrying
}Usage Examples
Simple retry:
final cmd = RetryableCommand(
Command.createAsync(fetchUser, null),
maxAttempts: 3,
delay: Duration(seconds: 2),
shouldRetry: (error, attempt) => error is NetworkException,
);Exponential backoff:
final cmd = RetryableCommand(
Command.createAsync(apiCall, null),
maxAttempts: 5,
onRetry: (error, param, attempt) {
final backoff = Duration(seconds: math.pow(2, attempt).toInt());
return RetryAction.simple(delay: backoff);
},
);Reduce batch size on timeout:
final cmd = RetryableCommand(
Command.createAsync(processBatch, null),
onRetry: (error, param, attempt) {
final newSize = param.batchSize ~/ math.pow(2, attempt);
if (newSize < 10) return RetryAction.giveUp();
return RetryAction.withParam(param.copyWith(batchSize: newSize));
},
);Fallback to different API:
final primary = Command.createAsync(fetchFromPrimary, null);
final backup = Command.createAsync(fetchFromBackup, null);
final cmd = RetryableCommand(
primary,
maxAttempts: 3,
onRetry: (error, param, attempt) {
if (attempt == 1) {
return RetryAction.simple(); // Retry primary
} else {
return RetryAction.withCommand(backup, param); // Switch to backup
}
},
);Built-in strategies:
RetryStrategies.exponentialBackoff()
RetryStrategies.exponentialBackoffWithJitter(maxJitterMs: 1000)
RetryStrategies.linearBackoff(baseDelay: Duration(seconds: 1))
RetryStrategies.constantDelay(delay: Duration(seconds: 2))Key behaviors:
- Decorator pattern (wraps any command)
- Can switch commands between retries (fallback APIs)
- Can modify parameters between retries (reduce batch size, increase timeout)
- Composable with other decorators
- Only retries if
shouldRetryreturns true (default: retry all errors)
Summary of Changes
v8.1.0
- Add:
ErrorFilterFntypedef - Add:
errorFilterFnparameter to all 12 factory methods - Breaking: None
v9.0.0
- Add: Static
Command.errorRegistry(ErrorHandlerRegistry) - Add: Static hook registration methods on Command class
- Add: Static
Command.hookPolicyproperty - Add: Hook override parameters to all factory methods (
callBeforeExecuteHook, etc.) - Add:
CommandHookResult<T>class - Add:
RetryableCommand<TParam, TResult>decorator class - Add:
RetryAction<TParam, TResult>class - Add:
RetryStrategy<TParam, TResult>typedef - Add:
RetryStrategiesbuilt-in strategies - Breaking: None
Backward Compatibility
✅ All changes are additive and opt-in
- Existing code works unchanged
- New features must be explicitly used
globalExceptionHandlercontinues to work- Existing
errorFilterparameter unchanged - Zero overhead if features not used
Open Questions
- RetryableCommand execution approach - Should retry use
executeWithFuture()or listen to command result streams? - Async retry strategy - Should
onRetrysupport async functions (for token refresh, etc.)? - Retry state observability - Should retry state be observable (
currentAttempt,isRetrying)?
Feedback Requested
- Are the APIs clear and intuitive?
- Are there use cases these features don't cover?
- Are there simpler alternatives we should consider?
- Should any behaviors be different (e.g., registry exclusive matching vs all matching)?
- Any concerns about the pattern-based hook routing?