NuExt.Minimal.Mvvm is a high-performance, dependency-free MVVM core for .NET focused on robust async flows and deterministic command execution. It provides a minimal, clear API with Bindable/ViewModel base types, a self-validating command model (Relay/Async/Composite), and a lightweight, UI-focused service provider with trim- and AOT-friendly factory registration.
-
Core
Minimal.Mvvm.BindableBase— lightweight, UI-awareINotifyPropertyChangedbase with cached event arguments and allocation-free generated notification paths.Minimal.Mvvm.ViewModelBase— a ViewModel foundation with parent-aware service resolution, a lazily created local service scope, parameter support, and serialized asynchronous lifecycle.- Property notifications are marshaled through an explicitly configured or captured
SynchronizationContext; otherwise, they are raised on the caller's thread. Minimal.Mvvm.WeakEvent— lightweight weak-event storage for(object sender, TEventArgs)handlers.
-
Command model (self-validating)
RelayCommand,AsyncCommand,AsyncValueCommand, their generic variants, andCompositeCommandvalidate their execution state internally.- If
CanExecute(parameter)returnsfalse,Execute(parameter)does nothing, ensuring consistent behavior for UI-bound and programmatic calls.
Semantics
- Async commands provide cancellation and reentrancy control (
AllowConcurrentExecution, default: false). AsyncValueCommand/AsyncValueCommand<T>provide the same execution semantics usingValueTask.- Exceptions bubble to
UnhandledException(per-command) first; if not handled, toAsyncCommand.GlobalUnhandledException. Cancel()signals all active executions through their individualCancellationTokeninstances; if nothing is executing, it’s a no-op.
-
Command implementations
RelayCommand/RelayCommand<T>— classic synchronous delegate-based commands (can be invoked concurrently from multiple threads).AsyncCommand/AsyncCommand<T>— asynchronous commands with predictable error propagation and cancellation.AsyncValueCommand/AsyncValueCommand<T>— asynchronous commands built onValueTaskfor allocation-sensitive scenarios.CompositeCommand— aggregates multiple commands and executes them sequentially; awaitsExecuteAsync(...)and callsExecute(...)for non-async commands.
-
UI-focused Service Provider
Minimal.Mvvm.ServiceProvideris a lightweight, mutable service container designed for hierarchical UI scopes.- Hierarchical lookup order for ViewModels: local → fallback → parent → default.
- Supports instance, lazy singleton, transient, named, and runtime-replaceable registrations.
- Factory and instance registrations are trim- and AOT-friendly. Type activation is available as a convenience path and requires a preserved public parameterless constructor.
- Lifetime basics for UI apps:
- Singleton (lazy) — stored per container, created on the first successful resolution.
- Transient — new instance per resolution, not cached.
- Scope — a ViewModel acts as a scope;
ViewModelBase.Servicesis created lazily, and created local singletons are released during uninitialization. - Application scope
ServiceProvider.Defaultacts as the application-level container. Prefer registering app-wide services there; ViewModels may register local overrides in their own scope.
NuExt.Minimal.Mvvm is designed for UI applications that need predictable asynchronous behavior without adopting a full application framework. Commands validate their own execution state, track concurrent invocations independently, expose explicit cancellation and error handling, and support deterministic asynchronous composition.
ViewModels combine UI-aware property notifications with serialized asynchronous lifecycle and hierarchical service resolution. Each ViewModel can act as a lazy local scope, inherit context-specific services from its parent, and fall back to application-level services without hiding mandatory dependencies that belong in constructors.
The framework keeps runtime behavior explicit and performance-oriented: notification arguments are cached, frequent command and resolution paths avoid unnecessary allocations, service registrations remain mutable, and factory-based composition is trim- and AOT-friendly.
- Building WPF? Use NuExt.Minimal.Mvvm.Wpf for document services, async dialogs, explicit view composition, and dispatcher-safe APIs.
- MahApps.Metro integration: NuExt.Minimal.Mvvm.MahApps.Metro
Use the NuExt.Minimal.Mvvm.SourceGenerator for compile-time boilerplate generation in ViewModels.
public class SearchViewModel : ViewModelBase
{
public IAsyncCommand<string> SearchCommand { get; }
public ICommand CancelCommand { get; }
public SearchViewModel()
{
SearchCommand = new AsyncCommand<string>(SearchAsync, CanSearch)
{
AllowConcurrentExecution = true
};
CancelCommand = new RelayCommand(() => SearchCommand.Cancel());
}
private async Task SearchAsync(string query, CancellationToken cancellationToken)
{
await Task.Delay(1000, cancellationToken);
Results = $"Results for: {query}";
}
private bool CanSearch(string query) => !string.IsNullOrWhiteSpace(query);
private string _results = string.Empty;
public string Results
{
get => _results;
private set => SetProperty(ref _results, value);
}
}public class DataViewModel : ViewModelBase
{
public IAsyncCommand LoadDataCommand { get; }
public DataViewModel()
{
LoadDataCommand = new AsyncCommand(LoadDataAsync);
LoadDataCommand.UnhandledException += (sender, e) =>
{
if (e.Exception is HttpRequestException httpEx)
{
ShowError($"Network error: {httpEx.Message}");
e.Handled = true; // local tier handled
}
};
}
private async Task LoadDataAsync(CancellationToken cancellationToken)
{
throw new HttpRequestException("Connection failed");
}
private void ShowError(string message) { /* UI */ }
}Global fallback: subscribe to AsyncCommand.GlobalUnhandledException once at app startup (composition root) for logging/telemetry.
AsyncCommand.GlobalUnhandledException += (sender, e) =>
{
Logger.LogError(e.Exception, "Global command error");
e.Handled = true;
};To further simplify your ViewModel development, consider using the source generator provided by the NuExt.Minimal.Mvvm.SourceGenerator package. Here's an example:
using Minimal.Mvvm;
public partial class ProductViewModel : ViewModelBase
{
[Notify]
private string _name = string.Empty;
[Notify(Setter = AccessModifier.Private)]
private decimal _price;
public ProductViewModel()
{
SaveCommand = new AsyncCommand(SaveAsync);
}
[Notify]
private async Task SaveAsync(CancellationToken token)
{
await Task.Delay(500, token);
Price = 99.99m;
}
}Register application-wide services once at the composition root:
ServiceProvider.Default.RegisterService<IMyService, MyService>();public sealed class MyViewModel : ViewModelBase
{
public MyViewModel(IServiceContainer fallback) : base(fallback)
{
// ViewModel scope (local container); override per-VM if needed.
Services.RegisterService<IMyService, MyVmSpecificService>();
Services.RegisterTransient<INotification>(() => new Toast());
}
public void Use()
{
// Resolution order: local → fallback → parent → default
var service = GetService<IMyService>();
var notification = GetService<INotification>();
}
}Notes (UI lifetimes):
- Scope = the ViewModel instance. Local services are stored in its lazy container and released during cleanup.
- Application scope =
ServiceProvider.Default.- Singleton (lazy) = created on first successful resolution and cached in the owning container.
- Transient = new instance per resolution (not cached).
- Cleanup: default uninitialization cleans up created local singletons; override
UninitializeAsyncCore(...)to customize cleanup.
public sealed class ValidateViewModel : ViewModelBase
{
public IAsyncCommand ValidateCommand { get; }
public ValidateViewModel()
{
// Often completes synchronously; ValueTask can avoid a Task allocation in that case.
ValidateCommand = new AsyncValueCommand(ValidateAsync);
}
private ValueTask ValidateAsync(CancellationToken ct)
{
// synchronous fast path
if (IsValidFast()) return ValueTask.CompletedTask;
// fallback async path
return SlowValidateAsync(ct);
}
private bool IsValidFast() => /* lightweight checks */;
private async ValueTask SlowValidateAsync(CancellationToken ct)
{
await Task.Delay(50, ct);
// heavy checks...
}
}- Application services: register into
ServiceProvider.Defaultat startup. - Per-VM overrides: register in
ViewModelBase.Services(scope = the ViewModel). - External DI as parent: wrap your existing container as a parent for this provider:
var app = new ServiceProvider((System.IServiceProvider)existing);Then either set it as application scope (ServiceProvider.Default = app) or pass as a ViewModel fallback (: base(app)). - Named registrations: use
nameoverloads to keep side-by-side implementations. - Concept mapping:
- App “Singleton” →
ServiceProvider.Default.RegisterService<T>(...) - Per-View/ViewModel “Scoped” →
Services.RegisterService<T>(...) - “Transient” →
RegisterTransient<T>(...)
- App “Singleton” →
In WPF, [UseCommandManager] wires a generated command property to CommandManager.RequerySuggested, so CanExecute is reevaluated automatically on typical UI events (focus changes, keyboard, window activation). You don’t need to call RaiseCanExecuteChanged() manually.
using Minimal.Mvvm;
using System.Threading;
using System.Threading.Tasks;
public partial class LoginViewModel : ViewModelBase
{
[Notify] private string _userName = string.Empty;
[Notify] private string _password = string.Empty;
// Field-based pattern: generator creates the LoginCommand property.
// [UseCommandManager] auto-subscribes the property setter to WPF CommandManager.RequerySuggested.
[Notify, UseCommandManager]
private IAsyncCommand? _loginCommand;
public LoginViewModel()
{
LoginCommand = new AsyncCommand(LoginAsync, CanLogin);
}
private bool CanLogin() =>
!string.IsNullOrWhiteSpace(UserName) &&
!string.IsNullOrWhiteSpace(Password);
private async Task LoginAsync(CancellationToken ct)
{
await Task.Delay(250, ct);
// sign-in...
}
}Alternatively, you can place
[Notify, UseCommandManager]on a method that should become a command; the generator will create the command property and wire WPF requery in the property setter as well.
<Window x:Class="MyApp.Views.LoginView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:MyApp.ViewModels"
Title="Login" Width="360" Height="220">
<Window.DataContext>
<vm:LoginViewModel/>
</Window.DataContext>
<StackPanel Margin="16" VerticalAlignment="Center">
<TextBox Margin="0,0,0,8"
Text="{Binding UserName, UpdateSourceTrigger=PropertyChanged}"/>
<TextBox Margin="0,0,0,12"
Text="{Binding Password, UpdateSourceTrigger=PropertyChanged}"/>
<!-- CanExecute will re-evaluate automatically on UI changes thanks to [UseCommandManager] -->
<Button Content="Sign in"
Command="{Binding LoginCommand}"
HorizontalAlignment="Right"
MinWidth="96" Padding="12,6"/>
</StackPanel>
</Window>[UseCommandManager]is WPF-only; Avalonia/WinUI don’t haveCommandManager.- If you still need a manual requery (rare), call
CommandManager.InvalidateRequerySuggested().
You have two options to enable AsyncValueCommand* on legacy TFMs:
-
Recommended for legacy-only projects: install the binary add-on:
dotnet add package NuExt.Minimal.Mvvm.LegacyThis adds
AsyncValueCommandandAsyncValueCommand<T>for .NET Framework 4.6.2 and .NET Standard 2.0 and referencesSystem.Threading.Tasks.Extensions. -
Sources-only workflow: opt in through
NuExt.Minimal.Mvvm.Sourcesby definingNUEXT_ENABLE_VALUETASKand referencingSystem.Threading.Tasks.Extensions.
Q: How is this different from CommunityToolkit.Mvvm?
A: NuExt.Minimal.Mvvm focuses on deterministic command execution, independently tracked concurrent operations, serialized ViewModel lifecycle, and hierarchical UI service scopes. CommunityToolkit.Mvvm provides a broader set of general-purpose MVVM components, including messaging and validation. Choose NuExt.Minimal.Mvvm when these runtime semantics and UI-oriented composition model are important.
Q: Do I have to wire WPF CommandManager.RequerySuggested myself?
A: No. With the source generator, mark the command with [UseCommandManager], and the generated property will auto-subscribe/unsubscribe for requery.
Q: What is the command error flow?
A: Exceptions raised during AsyncCommand execution are first published to UnhandledException; if not handled there, they flow to AsyncCommand.GlobalUnhandledException.
Q: Why does ViewModelBase provide hierarchical service access?
A: Stable, mandatory dependencies should still be passed through constructors. Hierarchical services are intended for UI-context dependencies that may depend on the current ViewModel tree, such as navigation, dialogs, document context, selection, or local runtime overrides. Each ViewModel can expose a lazy local scope while resolving missing services through fallback → parent → default.
Q: How does the built-in service provider differ from Microsoft.Extensions.DependencyInjection?
A: It is a mutable, hierarchical container designed for UI scopes, local overrides, and explicit factory registrations. It does not build arbitrary constructor dependency graphs or replace a general-purpose application container. Use it independently for lightweight UI composition, or place an existing IServiceProvider behind it as a parent.
Q: How do I integrate my existing DI container?
A: Wrap it as a parent via new ServiceProvider(System.IServiceProvider parent).
Use it in one of two ways:
- Application scope: set
ServiceProvider.Default = new ServiceProvider(parent), so all ViewModels can resolve app-wide services via the default provider. - Per-VM fallback: pass it to specific ViewModels (
: base(fallback)) when you need a VM to resolve “up” into the external container while still allowing local overrides in that VM scope.
Q: How are UI scopes organized?
A: ServiceProvider.Default is the application scope. Each ViewModel can lazily create a local scope through ViewModelBase.Services, with resolution following local → fallback → parent → default. Transient registrations create a new instance for each resolution.
Via NuGet:
dotnet add package NuExt.Minimal.MvvmOr via Visual Studio:
- Go to
Tools -> NuGet Package Manager -> Manage NuGet Packages for Solution.... - Search for
NuExt.Minimal.Mvvm. - Click "Install".
A source package is also available: NuExt.Minimal.Mvvm.Sources. This package allows you to embed the entire framework directly into your application, enabling easier source code exploring and debugging.
See ValueTask commands on legacy targets for legacy opt-in details.
To install the source code package, use the following command:
dotnet add package NuExt.Minimal.Mvvm.SourcesOr via Visual Studio:
- Go to
Tools -> NuGet Package Manager -> Manage NuGet Packages for Solution.... - Search for
NuExt.Minimal.Mvvm.Sources. - Click "Install".
- .NET Standard 2.0/2.1, .NET 8/9/10, .NET Framework 4.6.2+
- Language: C# 12+
Legacy only: to use
AsyncValueCommand*on .NET Framework 4.6.2 / .NET Standard 2.0, see ValueTask commands on legacy targets (binary add-on or.Sourcesopt-in withNUEXT_ENABLE_VALUETASKandSystem.Threading.Tasks.Extensions).
- NuExt.Minimal.Behaviors.Wpf
- NuExt.Presentation.Wpf
- NuExt.Minimal.Mvvm.SourceGenerator
- NuExt.Minimal.Mvvm.Wpf
- NuExt.Minimal.Mvvm.MahApps.Metro
- NuExt.System
- NuExt.System.Data
- NuExt.System.Data.SQLite
Issues and PRs are welcome. Keep changes minimal and performance-conscious.
MIT. See LICENSE.