Why commands live on the ViewModel
A command is the ViewModel-facing handle for a user action. The page binds Button.Command; the ViewModel owns the work, CanExecute, and cancellation. That keeps code-behind empty and lets tests call ExecuteAsync without a visual tree.
- ModelCommand / ModelCommand<T> — synchronous ICommand; weak CanExecuteChanged.
- AsyncModelCommand / AsyncModelCommand<T> — async, IsRunning, Cancel, ExecuteAsync; weak CanExecuteChanged.
- ICommand.Execute never throws. Failures go to IErrorSink / IDialogs. ExecuteAsync still rethrows.
- CanExecuteChanged, IsRunning, and State raise on IMainThread (0.6.0+). Bind Button.Command only on 0.6.0+.
AsyncModelCommand
AsyncModelCommand runs through IOperationExecutor: CanExecute, a concurrency gate, optional timeout, optional retry, optional debounce / throttle, then the delegate. IsRunning is atomic. Cancel and ViewModel.Dispose cancel in-flight work. ConcurrencyMode values are Prevent, CancelPrevious, Queue, Allow, and Replace.
SaveCommand = new AsyncModelCommand(
SaveAsync,
() => CanSave,
new AsyncCommandOptions
{
Concurrency = ConcurrencyMode.Prevent,
Timeout = TimeSpan.FromSeconds(15),
RetryCount = 2,
RetryDelay = TimeSpan.FromSeconds(1),
});Binding and CanExecute
Call NotifyCanExecuteChanged when the condition changes (a property setter, a navigation stack change, an auth flip). CanExecuteChanged is a weak event so a Button on a popped page does not pin the command. Do not allocate a new command per execute. Do not store a static command on a long-lived service — that pins the ViewModel graph.