Ozakboy.Mvvm 0.1.0

dotnet add package Ozakboy.Mvvm --version 0.1.0
                    
NuGet\Install-Package Ozakboy.Mvvm -Version 0.1.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Ozakboy.Mvvm" Version="0.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Ozakboy.Mvvm" Version="0.1.0" />
                    
Directory.Packages.props
<PackageReference Include="Ozakboy.Mvvm" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Ozakboy.Mvvm --version 0.1.0
                    
#r "nuget: Ozakboy.Mvvm, 0.1.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Ozakboy.Mvvm@0.1.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Ozakboy.Mvvm&version=0.1.0
                    
Install as a Cake Addin
#tool nuget:?package=Ozakboy.Mvvm&version=0.1.0
                    
Install as a Cake Tool

Ozakboy.Mvvm

MVVM building blocks for .NET 10. Three things, nothing more:

  1. ObservableObject — property-change notification that stays silent when nothing changed.
  2. RelayCommand / RelayCommand<T> — synchronous commands that refuse a parameter of the wrong type instead of guessing.
  3. AsyncRelayCommand / AsyncRelayCommand<T> — asynchronous commands that cannot be double-submitted, can be canceled, and never lose an exception.

繁體中文說明請見 README_zh-TW.md

Design notes

  • No third-party dependencies, no WPF reference. ICommand and INotifyPropertyChanged both live in the BCL (System.ObjectModel); the System.Windows.Input namespace is a historical name, not WPF. The package builds and tests on any OS and works with anything that binds to ICommand.
  • Defaults chosen for a UI where a mistake costs money. It was written for a trading front end: commands are non-reentrant unless you say otherwise, parameters are never silently converted, and a failed button does not take the application down.
  • Small on purpose. No source generators, no messenger, no IoC. Just the three types above.

Install

dotnet add package Ozakboy.Mvvm

Target framework: net10.0.

1. ObservableObject

using Ozakboy.Mvvm;

public sealed class OrderTicketViewModel : ObservableObject
{
    private decimal _price;
    private decimal _quantity;

    public decimal Price
    {
        get => _price;
        set
        {
            // SetProperty returns true only when the value actually changed,
            // which is exactly when the computed property needs a notification too.
            if (SetProperty(ref _price, value))
            {
                OnPropertiesChanged(nameof(Notional));
            }
        }
    }

    public decimal Quantity
    {
        get => _quantity;
        set
        {
            if (SetProperty(ref _quantity, value))
            {
                OnPropertiesChanged(nameof(Notional));
            }
        }
    }

    public decimal Notional => Price * Quantity;
}
  • Equal values raise nothing — neither PropertyChanging nor PropertyChanged.

  • PropertyChanging fires while the property still returns the old value; PropertyChanged fires after the write.

  • decimal and scale. The default comparer treats 1.0m and 1.00m as equal, so a change of scale alone is silent and the field keeps its old scale. If your view displays prices to an exchange tick size, pass a comparer:

    set => SetProperty(ref _price, value, ScaleAwareDecimalComparer.Instance);
    
  • Why dependent properties use OnPropertiesChanged(...) rather than a SetProperty overload: [CallerMemberName] must be an optional parameter and params must be the last one, so one overload cannot have both. Dependents also change for reasons other than a setter (recomputing after a fill, say), and a standalone method covers both cases.

2. RelayCommand

using Ozakboy.Mvvm.Input;

public RelayCommand ClearCommand { get; }
public RelayCommand<decimal> SetQuantityCommand { get; }

ClearCommand = new RelayCommand(Clear, () => HasInput);
SetQuantityCommand = new RelayCommand<decimal>(qty => Quantity = qty, qty => qty > 0m);

// Whenever HasInput changes:
ClearCommand.NotifyCanExecuteChanged();

Wrong parameter type. RelayCommand<T> accepts a parameter only if it is a T (derived types included), or null when T can be null. Anything else is a mismatch: CanExecute returns false, so the button stays disabled, and Execute throws ArgumentException, because reaching it means a bug. Nothing is converted:

Parameter RelayCommand<decimal> RelayCommand<decimal?> RelayCommand<string>
1.5m accepted accepted mismatch
5 (boxed int) mismatch mismatch mismatch
"5" (XAML CommandParameter="5") mismatch mismatch accepted
null mismatch accepted as null accepted as null

null is not turned into default(T): while a binding is still unresolved CommandParameter is null, and sending that as a quantity of zero is far worse than refusing.

Execute checks canExecute again. When it returns false, Execute does nothing and throws nothing. A WPF button asks CanExecute first, but not every caller is a button: a shortcut handler, a test, or another command forwarding the call can invoke Execute directly, and without the second check it would bypass the "may an order be placed right now" rule. For RelayCommand<T> the type check comes first, so a mismatched parameter still throws. Keep canExecute cheap and free of side effects — a button-triggered execution evaluates it twice — and remember it is a gate, not a replacement for the final validation inside the delegate (risk checks before an order). The same applies to AsyncRelayCommand, and it is a deliberate difference from CommunityToolkit.Mvvm; see Differences from CommunityToolkit.Mvvm.

3. AsyncRelayCommand

using Ozakboy.Mvvm.Input;

public AsyncRelayCommand SubmitOrderCommand { get; }

SubmitOrderCommand = new AsyncRelayCommand(
    async token => await _orders.SubmitAsync(BuildTicket(), token),
    () => IsConnected);
<Button Content="Submit" Command="{Binding SubmitOrderCommand}" />
<Button Content="Cancel" Command="{Binding CancelSubmitCommand}"
        IsEnabled="{Binding SubmitOrderCommand.CanBeCanceled}" />
<ProgressBar IsIndeterminate="True"
             Visibility="{Binding SubmitOrderCommand.IsRunning, Converter={StaticResource BoolToVisibility}}" />
<TextBlock Text="{Binding SubmitOrderCommand.LastException.Message}" Foreground="Red" />

No double submits

By default a running command refuses to start again. CanExecute returns false while it runs, and an Execute or ExecuteAsync that arrives anyway is refused: the delegate does not run, nothing is thrown, and ExecuteAsync returns a completed task. The gate is a compare-and-swap, so it holds even when the second click lands before the button has been disabled — CanExecuteChanged takes time to reach the UI. Sixteen threads calling Execute at the same instant start exactly one execution; the test suite checks this over 50 rounds.

For commands that are harmless to run twice (refreshing a view), opt out:

new AsyncRelayCommand(RefreshAsync, options: AsyncRelayCommandOptions.AllowConcurrentExecutions);

Exceptions

ICommand.Execute returns void, which is the async void trap: there is no task to carry an exception, so it gets thrown straight onto the SynchronizationContext. This package handles the two paths separately:

Started through Exception goes to
await command.ExecuteAsync() The returned task, always. Also recorded in LastException.
ICommand.Execute (a button) LastException (with change notification) and ExecutionTask. Not rethrown by default.
ICommand.Execute with RethrowExceptions Both of the above, and rethrown on the caller's SynchronizationContext, as async void would.

Why "not rethrown" is the default: in WPF, a rethrow is an unhandled Dispatcher exception, which closes the application by default. The moment a trading UI closes, nobody is watching the open positions — one failed button is not worth that. The exception is not swallowed either: bind LastException and show it. Turn on RethrowExceptions during development if you want failures to be loud.

LastException is set before IsRunning drops back to false, so the error is already readable when the busy indicator disappears. It is cleared when the next execution starts. Cancellation is not a failure: it is neither recorded nor rethrown.

Cancellation

Give the delegate a CancellationToken and the command becomes cancelable. Cancel() cancels every execution in progress; each new execution gets a fresh token. CanBeCanceled is true only while a cancelable execution is running and has not been asked to stop.

Differences from CommunityToolkit.Mvvm

The API shape is familiar on purpose, but one behaviour is deliberately different:

Calling Execute / ExecuteAsync directly while canExecute returns false Result
CommunityToolkit.Mvvm The delegate runs anyway.
Ozakboy.Mvvm (all four command types) Nothing runs and nothing is thrown; ExecuteAsync returns a completed task, exactly as when the re-entrancy gate refuses.

The reason is the audience: in a trading UI, canExecute usually encodes "may an order be placed right now" (connected, not in safe mode, a valid ticket). A WPF button asks before executing, but shortcut handlers, tests and commands that forward to other commands do not, and those paths must not be able to skip the rule. If code you are porting relied on Execute forcing its way past canExecute, call the underlying method directly instead.

Threading

Member Thread
ObservableObject.PropertyChanged / PropertyChanging The thread that called SetProperty. No marshalling, as in the BCL.
CanExecuteChanged (all commands) The SynchronizationContext captured when the command was constructed: posted there when raised from another thread, invoked directly when already on it, or on the raising thread when there was no context.
AsyncRelayCommand.PropertyChanged Start: the thread calling Execute. Finish: the context that was current when Execute was called. Cancel(): the thread calling it.
Command methods Callable from any thread.

SetProperty is not atomic — do not write the same property from several threads at once. Do not block on an ExecuteAsync task with .Wait() or .Result on the UI thread; completion returns to that thread and would deadlock.

Requirements

  • .NET 10

License

MIT. See LICENSE.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net10.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.1.0 91 9/11/2026