MVVM en .NET MAUI 10 con CommunityToolkit.Mvvm: guía práctica (2026)
Guía práctica de MVVM en .NET MAUI 10 con CommunityToolkit.Mvvm 8.4: ObservableProperty, RelayCommand, mensajería desacoplada, DI y pruebas con código real de producción.
El patrón MVVM en .NET MAUI 10 con CommunityToolkit.Mvvm te permite eliminar el código repetitivo de INotifyPropertyChanged usando generadores de código fuente, reduciendo un ViewModel típico de 200 líneas a menos de 40. En esta guía te enseño cómo configurarlo, los atributos [ObservableProperty] y [RelayCommand], mensajería desacoplada, inyección de dependencias con MauiAppBuilder y los errores en producción que he visto repetirse en tres apps distintas. Honestamente, después de portar dos de esas apps desde Xamarin.Forms, no volvería atrás.
CommunityToolkit.Mvvm 8.4 usa generadores de fuente de Roslyn para crear automáticamente propiedades observables y comandos en tiempo de compilación, sin reflexión en runtime.
[ObservableProperty] sobre un campo privado genera la propiedad pública, el evento PropertyChanged y métodos parciales OnXChanging/OnXChanged.
[RelayCommand] genera un IRelayCommand con soporte para async, CanExecute y cancelación mediante CancellationToken.
WeakReferenceMessenger evita fugas de memoria comunes al desacoplar ViewModels sin mantener referencias fuertes.
El contenedor DI integrado en MauiAppBuilder.Services registra ViewModels y vistas como Transient; los servicios HTTP deben ser Singleton para reutilizar el HttpMessageHandler.
Las pruebas unitarias del ViewModel deben verificar las notificaciones PropertyChanged y los efectos secundarios de los comandos, no la UI.
Qué es MVVM en .NET MAUI y por qué importa
MVVM (Model-View-ViewModel) es el patrón arquitectónico que separa la lógica de presentación de la interfaz visual. En .NET MAUI, la View es tu XAML, el Model son tus DTOs y servicios, y el ViewModel es la pieza intermedia que expone estado y comandos consumibles mediante {Binding}. La razón por la que este patrón sigue vivo después de quince años no es académica. Hace tu código testeable. Puedes ejecutar el ViewModel en una prueba xUnit sin instanciar una sola página de MAUI.
Antes de CommunityToolkit.Mvvm, escribir un ViewModel implicaba implementar INotifyPropertyChanged a mano, llamar a RaisePropertyChanged(nameof(MiPropiedad)) en cada setter y crear clases RelayCommand personalizadas. ¿El resultado? Archivos de 300 líneas donde el 70% era ruido. He visto equipos abandonar MVVM por pura fricción del boilerplate y caer en code-behind monolítico que no escala. CommunityToolkit.Mvvm resuelve esto con generadores de código fuente que ejecuta el compilador de Roslyn: el código repetitivo se genera durante la compilación, no en runtime, así que pagas cero coste de reflexión.
Si vienes de Xamarin.Forms con MvvmCross o Prism, la transición es directa. Si vienes de code-behind puro, prepárate para una curva inicial; a las dos semanas no querrás volver. Para contexto sobre la migración (yo pasé por ahí el año pasado con una app de logística), revisa la guía de migración de Xamarin.Forms a .NET MAUI 10.
Instalar y configurar CommunityToolkit.Mvvm
Necesitas un proyecto .NET MAUI con .NET 10 SDK instalado. Añade el paquete NuGet desde la raíz del proyecto:
La versión 8.4 es la primera que estabiliza el generador para .NET 10 y resuelve el bug de propagación de nullable en propiedades parciales. Si vas a usar Hot Reload con XAML, asegúrate de que EnableHotReload esté activado en el csproj. Los generadores se re-ejecutan en cada cambio sin reiniciar el debugger, lo cual te ahorra muchísimo tiempo durante el diseño visual.
Crea una carpeta ViewModels y dentro un archivo base. Yo prefiero una clase base mínima que herede de ObservableObject en lugar de duplicarla en cada ViewModel:
using CommunityToolkit.Mvvm.ComponentModel;
namespace MiApp.ViewModels;
public partial class BaseViewModel : ObservableObject
{
[ObservableProperty]
private bool isBusy;
[ObservableProperty]
private string title = string.Empty;
public bool IsNotBusy => !IsBusy;
}
Fíjate en la palabra clave partial en la clase. Esto es obligatorio: el generador de fuente añade el código generado en un archivo parcial paralelo. Si olvidas partial, recibirás el error MVVMTK0032 en tiempo de compilación (a mí me pasó la primera vez y perdí media hora buscando "qué está mal con mi sintaxis"). Para la documentación oficial completa, consulta la documentación de CommunityToolkit.Mvvm en Microsoft Learn.
Propiedades observables con [ObservableProperty]
El atributo [ObservableProperty] es la primera victoria visible. Lo aplicas sobre un campo privado y el generador produce la propiedad pública con el evento PropertyChanged. La convención de nombres convierte nombreUsuario en NombreUsuario automáticamente.
Dos detalles que ahorran horas. [NotifyPropertyChangedFor] indica al generador que cuando cambie esta propiedad también dispare PropertyChanged para la propiedad calculada CanLogin, así el botón vinculado se habilita o deshabilita sin que escribas una sola línea de boilerplate. Y los métodos parciales OnXChanging/OnXChanged son hooks opcionales; si no los implementas, no se generan llamadas, no hay coste.
Para colecciones que se actualizan dinámicamente, usa ObservableCollection<T> directamente. No la marques con [ObservableProperty] a menos que vayas a reemplazar la instancia completa, no solo añadir elementos. Ese matiz me ha costado más de un bug raro.
Comandos asíncronos con [RelayCommand]
El atributo [RelayCommand] genera un IRelayCommand a partir de un método. Soporta async, CanExecute y cancelación con CancellationToken sin que tengas que envolver nada. Este es el patrón que uso en producción para una pantalla de login real:
using CommunityToolkit.Mvvm.Input;
public partial class LoginViewModel : BaseViewModel
{
private readonly IAuthService _authService;
public LoginViewModel(IAuthService authService)
{
_authService = authService;
}
[RelayCommand(CanExecute = nameof(CanLogin), IncludeCancelCommand = true)]
private async Task LoginAsync(CancellationToken cancellationToken)
{
try
{
IsBusy = true;
var result = await _authService.SignInAsync(Email, Password, cancellationToken);
if (result.Success)
{
await Shell.Current.GoToAsync("//home");
}
else
{
await Shell.Current.DisplayAlert("Error", result.Message, "OK");
}
}
catch (OperationCanceledException)
{
// Cancelado por el usuario, no hacer nada
}
finally
{
IsBusy = false;
}
}
}
IncludeCancelCommand = true genera automáticamente LoginCancelCommand, que cancela el CancellationToken pasado al método. Esto resuelve uno de los problemas más comunes en apps móviles: el usuario toca atrás durante una operación HTTP y la app sigue procesando, gastando datos del móvil y batería. Para la capa HTTP que invocas desde estos comandos, profundizo en la guía de consumo de API REST en .NET MAUI.
Inyección de dependencias en MauiProgram
El contenedor DI integrado en .NET MAUI vive en MauiAppBuilder.Services y es el mismo Microsoft.Extensions.DependencyInjection que usas en ASP.NET Core. Registra tus ViewModels, vistas y servicios en MauiProgram.cs:
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
});
// Servicios singleton: HttpClient, autenticación, configuración
builder.Services.AddSingleton<IAuthService, AuthService>();
builder.Services.AddSingleton<HttpClient>(_ => new HttpClient
{
BaseAddress = new Uri("https://api.miapp.com"),
Timeout = TimeSpan.FromSeconds(30)
});
// ViewModels y vistas: transient para evitar estado compartido
builder.Services.AddTransient<LoginViewModel>();
builder.Services.AddTransient<LoginPage>();
builder.Services.AddTransient<HomeViewModel>();
builder.Services.AddTransient<HomePage>();
return builder.Build();
}
}
En el code-behind de la página, recibes el ViewModel por constructor:
public partial class LoginPage : ContentPage
{
public LoginPage(LoginViewModel viewModel)
{
InitializeComponent();
BindingContext = viewModel;
}
}
La regla que sigo después de tres apps en producción: ViewModels y páginas como Transient, y servicios HTTP, cachés y configuración como Singleton. Registrar un ViewModel como singleton es la fuga de memoria más común que veo en code review. El ViewModel mantiene referencia a la página, que mantiene referencia al BindingContext, que es el ViewModel: ciclo cerrado y la GC no libera nada.
Mensajería desacoplada entre ViewModels
Cuando un ViewModel necesita notificar a otro sin acoplarse directamente, WeakReferenceMessenger es la solución. Imagina que SettingsViewModel cambia el idioma y HomeViewModel debe refrescarse:
public sealed record LanguageChangedMessage(string LanguageCode);
[RelayCommand]
private void ChangeLanguage(string code)
{
Preferences.Set("app_lang", code);
WeakReferenceMessenger.Default.Send(new LanguageChangedMessage(code));
}
public HomeViewModel()
{
WeakReferenceMessenger.Default.Register<LanguageChangedMessage>(
this, (recipient, message) => recipient.ReloadLocalizedStrings(message.LanguageCode));
}
El prefijo Weak es importante. El messenger mantiene referencias débiles a los suscriptores, así que si un ViewModel se destruye y olvidas desregistrarlo, la GC sigue limpiándolo. La versión StrongReferenceMessenger también existe pero requiere que llames a Unregister explícitamente; solo úsala si necesitas el rendimiento máximo en escenarios con miles de mensajes por segundo (yo nunca he tenido que llegar a eso en una app móvil).
Errores comunes que rompen tu app en producción
Cinco patrones que he tenido que arreglar más de una vez en revisiones de código y postmortems:
1. Olvidar partial en la clase
El compilador lanza MVVMTK0032 y la app no compila. Fácil de detectar, pero confuso si no conoces el código de error.
2. Usar Application.Current.MainPage desde el ViewModel
Acoplas el ViewModel a la UI y rompes las pruebas unitarias. Inyecta una abstracción INavigationService y delega Shell.Current.GoToAsync ahí dentro.
3. Bloquear el hilo de UI con .Result o .Wait()
Esto causa el deadlock clásico de WinUI y iOS porque el contexto de sincronización captura el callback. Usa siempre await. Punto.
4. Suscribirse al messenger sin desregistrar en páginas modales
Con WeakReferenceMessenger la GC eventualmente limpia, pero si la página modal sigue viva en el stack, recibe mensajes destinados a la pantalla activa. Llama a WeakReferenceMessenger.Default.UnregisterAll(this) en OnDisappearing.
5. ViewModels gigantes con 30 propiedades
Síntoma de que tu pantalla hace demasiado. Divide en sub-ViewModels expuestos como propiedades del ViewModel principal, cada uno con su responsabilidad. Para una visión profunda del diseño de componentes y ejemplos avanzados, revisa el repositorio oficial de CommunityToolkit en GitHub.
Cómo probar un ViewModel sin la UI
Una de las razones reales para usar MVVM es la testeabilidad. Un test xUnit típico para LoginViewModel queda así:
using Xunit;
using NSubstitute;
public class LoginViewModelTests
{
[Fact]
public async Task LoginAsync_WithValidCredentials_NavigatesToHome()
{
var authService = Substitute.For<IAuthService>();
authService.SignInAsync("[email protected]", "password123", Arg.Any<CancellationToken>())
.Returns(new AuthResult(Success: true, Message: null));
var vm = new LoginViewModel(authService)
{
Email = "[email protected]",
Password = "password123"
};
Assert.True(vm.CanLogin);
await vm.LoginCommand.ExecuteAsync(null);
await authService.Received(1).SignInAsync(
"[email protected]", "password123", Arg.Any<CancellationToken>());
Assert.False(vm.IsBusy);
}
[Fact]
public void CanLogin_WithShortPassword_ReturnsFalse()
{
var vm = new LoginViewModel(Substitute.For<IAuthService>())
{
Email = "[email protected]",
Password = "short"
};
Assert.False(vm.CanLogin);
}
}
El test no instancia ninguna página MAUI ni necesita el runtime. Esto convierte tu suite de pruebas de minutos en segundos (literalmente, en mi último proyecto pasamos de 4 minutos a 11 segundos). Para la lista completa de capacidades de la versión actual, las notas de la versión .NET MAUI 10 incluyen los breaking changes del binding compilado que afectan a algunos casos límite.
Preguntas frecuentes
¿Cuál es la diferencia entre ObservableObject y INotifyPropertyChanged?
INotifyPropertyChanged es la interfaz del framework que define el evento PropertyChanged. ObservableObject es la implementación base de CommunityToolkit.Mvvm que ya provee SetProperty, OnPropertyChanged y soporte para los atributos [ObservableProperty]. Heredas de ObservableObject y olvidas implementar la interfaz a mano.
¿Necesito MVVM si mi app es pequeña?
Si la app tiene tres pantallas estáticas, no. Si vas a tener formularios, navegación, llamadas async y pruebas unitarias, sí. La curva de aprendizaje con CommunityToolkit.Mvvm es de un par de horas y el beneficio en mantenibilidad aparece desde la cuarta pantalla.
¿CommunityToolkit.Mvvm funciona con AOT y NativeAOT?
Sí. Como los generadores de fuente producen código en compilación, no usan reflexión en runtime. Es compatible con PublishAot y reduce el tamaño del binario frente a frameworks MVVM clásicos como MvvmCross o Prism.
¿Puedo combinar code-behind y MVVM en la misma página?
Puedes, y a veces debes. Lógica puramente visual como animaciones o configuración de controles nativos vive bien en code-behind. La regla: si la lógica accede a datos, hace llamadas de red o toma decisiones de negocio, va al ViewModel.
¿Qué reemplaza a MessagingCenter de Xamarin.Forms en .NET MAUI?
MessagingCenter está marcado como obsoleto en .NET MAUI. El reemplazo recomendado es WeakReferenceMessenger de CommunityToolkit.Mvvm: API más segura por tipo, sin strings mágicos, y evita las fugas de memoria que MessagingCenter producía cuando olvidabas desuscribirte.
Guía práctica para implementar almacenamiento local en .NET MAUI 10 con SQLite: repositorio asíncrono, DI, cifrado SQLCipher, migraciones de esquema y sincronización offline-first, con ejemplos listos para producción.