MVP Pattern'i: Passive View, Supervising Controller ve Test Edilebilir Presenter
MVP'de view bir interface'in arkasına çekilir ve tüm ekran mantığı presenter'a taşınır; böylece presenter, DOM olmadan sahte bir view ile milisaniyeler içinde test edilir. Sipariş formunu TypeScript'te OrderFormView interface'i, DOM implementasyonu ve Vitest testleriyle kuruyor, Passive View ile Supervising Controller farkını ve presenter'ın şişmesini önlemenin yolunu görüyoruz.
Sürüm notu: Bu yazıdaki örnekler Angular 22 ve TypeScript ile denenmiştir.
Önce Tahmin Et
İlk yazıdaki Smart UI'ın (UI: User Interface, kullanıcı arayüzü) testi DOM (Document Object Model) kuruyor, input'a değer yazıyor, event fırlatıyor ve bir paragrafın metnini okuyordu. İkinci yazıdaki MVC (Model-View-Controller) sürümünde domain kuralları test edilebilir hâle geldi, ama "adet geçersizse butonu kapat" ve "kaydederken çift tıklamayı engelle" gibi view logic hâlâ controller ile view arasına dağılmış durumdaydı.
Şu davranışları test etmek istiyorsun: sayfa açılınca kitaplar listelenir; Emma için adet 3 yazılınca toplam 25.65 olur; "abc" yazılınca buton kapanır; butona iki kez hızlıca basılınca sipariş API'sine (Application Programming Interface) yalnızca bir istek gider; API hata dönerse kullanıcı bir hata mesajı görür. Bu beş testten kaçı bir tarayıcıya ya da jsdom'a ihtiyaç duyar? Doğru tasarımla cevap sıfır.
View Interface ve Presenter
MVP (Model-View-Presenter) fikrini tek cümleyle söylersek: view'ı bir interface'in arkasına sakla, ekranla ilgili her kararı presenter'a ver. Presenter view'ı yalnız bu interface üzerinden tanıyor; DOM'u, Angular'ı ya da WinForms'u bilmiyor. Sipariş formunun view interface'i:
export interface BookOption {
readonly id: number;
readonly title: string;
}
export interface OrderFormView {
readonly selectedBookId: number;
readonly quantityText: string;
showBooks(books: readonly BookOption[]): void;
showTotal(text: string): void;
showError(message: string): void;
setPlaceOrderEnabled(enabled: boolean): void;
onBookChanged(handler: () => void): void;
onQuantityChanged(handler: () => void): void;
onPlaceOrder(handler: () => void): void;
}
Interface iki tür üye taşıyor. Getter'lar ve show... method'ları presenter'ın view'ı okuması ve ona bir şey göstermesi için. on... method'ları ise kullanıcının hareketlerini presenter'a iletmek için; view yalnız "bir şey oldu" diyor, ne anlama geldiğine karar vermiyor. Bu, Smalltalk MVC'deki controller'ın input yorumlama işinin de presenter'a geçmesi demek. Interface ve implements ile çalışmanın TypeScript tarafı TypeScript serisinin "TypeScript ile Class'lar: Erişim Belirleyiciler, abstract, implements ve override" yazısında.
Presenter, ilk yazıdaki pricing.ts'i kullanıyor ve sipariş API'sini de bir interface olarak alıyor:
import { catalog, parseQuantity, priceOrder } from '../domain/pricing';
import type { OrderFormView } from './order-form-view';
export interface OrderApi {
placeOrder(bookId: number, quantity: number): Promise<{ orderId: number }>;
}
export class OrderFormPresenter {
#saving = false;
constructor(private readonly view: OrderFormView, private readonly api: OrderApi) {
view.showBooks(catalog);
view.onBookChanged(() => this.refresh());
view.onQuantityChanged(() => this.refresh());
view.onPlaceOrder(() => this.placeOrder());
this.refresh();
}
private refresh(): void {
const check = parseQuantity(this.view.quantityText);
if (!check.ok) {
this.view.showError(check.error);
this.view.showTotal('');
this.view.setPlaceOrderEnabled(false);
return;
}
const book = catalog.find(b => b.id === this.view.selectedBookId)!;
this.view.showError('');
this.view.showTotal(`Total: ${priceOrder(book, check.quantity).toFixed(2)}`);
this.view.setPlaceOrderEnabled(!this.#saving);
}
private async placeOrder(): Promise<void> {
const check = parseQuantity(this.view.quantityText);
if (!check.ok || this.#saving) return;
this.#saving = true;
this.view.setPlaceOrderEnabled(false);
try {
const { orderId } = await this.api.placeOrder(this.view.selectedBookId, check.quantity);
this.view.showTotal(`Order #${orderId} placed`);
} catch {
this.view.showError('Something went wrong, please try again');
} finally {
this.#saving = false;
this.view.setPlaceOrderEnabled(parseQuantity(this.view.quantityText).ok);
}
}
}
Presenter'da tek bir document, HTMLElement ya da selector yok. #saving bayrağı tam bir view state: domain'in bilmediği, yalnız ekran açıkken anlamlı bir bilgi. Klasik MVC'de bu bilginin bir evi yoktu; MVP'de evi presenter.
Humble View: DOM Implementasyonu
Gerçek view, interface'i DOM ile uygulayan ince bir class:
import type { BookOption, OrderFormView } from './order-form-view';
export class DomOrderFormView implements OrderFormView {
readonly #book: HTMLSelectElement;
readonly #quantity: HTMLInputElement;
readonly #total: HTMLElement;
readonly #error: HTMLElement;
readonly #placeOrder: HTMLButtonElement;
constructor(root: HTMLElement) {
root.innerHTML = `
<select id="book"></select>
<input id="quantity" value="1">
<p id="total"></p>
<p id="error"></p>
<button id="place-order">Place order</button>`;
this.#book = root.querySelector('#book')!;
this.#quantity = root.querySelector('#quantity')!;
this.#total = root.querySelector('#total')!;
this.#error = root.querySelector('#error')!;
this.#placeOrder = root.querySelector('#place-order')!;
}
get selectedBookId(): number { return Number(this.#book.value); }
get quantityText(): string { return this.#quantity.value; }
showBooks(books: readonly BookOption[]): void {
this.#book.replaceChildren(...books.map(b => new Option(b.title, String(b.id))));
}
showTotal(text: string): void { this.#total.textContent = text; }
showError(message: string): void { this.#error.textContent = message; }
setPlaceOrderEnabled(enabled: boolean): void { this.#placeOrder.disabled = !enabled; }
onBookChanged(handler: () => void): void { this.#book.addEventListener('change', handler); }
onQuantityChanged(handler: () => void): void { this.#quantity.addEventListener('input', handler); }
onPlaceOrder(handler: () => void): void { this.#placeOrder.addEventListener('click', handler); }
}
Bu class'ta tek bir if yok. Her method bir satır ve yaptığı iş mekanik: bir değeri okumak, bir değeri yazmak ya da bir listener eklemek. İlk yazıda gördüğümüz Humble View fikrinin tamamlanmış hâli bu; test edilmesi zor olan parça, test edilmesine gerek kalmayacak kadar aptal.
Presenter'ı View Olmadan Test Etmek
Test için interface'i bellekte uygulayan sahte bir view yazıyoruz. Gerçek view'dan farkı, gösterdiği her şeyi bir field'a kaydetmesi ve testin kullanıcı gibi davranabilmesi için birkaç yardımcı method sunması:
import type { BookOption, OrderFormView } from './order-form-view';
export class FakeOrderFormView implements OrderFormView {
selectedBookId = 1;
quantityText = '1';
books: readonly BookOption[] = [];
total = '';
error = '';
placeOrderEnabled = false;
#bookChanged = () => {};
#quantityChanged = () => {};
#placeOrder = () => {};
showBooks(books: readonly BookOption[]): void { this.books = books; }
showTotal(text: string): void { this.total = text; }
showError(message: string): void { this.error = message; }
setPlaceOrderEnabled(enabled: boolean): void { this.placeOrderEnabled = enabled; }
onBookChanged(handler: () => void): void { this.#bookChanged = handler; }
onQuantityChanged(handler: () => void): void { this.#quantityChanged = handler; }
onPlaceOrder(handler: () => void): void { this.#placeOrder = handler; }
// testin kullanıcı gibi davranması için
typeQuantity(text: string): void {
this.quantityText = text;
this.#quantityChanged();
}
selectBook(id: number): void {
this.selectedBookId = id;
this.#bookChanged();
}
clickPlaceOrder(): void { this.#placeOrder(); }
}
Önce Tahmin Et'teki beş davranışın testi:
import { describe, expect, it, vi } from 'vitest';
import { OrderFormPresenter, type OrderApi } from './order-form-presenter';
import { FakeOrderFormView } from './fake-order-form-view';
function setup(api: OrderApi = { placeOrder: vi.fn().mockResolvedValue({ orderId: 42 }) }) {
const view = new FakeOrderFormView();
new OrderFormPresenter(view, api);
return { view, api };
}
describe('OrderFormPresenter', () => {
it('shows the books and the initial total', () => {
const { view } = setup();
expect(view.books.map(b => b.title)).toEqual(['The Hobbit', 'Emma']);
expect(view.total).toBe('Total: 14.00');
expect(view.placeOrderEnabled).toBe(true);
});
it('applies the discount from 3 copies', () => {
const { view } = setup();
view.selectBook(2);
view.typeQuantity('3');
expect(view.total).toBe('Total: 25.65');
});
it('disables the button for invalid quantity', () => {
const { view } = setup();
view.typeQuantity('abc');
expect(view.error).toBe('Quantity must be between 1 and 10');
expect(view.placeOrderEnabled).toBe(false);
});
it('places the order once and shows its number', async () => {
const { view, api } = setup();
view.typeQuantity('2');
view.clickPlaceOrder();
view.clickPlaceOrder(); // çift tıklama
await vi.waitFor(() => expect(view.total).toBe('Order #42 placed'));
expect(api.placeOrder).toHaveBeenCalledTimes(1);
expect(api.placeOrder).toHaveBeenCalledWith(1, 2);
});
it('shows an error when the API fails', async () => {
const { view } = setup({ placeOrder: vi.fn().mockRejectedValue(new Error('503')) });
view.clickPlaceOrder();
await vi.waitFor(() => expect(view.error).toBe('Something went wrong, please try again'));
expect(view.placeOrderEnabled).toBe(true);
});
});
Gerçek DOM view'ının aynı presenter'la doğru bağlandığını görmek için tek bir entegrasyon testi yeterli:
import { expect, test, vi } from 'vitest';
import { OrderFormPresenter } from './order-form-presenter';
import { DomOrderFormView } from './dom-order-form-view';
test('the real DOM view wires up with the same presenter', () => {
const root = document.createElement('div');
new OrderFormPresenter(new DomOrderFormView(root), { placeOrder: vi.fn() });
const quantity = root.querySelector<HTMLInputElement>('#quantity')!;
quantity.value = '3';
quantity.dispatchEvent(new Event('input'));
expect(root.querySelector('#total')!.textContent).toBe('Total: 37.80');
});
✓ src/p03/supervising.spec.ts > view binds simple fields, presenter handles validation 5ms
✓ src/p03/dom-view.spec.ts > the real DOM view wires up with the same presenter 6ms
✓ src/p03/order-form-presenter.spec.ts > OrderFormPresenter > shows the books and the initial total 1ms
✓ src/p03/order-form-presenter.spec.ts > OrderFormPresenter > applies the discount from 3 copies 0ms
✓ src/p03/order-form-presenter.spec.ts > OrderFormPresenter > disables the button for invalid quantity 0ms
✓ src/p03/order-form-presenter.spec.ts > OrderFormPresenter > places the order once and shows its number 52ms
✓ src/p03/order-form-presenter.spec.ts > OrderFormPresenter > shows an error when the API fails 50ms
Test Files 3 passed (3)
Tests 7 passed (7)
Presenter testlerinde DOM yok, selector yok, metin biçimine bağımlı bir querySelector yok. Çift tıklama testi #saving bayrağının işe yaradığını, hata testi butonun hata sonrası yeniden açıldığını doğruluyor. İki async testteki yaklaşık 50 milisaniye, vi.waitFor'un varsayılan yoklama aralığı; presenter'ın kendisi beklemiyor. Tasarımcı "Total" yazısını değiştirdiğinde yalnız tek bir entegrasyon testi etkileniyor.
Passive View ve Supervising Controller
Yukarıdaki sürümün adı Passive View. View hiçbir mantık taşımıyor, kendi state'ini bile senkron tutmuyor; presenter her şeyi tek tek söylüyor. Fowler, 2006'da MVP'yi incelerken bu adı kullanmaya başladı ve MVP etiketini iki ayrı pattern'e böldü. Passive View'ın Potel'in orijinal MVP tarifinde olmadığını, insanlar test edilebilirliği zorladıkça ortaya çıktığını da not ediyor.
İkinci varyant Supervising Controller. Burada view, basit ve bildirimsel eşlemeleri (bir alanın değerini ekranda göstermek gibi) kendisi yapıyor, genelde bir binding mekanizmasıyla; presenter yalnız karmaşık durumlara karışıyor. İkinci yazıdaki observable OrderModel'i kullanan bir sürüm:
import { parseQuantity } from '../domain/pricing';
import { OrderModel } from '../p02/order-model';
export interface SupervisedOrderFormView {
readonly quantityText: string;
bind(model: OrderModel): void; // basit eşlemeleri view kendisi yapar
showError(message: string): void;
setPlaceOrderEnabled(enabled: boolean): void;
onQuantityChanged(handler: () => void): void;
}
export class SupervisingOrderFormPresenter {
constructor(private readonly view: SupervisedOrderFormView, private readonly model: OrderModel) {
view.bind(model);
view.onQuantityChanged(() => this.onQuantityChanged());
}
private onQuantityChanged(): void {
const check = parseQuantity(this.view.quantityText);
this.view.showError(check.ok ? '' : check.error);
this.view.setPlaceOrderEnabled(check.ok);
if (check.ok) this.model.changeQuantity(check.quantity);
}
}
View, bind içinde başlığı ve toplamı model'den kendisi okuyor:
bind(model: OrderModel): void {
const render = () => {
this.#el('#title').textContent = model.book.title;
this.#el('#total').textContent = `Total: ${model.total.toFixed(2)}`;
};
model.subscribe(render);
render();
}
Takas açık. Supervising Controller'da view interface'i küçük ve presenter kısa; basit eşlemeler için presenter'a satır yazmıyorsun. Bedeli, bind içindeki eşlemelerin presenter testlerinin dışında kalması. Fowler'ın Humble View tartışmasındaki ifadeyle: Passive View view'ı tamamen humble yapar ama sahte view gerektirir; Supervising Controller basit eşlemelerde küçük bir risk bırakır. Binding'i güçlü bir platformdaysan Supervising Controller, binding'in olmadığı ya da güvenilmez olduğu yerde Passive View doğal tercih.
Flow Synchronization: Senkronizasyonu Kim Yapıyor?
İkinci yazıda Smalltalk MVC'nin senkronizasyonu Observer ile çözdüğünü gördük: model değişiyor, view'lar kendiliğinden yeniden çiziliyor. Passive View'da durum farklı. Presenter, bir şey değiştiğinde hangi alanların etkilendiğini biliyor ve view'a tek tek söylüyor: refresh method'u hata mesajını, toplamı ve butonun durumunu aynı anda güncelliyor. Fowler bu yaklaşıma Flow Synchronization diyor: senkronizasyon, uygulamanın akışındaki belirli noktalarda açıkça yapılıyor.
Bunun iyi tarafı okunabilirlik. refresh'i okuyan biri ekranda neyin ne zaman değiştiğini koddan görüyor; Observer'daki gibi runtime'da kurulan bir abone listesi yok. Kötü tarafı unutkanlık riski. Yarın forma "kargo ücreti" alanı eklenirse ve onu refresh'e yazmayı unutursan alan eski değerde kalır; hiçbir şey hata vermez. Presenter testleri bu riskin ilacı: her yeni alan için "şu girdi değişince şu çıktı güncellenir" testini yazdığın sürece unutkanlık testte yakalanır. MVVM'in (Model-View-ViewModel) binding'i ise bu listeyi elle tutma işini framework'e devrediyor; bir sonraki yazının konusu tam olarak bu devir.
MVP Nereden Geldi, Bugün Nerede Anlamlı?
MVP, Mike Potel'in 1996'da yayımladığı ve Taligent'in C++ ve Java programlama modelini anlatan makaleyle tanındı; Fowler, fikrin IBM (International Business Machines) ve Taligent'te 1990'larda ortaya çıktığını yazıyor. Potel, Smalltalk MVC'yi genelleştirip controller'ın yerine presenter'ı koydu. 2000'de Andy Bower ve Blair McGlashan, Dolphin Smalltalk için yazdıkları "Twisting the Triad" makalesinde MVP'yi kendi UI framework'lerinin temeli yaptı.
Asıl yaygınlığı .NET ve Android dünyasında yaşadı. WinForms'un data binding desteği sınırlıydı ve form'un code-behind dosyası kolayca Smart UI'a dönüşüyordu; Microsoft patterns & practices ekibinin Smart Client Software Factory gibi rehberleri bu yüzden MVP'yi önerdi. Android'de de 2017'deki Architecture Components'a (ViewModel ve LiveData) kadar Activity'ler her şeyi yapan class'lardı ve MVP, Google'ın kendi örnek projelerinde bile en çok önerilen çözümdü. Her iki dünyada da MVP'den MVVM'e geçiş, platform binding'i ciddi biçimde desteklediği anda oldu.
Bugün MVP'nin anlamlı olduğu yerler bu tarihten çıkarılabiliyor. Binding'i olmayan ortamlar: framework'süz yazılan bir widget, bir oyun motorunun UI'ı, bir terminal arayüzü, bakımı süren bir WinForms uygulaması. Aynı ekran mantığının birden fazla görünümle çalışması gereken durumlar: bir masaüstü ve bir web görünümü, ya da gerçek view'ın yanında bir otomasyon view'ı. Ve UI framework'ünü test ortamında kurmanın pahalı olduğu her yer. Angular gibi binding'i güçlü bir framework'te ise MVP'nin elle yazılmış view interface'i çoğu zaman gereksiz tören; bir sonraki yazıda nedenini göreceğiz.
Sık Düşülen Tuzaklar
Presenter'ın view'ın her pikseline karar vermesi. Ekrana bir renk, bir tooltip, bir yazı kalınlığı eklendikçe interface büyüyor:
// presenter her piksele karar verirse
export interface BloatedOrderFormView {
setQuantityBorderColor(color: string): void;
setQuantityTooltip(text: string): void;
setTotalFontWeight(weight: 'normal' | 'bold'): void;
setBadgeVisible(visible: boolean): void;
setBadgeText(text: string): void;
setBadgeColor(color: string): void;
focusQuantity(): void;
}
// presenter niyeti söyler, görünüm view'da kalır
export interface IntentOrderFormView {
showQuantityState(state: 'valid' | 'invalid'): void;
showDiscount(applied: boolean): void;
}
İlk interface'te presenter bir tasarım aracına dönüşüyor ve her görsel değişiklik presenter'a, interface'e ve sahte view'a dokunuyor. İkincisinde presenter niyeti söylüyor ("adet geçersiz", "indirim uygulandı"); kırmızı kenarlık mı, tooltip mi, ikisi birden mi olacağına view karar veriyor. Ölçü: interface'teki method adlarında renk, piksel ya da CSS (Cascading Style Sheets) kavramı geçiyorsa presenter fazla şey biliyor.
View interface'ini DOM'un aynası yapmak. getQuantityInput(): HTMLInputElement gibi bir üye, presenter'ı yeniden DOM'a bağlar ve sahte view yazmayı imkânsızlaştırır. Interface'te yalnız string, number, boolean ve kendi tiplerin olmalı.
Presenter'a domain kuralı yazmak. İndirim kuralını priceOrder yerine presenter'ın içine yazarsan Separated Presentation'ı geri almış olursun; kural yine yalnız bu ekranda yaşar. Presenter domain'i çağırır, domain'in yerini almaz.
Her ekran için bir presenter, her presenter için bir interface, her interface için bir sahte view. Tek alanlı bir onay dialog'u için bu üçlü, test ettiği mantıktan daha fazla kod üretir.
Ne Zaman Kullanma
Platformun güçlü bir binding mekanizması varsa, örneğin WPF (Windows Presentation Foundation), .NET MAUI (Multi-platform App UI), Angular ya da Blazor'da, MVP'nin view interface'i ve show... method'ları binding'in zaten yaptığı işi elle yeniden yazmak olur. Orada aynı test edilebilirliği MVVM ile, interface yazmadan alırsın. Ekran çok basitse (iki alan, bir buton, hiçbir koşul yok) presenter'ın test edeceği bir mantık yoktur; Smart UI ya da ince bir component yeterli. Ve ekibin geri kalanı pattern'i bilmiyorsa, yarım uygulanmış bir MVP (presenter'ı olan ama view'ı hâlâ karar veren) iki dünyanın kötü yanlarını birleştirir.
Küçük Bir Deneme
★☆☆ Presenter'a yeni bir kural ekle: seçili kitap Emma ise ve adet 10 ise "Only 9 copies in stock" hatası göster. Önce FakeOrderFormView ile testini yaz, sonra presenter'ı değiştir. DomOrderFormView'a dokunman gerekti mi?
★★☆ IntentOrderFormView'ı OrderFormView'a ekle ve DOM implementasyonunda "invalid" durumunu bir CSS class'ı ile göster. Presenter testinde yalnız niyeti (showQuantityState('invalid')) doğrula.
★★★ Aynı OrderFormPresenter için ikinci bir view yaz: girdiyi process.stdin'den okuyan, sonucu console.log ile yazan bir terminal view'ı. Presenter'da tek satır değiştirmeden iki view'ı da çalıştırabildin mi?
MVP'de senkronizasyonu presenter elle yapıyor: her değişiklikte showTotal, showError, setPlaceOrderEnabled çağrıları. Bir sonraki yazıda bu işi binding'e bırakan Presentation Model ve MVVM'e geçiyoruz; aynı sipariş formunu C#'ta CommunityToolkit.Mvvm ile ve Angular'da signal'larla yazacağız.