MVVM ve Presentation Model: Data Binding, Command ve ViewModel
MVVM, Fowler'ın Presentation Model'inin data binding üzerine kurulmuş hâlidir: ViewModel view state'i ve view logic'i taşır, view ile senkronizasyonu binding yapar, kullanıcı aksiyonları CanExecute'u olan command'lara dönüşür. Sipariş formunun ViewModel'ini C#'ta CommunityToolkit.Mvvm 8.4 source generator'larıyla ve Angular 22'de signal'larla yazıyor, ikisini de view olmadan test ediyor ve binding'in sessiz hatalarını gerçek çıktılarla görüyoruz.
Sürüm notu: Bu yazıdaki örnekler Angular 22 ve TypeScript ile, karşılaştırma örnekleri .NET 10 ile denenmiştir.
Önce Tahmin Et
Bir önceki yazıdaki MVP (Model-View-Presenter) presenter'ı her değişiklikte view'a üç şey söylüyordu: showError, showTotal, setPlaceOrderEnabled. Kullanıcı adet input'una "3" yazınca presenter bu üç çağrıyı sırayla yapıyordu ve birini unutursan ekran eski değerde kalıyordu.
Şimdi presenter'ın view'ı hiç tanımadığını düşün. Ne bir view interface'i var ne de show... method'ları. Presenter'ın yerindeki class yalnızca property'ler taşıyor: QuantityText, Total, Error. Ekran, yani UI (User Interface, kullanıcı arayüzü), bu property'lere bağlı. Kullanıcı "3" yazdığında, Total property'sinin değiştiğini ekrana kim haber verecek? Ve Total'ı hesaplayan kodu yazıp haber verme kısmını unutursan ne olur: exception mı, derleme hatası mı, hiçbir şey mi?
Presentation Model = MVVM
Martin Fowler, 2004 tarihli Presentation Model yazısında pattern'i şöyle tarif ediyor: view'ın state'ini ve davranışını, ekrandaki GUI (Graphical User Interface) control'lerinden bağımsız bir class'ta temsil et. View bu class'tan okuyor ve ona yazıyor; class view'ı tanımıyor. MVP'deki presenter view'a "şunu göster" derken Presentation Model hiçbir şey demiyor, yalnız kendi state'ini güncelliyor.
Bir yıl sonra, Ekim 2005'te John Gossman aynı fikri WPF (Windows Presentation Foundation) için MVVM (Model-View-ViewModel) adıyla duyurdu. Gossman'ın yazısında iki şey öne çıkıyor. Birincisi ViewModel adının anlamı: "Model of a View", yani view'ın modeli. İkincisi, pattern'in genel bir data binding mekanizmasına dayanması. Fowler da kendi sayfasında Presentation Model'in giderek MVVM adıyla bilindiğini not ediyor. İki ad aynı fikri anlatıyor; fark, MVVM'in senkronizasyon kodunu platformun binding'ine devretmesi.
Rolleri sipariş formuna yerleştirelim. Model domain: Pricing.PriceOrder, Catalog, sipariş API'si (Application Programming Interface). View, XAML (Extensible Application Markup Language) ya da HTML (HyperText Markup Language) template'i: yalnız neyin neye bağlandığını söylüyor. ViewModel ise ilk yazıda ayırdığımız view state'i (input'taki ham metin, "kaydediliyor" bayrağı) ve view logic'i ("adet geçersizse butonu kapat") taşıyor. Gossman'ın deyişiyle selection, mode gibi state'in de evi ViewModel.
C# ViewModel: CommunityToolkit.Mvvm 8.4
WPF ve .NET MAUI (Multi-platform App UI) dünyasında MVVM'in pratik standardı Microsoft'un CommunityToolkit.Mvvm paketi. 8.x sürümleri INotifyPropertyChanged ve ICommand için gereken tekrarlı kodu source generator'larla üretiyor. Sipariş formunun ViewModel'i:
using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace Bookstore.Desktop;
public interface IOrderApi
{
Task<int> PlaceOrderAsync(int bookId, int quantity, CancellationToken cancellationToken);
}
public sealed partial class OrderFormViewModel(IOrderApi api) : ObservableObject
{
public IReadOnlyList<Book> Books { get; } = Catalog.Books;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Total), nameof(Error))]
[NotifyCanExecuteChangedFor(nameof(PlaceOrderCommand))]
public partial string QuantityText { get; set; } = "1";
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Total))]
public partial Book SelectedBook { get; set; } = Catalog.Books[0];
[ObservableProperty]
public partial string? Confirmation { get; set; }
public string Total => TryGetQuantity(out int quantity)
? string.Create(CultureInfo.InvariantCulture, $"Total: {Pricing.PriceOrder(SelectedBook, quantity):0.00}")
: "";
public string Error => TryGetQuantity(out _) ? "" : "Quantity must be between 1 and 10";
[RelayCommand(CanExecute = nameof(CanPlaceOrder))]
private async Task PlaceOrderAsync(CancellationToken cancellationToken)
{
_ = TryGetQuantity(out int quantity);
int orderId = await api.PlaceOrderAsync(SelectedBook.Id, quantity, cancellationToken);
Confirmation = $"Order #{orderId} placed";
}
private bool CanPlaceOrder() => TryGetQuantity(out _);
private bool TryGetQuantity(out int quantity) =>
int.TryParse(QuantityText, NumberStyles.None, CultureInfo.InvariantCulture, out quantity)
&& quantity is >= 1 and <= 10;
}
Dört attribute işin çoğunu yapıyor. [ObservableProperty] property'nin setter'ını, değer değişince PropertyChanged fırlatacak şekilde üretiyor. [NotifyPropertyChangedFor], QuantityText değişince Total ve Error için de bildirim gönderiyor; çünkü bu ikisi hesaplanan property'ler ve kendi setter'ları yok. [NotifyCanExecuteChangedFor], butonun bağlı olduğu command'a "durumunu yeniden sor" diyor. [RelayCommand] ise PlaceOrderAsync method'undan PlaceOrderCommand adında bir IAsyncRelayCommand üretiyor ve CanExecute'u CanPlaceOrder'a bağlıyor.
Microsoft Learn'deki örnekler [ObservableProperty]'yi private bir field üzerinde gösteriyor; 8.4 sürümü partial property'leri de destekliyor. C# 14 ve .NET 10 ile yukarıdaki public partial string QuantityText { get; set; } = "1"; yazımı ek bir dil ayarı gerektirmeden derleniyor. Generator'ın ürettiği kod, EmitCompilerGeneratedFiles ile diske yazdırınca şöyle görünüyor:
public partial string QuantityText
{
get => field;
set
{
if (!global::System.Collections.Generic.EqualityComparer<string>.Default.Equals(field, value))
{
OnQuantityTextChanging(value);
OnQuantityTextChanging(default, value);
OnPropertyChanging(global::CommunityToolkit.Mvvm.ComponentModel.__Internals.__KnownINotifyPropertyChangingArgs.QuantityText);
OnPropertyChanging(global::CommunityToolkit.Mvvm.ComponentModel.__Internals.__KnownINotifyPropertyChangingArgs.Total);
OnPropertyChanging(global::CommunityToolkit.Mvvm.ComponentModel.__Internals.__KnownINotifyPropertyChangingArgs.Error);
field = value;
OnQuantityTextChanged(value);
OnQuantityTextChanged(default, value);
OnPropertyChanged(global::CommunityToolkit.Mvvm.ComponentModel.__Internals.__KnownINotifyPropertyChangedArgs.QuantityText);
OnPropertyChanged(global::CommunityToolkit.Mvvm.ComponentModel.__Internals.__KnownINotifyPropertyChangedArgs.Total);
OnPropertyChanged(global::CommunityToolkit.Mvvm.ComponentModel.__Internals.__KnownINotifyPropertyChangedArgs.Error);
PlaceOrderCommand.NotifyCanExecuteChanged();
}
}
}
Backing field için C# 14'ün field keyword'ü kullanılıyor ve bağımlı bildirimler de command güncellemesi de setter'ın içine yazılmış. Senin elle yazacağın ve unutabileceğin kod tam olarak bu.
Command ve CanExecute
MVVM'de kullanıcının tetiklediği işler method değil command olarak dışarı açılıyor. WPF'in ICommand interface'i üç üyeden oluşuyor: Execute, CanExecute ve CanExecuteChanged event'i. Butonun Command property'si bir command'a bağlandığında WPF, CanExecute false döndüğü sürece butonu kendiliğinden kapatıyor. Bu, Design Patterns serisinin "Command Pattern: İsteği Object'e Çevirmek, Undo/Redo ve Kuyruğa Atmak" yazısındaki Command pattern'in UI'daki en yaygın kullanımı.
ViewModel'i hiçbir view olmadan, bir console uygulamasından sürelim. View'ın binding'i ne yapıyorsa onu taklit ediyoruz: PropertyChanged ve CanExecuteChanged event'lerini dinleyip yazıyoruz.
using Bookstore.Desktop;
OrderFormViewModel viewModel = new(new SlowOrderApi());
// view'ın yapacağını taklit ediyoruz: binding PropertyChanged'i dinler
viewModel.PropertyChanged += (_, e) => Console.WriteLine($"PropertyChanged: {e.PropertyName}");
viewModel.PlaceOrderCommand.CanExecuteChanged += (_, _) =>
Console.WriteLine($"CanExecuteChanged -> {viewModel.PlaceOrderCommand.CanExecute(null)}");
Console.WriteLine("> QuantityText = \"3\"");
viewModel.QuantityText = "3";
Console.WriteLine($"Total = {viewModel.Total}");
Console.WriteLine("> QuantityText = \"abc\"");
viewModel.QuantityText = "abc";
Console.WriteLine($"Error = {viewModel.Error}");
Console.WriteLine("> QuantityText = \"2\", PlaceOrderCommand");
viewModel.QuantityText = "2";
Task running = viewModel.PlaceOrderCommand.ExecuteAsync(null);
Console.WriteLine($"While running, CanExecute = {viewModel.PlaceOrderCommand.CanExecute(null)}");
await running;
Console.WriteLine($"Confirmation = {viewModel.Confirmation}");
sealed class SlowOrderApi : IOrderApi
{
public async Task<int> PlaceOrderAsync(int bookId, int quantity, CancellationToken cancellationToken)
{
await Task.Delay(100, cancellationToken);
return 42;
}
}
> QuantityText = "3"
PropertyChanged: QuantityText
PropertyChanged: Total
PropertyChanged: Error
CanExecuteChanged -> True
Total = Total: 37.80
> QuantityText = "abc"
PropertyChanged: QuantityText
PropertyChanged: Total
PropertyChanged: Error
CanExecuteChanged -> False
Error = Quantity must be between 1 and 10
> QuantityText = "2", PlaceOrderCommand
PropertyChanged: QuantityText
PropertyChanged: Total
PropertyChanged: Error
CanExecuteChanged -> True
CanExecuteChanged -> False
While running, CanExecute = False
PropertyChanged: Confirmation
Confirmation = Order #42 placed
CanExecuteChanged -> True
Çıktıda MVVM'in bütün mekanizması görünüyor. Tek bir property ataması üç PropertyChanged bildirimi ve bir CanExecuteChanged üretiyor; binding'e bağlı bir ekranda bu, input'un, toplamın, hata mesajının ve butonun aynı anda güncellenmesi demek. Command çalışırken CanExecute false dönüyor: AsyncRelayCommand varsayılan olarak aynı anda ikinci bir çalıştırmaya izin vermiyor. Bir önceki yazıda presenter'a elle yazdığımız #saving bayrağının işini burada paket yapıyor. Bu test için tek bir mock framework'üne, tek bir UI thread'ine ya da WPF'e ihtiyaç olmadı; WPF yalnız Windows'ta çalıştığı hâlde bu ViewModel macOS'ta derlenip çalıştı.
Aynı ViewModel, Angular'da
Angular'da ayrı bir ViewModel class'ı yazmana gerek yok: component class'ı ViewModel'in ta kendisi. Template view, component class'ındaki signal'lar view state, computed()'lar view logic. Signal'ların kendisini Angular serisinin "Angular 16 Yenilikleri: Signals, Required Input ve takeUntilDestroyed" yazısından biliyorsun; burada bakacağımız şey rollerin dağılımı.
import { Component, computed, inject, signal } from '@angular/core';
import { catalog, parseQuantity, priceOrder } from '../domain/pricing';
import { OrderApi } from './order-api';
@Component({
selector: 'app-order-form',
template: `
<select #book (change)="selectedBookId.set(+book.value)">
@for (b of books; track b.id) {
<option [value]="b.id">{{ b.title }}</option>
}
</select>
<input #qty [value]="quantityText()" (input)="quantityText.set(qty.value)">
<p class="total">{{ total() }}</p>
<p class="error">{{ error() }}</p>
<button [disabled]="!canPlaceOrder()" (click)="placeOrder()">Place order</button>
<p class="confirmation">{{ confirmation() }}</p>
`,
})
export class OrderForm {
readonly #api = inject(OrderApi);
// view state: yalnız ekran açıkken anlamlı
readonly books = catalog;
readonly selectedBookId = signal(1);
readonly quantityText = signal('1');
readonly saving = signal(false);
readonly confirmation = signal('');
// view logic: state'ten türeyen değerler
readonly #check = computed(() => parseQuantity(this.quantityText()));
readonly total = computed(() => {
const check = this.#check();
const book = catalog.find(b => b.id === this.selectedBookId())!;
return check.ok ? `Total: ${priceOrder(book, check.quantity).toFixed(2)}` : '';
});
readonly error = computed(() => {
const check = this.#check();
return check.ok ? '' : check.error;
});
readonly canPlaceOrder = computed(() => this.#check().ok && !this.saving());
// command: kullanıcının tetiklediği iş
async placeOrder(): Promise<void> {
const check = this.#check();
if (!check.ok || this.saving()) return;
this.saving.set(true);
try {
const { orderId } = await this.#api.placeOrder(this.selectedBookId(), check.quantity);
this.confirmation.set(`Order #${orderId} placed`);
} finally {
this.saving.set(false);
}
}
}
Sipariş API'si C# sürümündeki IOrderApi'nin karşılığı; HttpClient'ı saran ince bir service:
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { firstValueFrom } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class OrderApi {
readonly #http = inject(HttpClient);
placeOrder(bookId: number, quantity: number): Promise<{ orderId: number }> {
return firstValueFrom(this.#http.post<{ orderId: number }>('/api/orders', { bookId, quantity }));
}
}
C# sürümüyle satır satır eşleşiyor. quantityText signal'ı QuantityText property'si, total ve error computed'ları NotifyPropertyChangedFor ile bildirilen hesaplanan property'ler, canPlaceOrder computed'ı CanExecute, placeOrder method'u command. Fark, bildirimlerin nasıl üretildiği. C#'ta hangi property'nin hangisine bağlı olduğunu attribute'larla sen söylüyorsun; Angular'da computed() hangi signal'ları okuduğunu runtime'da kendisi izliyor. total içinde selectedBookId()'yi okuduğun anda bağımlılık kuruluyor, unutmak diye bir şey yok.
Template tarafında binding'ler tek yönlü: [value] ile signal'dan input'a, (input) ile input'tan signal'a. WPF'teki two-way binding'in Angular'daki karşılığı [(ngModel)] ya da Angular 22'de stable olan Signal Forms; ikisinin ayrıntısı Angular serisinin "Angular'da Data Binding: Interpolation, Property, Event ve Two-Way Binding" ve "Angular 22: Varsayılan OnPush, Stable Signal Forms ve Angular 12 ile Proje Farkı" yazılarında. Bu örnekte iki yönü bilerek ayrı yazdım ki akış görünür olsun.
Testi Vitest ile ng test çalıştırıyor. İlk test component'i bir ViewModel gibi kullanıyor ve DOM'a (Document Object Model) hiç bakmıyor; ikincisi aynı state'in template'e bağlandığını doğruluyor:
import { TestBed } from '@angular/core/testing';
import { OrderForm } from './order-form';
import { OrderApi } from './order-api';
describe('OrderForm', () => {
let placeOrder: ReturnType<typeof vi.fn>;
beforeEach(() => {
placeOrder = vi.fn().mockResolvedValue({ orderId: 42 });
TestBed.configureTestingModule({ providers: [{ provide: OrderApi, useValue: { placeOrder } }] });
});
it('works as a ViewModel without touching the DOM', async () => {
const vm = TestBed.createComponent(OrderForm).componentInstance;
vm.quantityText.set('3');
expect(vm.total()).toBe('Total: 37.80');
vm.quantityText.set('abc');
expect(vm.error()).toBe('Quantity must be between 1 and 10');
expect(vm.canPlaceOrder()).toBe(false);
vm.quantityText.set('2');
const running = vm.placeOrder();
expect(vm.canPlaceOrder()).toBe(false);
await running;
expect(vm.confirmation()).toBe('Order #42 placed');
expect(placeOrder).toHaveBeenCalledWith(1, 2);
});
it('binds the template to the same state', async () => {
const fixture = TestBed.createComponent(OrderForm);
const root = fixture.nativeElement as HTMLElement;
const input = root.querySelector('input')!;
input.value = '3';
input.dispatchEvent(new Event('input'));
await fixture.whenStable();
expect(root.querySelector('.total')!.textContent).toBe('Total: 37.80');
expect(root.querySelector('button')!.disabled).toBe(false);
});
});
$ npx ng test --watch=false --reporters=verbose --include src/app/orders/order-form.spec.ts
✓ bookstore-ui src/app/orders/order-form.spec.ts > OrderForm > works as a ViewModel without touching the DOM 12ms
✓ bookstore-ui src/app/orders/order-form.spec.ts > OrderForm > binds the template to the same state 6ms
Test Files 1 passed (1)
Tests 2 passed (2)
Angular Style Guide, yalnız template'in okuduğu üyeler için protected öneriyor. Burada signal'ları test de okuduğu için public bıraktım; component'in public API'sini bir contract olarak ele almayı serinin sekizinci yazısında açacağız.
Binding'in Bedeli: Sessiz Hatalar ve "Magic"
Önce Tahmin Et'teki soruya dönelim: haber verme kısmını unutursan ne olur? C# ViewModel'inde QuantityText'in attribute'unu [NotifyPropertyChangedFor(nameof(Error))] yapıp Total'ı listeden çıkardım. Proje 0 uyarı, 0 hata ile derlendi ve çıktı şu oldu:
> QuantityText = "3"
PropertyChanged: QuantityText
PropertyChanged: Error
CanExecuteChanged -> True
Total = Total: 37.80
Total property'si doğru değeri döndürüyor (37.80), ama PropertyChanged: Total satırı yok. Binding'e bağlı bir ekranda toplam eski değerde kalır; exception yok, derleme uyarısı yok, test de ancak bildirimi açıkça kontrol ediyorsa yakalar. WPF'te XAML'daki binding yolunu yanlış yazmak da benzer biçimde sessiz: uygulama çalışır, alan boş kalır ve hata yalnız Visual Studio'nun çıktı penceresinde bir trace satırı olarak görünür.
MVVM eleştirilerinin çoğu bu noktadan geliyor. Binding'in kendisi deklaratif ve kısa, ama ekranda bir değer güncellenmediğinde neden güncellenmediğini bulmak için bildirim zincirini kafanda kurman gerekiyor. Gossman da MVVM'in basit arayüzler için fazla olabileceğini sonradan kabul etti.
Angular bu iki hatanın ikisini de başka bir yerde yakalıyor. Bağımlılık izleme otomatik olduğu için "bildirimi unutmak" yok. Template'teki yazım hatası ise derleme zamanında yakalanıyor; Angular 22'de strictTemplates varsayılan olarak açık. Template'te total() yerine totl() yazınca:
$ npx ng build
✘ [ERROR] TS2551: Property 'totl' does not exist on type 'OrderForm'. Did you mean 'total'? [plugin angular-compiler]
src/app/orders/order-form.ts:14:24:
14 │ <p class="total">{{ totl() }}</p>
╵ ~~~~
Sık Düşülen Tuzaklar
ViewModel'i view'a bağımlı yapmak. ViewModel'e bir Window, HTMLElement ya da ElementRef girdiği anda Presentation Model'in tüm avantajı gidiyor: ViewModel artık view olmadan test edilemiyor. ViewModel'in import listesinde UI tipi olmamalı.
Her şeyi ViewModel'e yığmak. ViewModel view state ve view logic'in evi; indirim kuralı, stok kontrolü ya da sipariş numarası üretimi domain'e ait. Örneklerde hesap Pricing.PriceOrder'da, ViewModel yalnız onu çağırıyor.
Hesaplanan property'leri field'a kopyalamak. Total'ı bir field'da tutup QuantityText değişince elle güncellemek iki gerçek kaynak yaratır. Türeyebilen değer türetilmeli: C#'ta hesaplanan property ve NotifyPropertyChangedFor, Angular'da computed().
Angular'da effect() ile türetilmiş state yazmak. effect(() => this.total.set(...)) çalışır gibi görünür ama computed()'ın yaptığı işi daha kırılgan biçimde yapar; Angular dokümantasyonu da effect'leri state yaymak için değil, dış dünyaya yan etki için öneriyor. Tek yönlü akışı yedinci yazıda ayrıntılı ele alacağız.
Ne Zaman Kullanma
Tek seferlik, birkaç alanlı bir dialog için ayrı bir ViewModel class'ı, command'lar ve bildirim attribute'ları, ekranın kendisinden uzun olabilir; WPF'te bile böyle bir dialog için code-behind meşru bir tercih. Binding'i olmayan bir platformda MVVM'i zorlamak, kendi binding'ini yazmak demek; orada MVP daha dürüst bir seçim. Ve çok sayıda bağımsız ekranın aynı state'i paylaştığı, birinin değişikliğinin diğerlerini zincirleme güncellediği büyük uygulamalarda two-way binding ağının kendisi problem olabiliyor; bu, altıncı yazıdaki Flux'ın çıkış noktası.
Küçük Bir Deneme
★☆☆ C# ViewModel'ine SelectedBook değişince PlaceOrderCommand'in de yeniden sorgulanmasını sağlayan attribute'u ekle. Bu senaryoda gerçekten gerekli mi? Console çıktısıyla doğrula.
★★☆ Angular component'ine bir discountApplied computed'ı ekle ve template'te "10% off" rozetini buna bağla. Testini DOM'a bakmadan, yalnız signal'ı okuyarak yaz.
★★★ C# ViewModel'i için bir xUnit test projesi kur ve QuantityText değiştiğinde Total için PropertyChanged fırladığını doğrulayan bir test yaz. Sonra attribute'tan Total'ı çıkarıp testin kırıldığını gör; az önceki "sessiz hata" artık sessiz değil.
Dört yazıdır pattern'leri tek tek kurduk. Bir sonraki yazıda soruyu tersine çeviriyoruz: elimizdeki framework'ler, Angular, React, Blazor, WPF ve ASP.NET Core, bu pattern'lerin hangisine karşılık geliyor ve "Angular MVC (Model-View-Controller) mi, MVVM mi?" sorusunun gerçek cevabı ne?