Ozakboy.Mvvm
0.1.0
dotnet add package Ozakboy.Mvvm --version 0.1.0
NuGet\Install-Package Ozakboy.Mvvm -Version 0.1.0
<PackageReference Include="Ozakboy.Mvvm" Version="0.1.0" />
<PackageVersion Include="Ozakboy.Mvvm" Version="0.1.0" />
<PackageReference Include="Ozakboy.Mvvm" />
paket add Ozakboy.Mvvm --version 0.1.0
#r "nuget: Ozakboy.Mvvm, 0.1.0"
#:package Ozakboy.Mvvm@0.1.0
#addin nuget:?package=Ozakboy.Mvvm&version=0.1.0
#tool nuget:?package=Ozakboy.Mvvm&version=0.1.0
Ozakboy.Mvvm
MVVM building blocks for .NET 10. Three things, nothing more:
ObservableObject— property-change notification that stays silent when nothing changed.RelayCommand/RelayCommand<T>— synchronous commands that refuse a parameter of the wrong type instead of guessing.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.
ICommandandINotifyPropertyChangedboth live in the BCL (System.ObjectModel); theSystem.Windows.Inputnamespace is a historical name, not WPF. The package builds and tests on any OS and works with anything that binds toICommand. - 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
PropertyChangingnorPropertyChanged.PropertyChangingfires while the property still returns the old value;PropertyChangedfires after the write.decimaland scale. The default comparer treats1.0mand1.00mas 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 aSetPropertyoverload:[CallerMemberName]must be an optional parameter andparamsmust 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 | Versions 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. |
-
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 |