Skip to content

New features: Hybrid Error Filtering, ErrorHandlerRegistry, Lifecycle Hooks #10

@escamoteur

Description

@escamoteur

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 errorFilter parameter

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, globalExceptionHandler is 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 onAfterExecute are 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 shouldRetry returns true (default: retry all errors)

Summary of Changes

v8.1.0

  • Add: ErrorFilterFn typedef
  • Add: errorFilterFn parameter 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.hookPolicy property
  • 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: RetryStrategies built-in strategies
  • Breaking: None

Backward Compatibility

All changes are additive and opt-in

  • Existing code works unchanged
  • New features must be explicitly used
  • globalExceptionHandler continues to work
  • Existing errorFilter parameter unchanged
  • Zero overhead if features not used

Open Questions

  1. RetryableCommand execution approach - Should retry use executeWithFuture() or listen to command result streams?
  2. Async retry strategy - Should onRetry support async functions (for token refresh, etc.)?
  3. Retry state observability - Should retry state be observable (currentAttempt, isRetrying)?

Feedback Requested

  1. Are the APIs clear and intuitive?
  2. Are there use cases these features don't cover?
  3. Are there simpler alternatives we should consider?
  4. Should any behaviors be different (e.g., registry exclusive matching vs all matching)?
  5. Any concerns about the pattern-based hook routing?

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestquestionFurther information is requested

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions