UI Mimari Pattern'leri Neden Var? Separated Presentation ve MVC'den Signal'lara Kısa Tarihçe
UI mimari pattern'leri tek bir problemi çözmek için var: ekran kodu ile domain mantığı iç içe geçince ikisi de değiştirilemez ve test edilemez hâle geliyor. Bu yazıda Bookstore'un sipariş formunu tüm mantığı event handler'da olan Smart UI hâlinden Separated Presentation'a taşıyor, view state ile domain state'i ayırıyor ve MVC'den signal'lara uzanan tarihçeyi her pattern'in çözdüğü problemle birlikte görüyoruz.
Sürüm notu: Bu yazıdaki örnekler Angular 22 ve TypeScript ile denenmiştir.
Önce Tahmin Et
Bookstore'un web tarafına küçük bir sipariş formu lazım: kitabı seç, adedi yaz, toplamı gör, siparişi ver. Kural basit: adet 1 ile 10 arasında bir tam sayı olmalı, 3 ve üzeri kitapta yüzde 10 indirim var. Framework'süz, düz TypeScript ve DOM (Document Object Model) ile yazılmış ilk hâli şu:
const books = [
{ id: 1, title: 'The Hobbit', price: 14.0 },
{ id: 2, title: 'Emma', price: 9.5 },
];
export function mountOrderForm(root: HTMLElement): void {
root.innerHTML = `
<select id="book">
${books.map(b => `<option value="${b.id}">${b.title}</option>`).join('')}
</select>
<input id="quantity" value="1">
<p id="total"></p>
<p id="error"></p>
<button id="place-order">Place order</button>`;
const bookSelect = root.querySelector<HTMLSelectElement>('#book')!;
const quantityInput = root.querySelector<HTMLInputElement>('#quantity')!;
const totalLabel = root.querySelector<HTMLElement>('#total')!;
const errorLabel = root.querySelector<HTMLElement>('#error')!;
const placeButton = root.querySelector<HTMLButtonElement>('#place-order')!;
const refresh = () => {
const book = books.find(b => b.id === Number(bookSelect.value))!;
const quantity = Number(quantityInput.value);
if (!Number.isInteger(quantity) || quantity < 1 || quantity > 10) {
errorLabel.textContent = 'Quantity must be between 1 and 10';
totalLabel.textContent = '';
placeButton.disabled = true;
return;
}
let total = book.price * quantity;
if (quantity >= 3) total = total * 0.9; // 3 ve üzeri kitapta %10 indirim
errorLabel.textContent = '';
totalLabel.textContent = `Total: ${total.toFixed(2)}`;
placeButton.disabled = false;
};
bookSelect.addEventListener('change', refresh);
quantityInput.addEventListener('input', refresh);
placeButton.addEventListener('click', async () => {
placeButton.disabled = true;
const response = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bookId: Number(bookSelect.value), quantity: Number(quantityInput.value) }),
});
totalLabel.textContent = response.ok ? 'Order placed' : 'Something went wrong';
});
refresh();
}
Çalışıyor. Ekranda The Hobbit'i seçip adede 3 yazınca "Total: 37.80" görünüyor, "abc" yazınca buton kapanıyor. Şimdi iki soru. Birincisi: "3 kitapta yüzde 10 indirim" kuralını test etmek istesen testin neye ihtiyaç duyar? İkincisi: yarın sepet sayfası da aynı indirimi gösterecek. O kural nereden gelecek?
Smart UI: Her Şey Event Handler'da
Yukarıdaki kodun bir adı var. Eric Evans, DDD (Domain-Driven Design) kitabında (2003) buna Smart UI (UI: User Interface, kullanıcı arayüzü) diyor ve bilerek "anti-pattern" diye tırnak içinde anıyor: iş kuralları ekranın içine, her ekran kendi kuralını taşıyacak şekilde yazılıyor. Martin Fowler'ın GUI (Graphical User Interface) mimarileri üzerine yazdığı "GUI Architectures" (2006) makalesindeki karşılığı Forms and Controls: 1990'ların Visual Basic, Delphi ve PowerBuilder araçlarında form'u çizip control'lerin event'lerine kod yazdığın model. Masaüstü uygulama geliştirmenin en hızlı yolu buydu ve bugün de küçük bir ekran için hâlâ en hızlı yol.
Testin ihtiyaç duyduğu şey ilk sorunun cevabı. Kural refresh callback'inin içinde, DOM element'lerinden okunan string'lerin arasında duruyor. Onu test etmek için form'u gerçekten kurman, input'a değer yazman, event fırlatman ve sonucu bir paragrafın textContent'inden okuman gerekiyor:
import { expect, test } from 'vitest';
import { mountOrderForm } from './order-form.smart';
test('3 copies of The Hobbit get 10% off', () => {
const root = document.createElement('div');
mountOrderForm(root);
const quantity = root.querySelector<HTMLInputElement>('#quantity')!;
quantity.value = '3';
quantity.dispatchEvent(new Event('input'));
expect(root.querySelector('#total')!.textContent).toBe('Total: 37.80');
});
Vitest bu testi jsdom üzerinde çalıştırıyor ve test geçiyor. Ama testin yarısı DOM kurulumu, yarısı selector. Kuralın kendisi tek satır; onu doğrulamak için #quantity, #total id'lerini, event tipini ve metin biçimini bilmek zorundasın. Tasarımcı "Total" yazısını "Toplam tutar" yapınca indirim kuralının testi kırılıyor.
Neden Ölçeklenmiyor?
Fowler, Forms and Controls'u anlatırken aynı verinin üç kopyasından söz ediyor. Record state veritabanındaki kalıcı kayıt. Session state uygulamanın bellekte tuttuğu çalışma kopyası. Screen state ise control'lerin içinde duran değer: input'taki metin, select'in seçili option'ı. Smart UI'da ikinci kopya yok denecek kadar ince. Adet, hiçbir yerde number olarak yaşamıyor; tek gerçek kaynağı quantityInput.value string'i ve her event'te yeniden parse ediliyor.
Bu yapı ekran sayısı arttıkça üç yerden çatlıyor. Birincisi tekrar: sepet sayfası, sipariş özeti ve e-posta template'i aynı indirimi hesaplamak zorunda ve her biri kuralı kendi event handler'ına kopyalıyor. Kural "5 ve üzeri kitapta yüzde 15" olduğunda kaç yerin değişeceğini kimse bilmiyor. İkincisi test: kuralı doğrulamanın tek yolu ekranı kurmak; bu yüzden test ya hiç yazılmıyor ya da her tasarım değişikliğinde kırılıyor. Üçüncüsü değişim: ekranı Angular'a taşımak istediğinde mantığı DOM kodundan cımbızla ayıklaman gerekiyor, çünkü ikisi aynı fonksiyonun içinde.
Dikkat et, problem kodun kötü yazılmış olması değil. Kod okunaklı, kısa ve çalışıyor. Problem, iki farklı hızda değişen iki şeyin aynı yerde durması: ekran tasarımı her sprint değişiyor, fiyat kuralı yılda bir değişiyor ama değiştiğinde her yerde aynı anda değişmeli.
Separated Presentation: İlk ve En Önemli Ayrım
Fowler bu ayrıma Separated Presentation diyor: domain'i modelleyen kod ile ekranı yöneten kodu net biçimde ayır; domain kodu presentation kodundan tamamen habersiz olsun. Bağımlılık tek yönlü: ekran domain'i bilir, domain ekranı bilmez. SOLID (Robert C. Martin'in beş tasarım prensibinin baş harfleri) ve Design Principles serisinin "Daha Az Bilinen Prensipler" yazısında gördüğün Separation of Concerns'ün UI tarafındaki en eski ve en çok işe yarayan uygulaması bu.
Sipariş formu için ilk adım, kuralları DOM'dan habersiz bir module'e çıkarmak:
export interface Book {
readonly id: number;
readonly title: string;
readonly price: number;
}
export const catalog: readonly Book[] = [
{ id: 1, title: 'The Hobbit', price: 14.0 },
{ id: 2, title: 'Emma', price: 9.5 },
];
export type QuantityCheck =
| { ok: true; quantity: number }
| { ok: false; error: string };
export function parseQuantity(text: string): QuantityCheck {
const quantity = Number(text);
if (!Number.isInteger(quantity) || quantity < 1 || quantity > 10) {
return { ok: false, error: 'Quantity must be between 1 and 10' };
}
return { ok: true, quantity };
}
export function priceOrder(book: Book, quantity: number): number {
const subtotal = book.price * quantity;
return quantity >= 3 ? subtotal * 0.9 : subtotal; // 3 ve üzeri kitapta %10 indirim
}
parseQuantity sonucu bir discriminated union olarak dönüyor; TypeScript serisinin "TypeScript'te Union ve Literal Tipler: Bir Değer, Birkaç İhtimal" yazısındaki pattern. Artık test DOM'a hiç dokunmuyor:
import { describe, expect, it } from 'vitest';
import { catalog, parseQuantity, priceOrder } from './pricing';
const hobbit = catalog[0];
describe('priceOrder', () => {
it('charges full price below 3 copies', () => {
expect(priceOrder(hobbit, 2)).toBe(28);
});
it('gives 10% off from 3 copies', () => {
expect(priceOrder(hobbit, 3)).toBeCloseTo(37.8);
});
});
describe('parseQuantity', () => {
it.each(['0', '11', '2.5', 'abc', ''])('rejects %j', text => {
expect(parseQuantity(text).ok).toBe(false);
});
});
✓ src/domain/pricing.spec.ts > priceOrder > charges full price below 3 copies 0ms
✓ src/domain/pricing.spec.ts > priceOrder > gives 10% off from 3 copies 0ms
✓ src/domain/pricing.spec.ts > parseQuantity > rejects "0" 0ms
✓ src/domain/pricing.spec.ts > parseQuantity > rejects "11" 0ms
✓ src/domain/pricing.spec.ts > parseQuantity > rejects "2.5" 0ms
✓ src/domain/pricing.spec.ts > parseQuantity > rejects "abc" 0ms
✓ src/domain/pricing.spec.ts > parseQuantity > rejects "" 0ms
Test Files 1 passed (1)
Tests 7 passed (7)
Kural, sepet sayfasında da e-posta template'inde de aynı fonksiyondan geliyor. İkinci sorunun cevabı da bu oldu.
View State ile Domain State Aynı Şey Değil
Separated Presentation'ı uygularken çoğu ekipte aynı soru çıkıyor: "Peki hata mesajı nereye gidiyor? Buton kapalı mı açık mı bilgisi nerede duruyor?" Bunlar domain'e ait değil. Domain'in bilmesi gereken "bir siparişin adedi 1 ile 10 arasında olmalı" kuralı; kullanıcının input'a şu anda "ab" yazmış olması, hata mesajının kırmızı görünmesi ya da sipariş kaydedilirken butonun kilitlenmesi ekranın durumu.
Bu yüzden ayrımı ikiye bölmek işe yarıyor. Domain state: kitap, fiyat, adet, indirimli toplam; ekran olmasa da anlamlı. View state: input'taki ham metin, seçili sekme, "kaydediliyor" bayrağı, hangi alanın dokunulduğu (touched) bilgisi; yalnız ekran açıkken anlamlı. View logic ise view state'ten türeyen kararlar: "adet geçersizse butonu kapat", "kaydederken çift tıklamayı engelle".
Bu yazıdaki ilk adımla domain state ve domain kuralları ayrıldı, ama view state ve view logic hâlâ event handler'da. Serinin geri kalanındaki pattern'lerin neredeyse tamamı tam bu soruya farklı cevaplar veriyor: view logic'i kim taşıyacak ve view ile nasıl senkron kalacak? MVC (Model-View-Controller) bu soruyu büyük ölçüde açık bırakıyor, MVP (Model-View-Presenter) presenter'a veriyor, MVVM (Model-View-ViewModel) ViewModel'e veriyor.
Humble View: Test Edilmesi Zor Olanı Aptallaştır
İkinci fikir test edilebilirlikle ilgili. Michael Feathers 2002'de "The Humble Dialog Box" yazısında şunu önerdi: test edilmesi zor olan object'i (o zamanlar bir dialog penceresi) olabildiğince davranışsız bırak, mantığı test edilebilir bir object'e taşı. Gerard Meszaros bunu xUnit Test Patterns kitabında Humble Object adıyla genelleştirdi; Fowler da UI bağlamında Humble View diyor.
Sipariş formuna uygulayınca refresh callback'i şu hâle geliyor:
const refresh = () => {
const book = catalog.find(b => b.id === Number(bookSelect.value))!;
const check = parseQuantity(quantityInput.value);
errorLabel.textContent = check.ok ? '' : check.error;
totalLabel.textContent = check.ok ? `Total: ${priceOrder(book, check.quantity).toFixed(2)}` : '';
placeButton.disabled = !check.ok;
};
Event handler hâlâ var ama artık karar vermiyor; okuyor, domain fonksiyonuna soruyor, sonucu ekrana yazıyor. Test edilmeyen kısım küçüldü ve mekanik hâle geldi. Bu, Humble View'ın yarısı. Diğer yarısı, "buton ne zaman kapanır" gibi view logic'i de ekrandan çıkarmak; bunu üçüncü yazıda MVP'nin presenter'ıyla tamamlayacağız.
Kısa Tarihçe: Smalltalk'tan Signal'lara

Bu fikirlerin hiçbiri yeni değil ve sıralamaları bir hikâye anlatıyor. Trygve Reenskaug, 1978–79'da Xerox PARC'ta (Palo Alto Research Center) misafir araştırmacıyken Smalltalk için MVC'yi tarif etti; 1979'da yazdığı notlarda ilk adı Thing-Model-View-Editor'dü. Smalltalk-80 bu fikrin evrilmiş bir hâlini ile geldi: model ekrandan habersizdi, view'lar model'i observe ediyordu, controller kullanıcının klavye ve mouse hareketlerini yorumluyordu. Separated Presentation burada doğdu.
1990'larda masaüstü araçları başka bir yöne gitti: Visual Basic ve Delphi ile form'a control sürükleyip event'e kod yazmak hızlıydı ve Forms and Controls neredeyse standart oldu. 1996'da Taligent'in CTO'su (Chief Technology Officer) Mike Potel, MVP makalesinde Smalltalk MVC'yi genelleştirdi; presenter, controller'ın yerini alıp ekran mantığını üstlendi. Dolphin Smalltalk ekibi 2000'de MVP'yi kendi UI framework'ünün temeli yaptı.
Fowler 2004'te Presentation Model'i yazdı: view'ın state'ini ve davranışını, ekrandaki control'lerden bağımsız bir class'ta tut. Bir yıl sonra, Ekim 2005'te Microsoft'tan John Gossman, WPF (Windows Presentation Foundation) için aynı fikri data binding üzerine kurup MVVM adıyla duyurdu. 2014'te Facebook, two-way binding'in büyük uygulamalardaki yan etkilerine cevap olarak Flux'ı tanıttı; Dan Abramov'un 2015'te çıkardığı Redux tek yönlü veri akışını yaygınlaştırdı. Bugün Angular, Vue ve Solid gibi framework'ler UI'ı state'ten türetiyor ve signal'larla yalnız değişen parçayı güncelliyor; Angular'ın tarafı serinin "Angular 16 Yenilikleri: Signals, Required Input ve takeUntilDestroyed" yazısında başladı.
Her durak bir öncekinin bıraktığı bir problemi çözmeye çalışıyor: MVC domain'i ekrandan ayırdı ama view state'e yer bırakmadı; MVP ve Presentation Model view logic'i test edilebilir bir yere taşıdı; MVVM senkronizasyon kodunu framework'e verdi; Flux, binding'in karmaşıklaştırdığı veri akışını tek yöne çevirdi. Seri bu sırayla ilerleyecek: ikinci yazıda MVC, üçüncüde MVP, dördüncüde MVVM ve beşincide "Angular MVC mi, MVVM mi?" sorusu.
Sık Düşülen Tuzaklar
Separated Presentation'ı klasör ayrımı sanmak. components/ ve services/ klasörleri açıp aynı kodu ikiye bölmek bağımlılık yönünü değiştirmez. Ölçü basit: domain module'ün herhangi bir DOM, Angular ya da HTTP (Hypertext Transfer Protocol) import'u var mı? Varsa ayrım yok.
View state'i domain'e itmek. Hata mesajı metnini ya da "kaydediliyor" bayrağını Order class'ına koymak domain'i ekrana bağlar. Domain "adet geçersiz" der; bunun hangi dilde, hangi renkte gösterileceği ekranın işi.
Domain'i ekranın ihtiyacına göre şekillendirmek. Ekranda iki ondalıklı string lazım diye priceOrder'ın string döndürmesi, domain'i bir view'a kilitler. Biçimlendirme presentation tarafında kalmalı.
Para hesabını floating point ile yapıp toBe ile test etmek. Testte toBeCloseTo kullanmamın sebebi bu; toBe(37.8) yazınca gerçek çıktı şu:
FAIL src/domain/pricing.spec.ts > priceOrder > gives 10% off from 3 copies
AssertionError: expected 37.800000000000004 to be 37.8 // Object.is equality
- Expected
+ Received
- 37.8
+ 37.800000000000004
Örnekte ekranda toFixed(2) ile yuvarladığımız için kullanıcı farkı görmüyor ama gerçek bir sistemde tutarları kuruş cinsinden integer ya da sunucuda decimal olarak hesaplarsın; seri boyunca konu pattern olduğu için bu sadeleştirmeyi koruyacağım.
Ne Zaman Kullanma
Her ekran Separated Presentation istemez. Bir kez kullanılacak bir admin ekranı, bir prototip ya da iki alanlı bir arama kutusu Smart UI olarak gayet iyi yaşar; Evans da Smart UI'ı basit, kısa ömürlü uygulamalar için meşru bir tercih olarak anar. Ayrımın bedeli fazladan dosya ve dolaylılık. Kural tek bir yerde kullanılıyor, değişmesi beklenmiyor ve hatası ucuzsa o bedeli ödemek zorunda değilsin.
Ama sinyalleri tanı: aynı hesap ikinci ekranda göründüğünde, bir kural için test yazmak istediğinde ya da ekran framework değiştireceğinde ayrımı yapma zamanı gelmiş demektir. Ayrımı sonradan yapmak mümkün; yukarıdaki dönüşüm on dakika sürdü. Pahalı olan, ayrımın hiç yapılmadığı beş yıllık bir kod tabanı.
Küçük Bir Deneme
★☆☆ parseQuantity'ye " 3 " (başında ve sonunda boşluk olan) metni ver. Kabul ediyor mu? Number()'ın bu davranışı senin iş kuralına uyuyor mu? Uymuyorsa testini önce yaz, sonra düzelt.
★★☆ Kurala yeni bir koşul ekle: "Emma için indirim yok." Değişikliği Smart UI sürümünde ve pricing.ts sürümünde ayrı ayrı yap. Kaç dosyaya, kaç teste dokundun?
★★★ Formun ekranda gösterdiği her bilgiyi üç başlık altında listele: record state, session state, screen state. Sonra screen state'in hangi parçalarının view logic'e girdi olduğunu işaretle; bu liste üçüncü yazıdaki presenter'ın interface'i olacak.
Bir sonraki yazıda MVC'nin kendisine bakıyoruz: Smalltalk'taki orijinal MVC'yi aynı sipariş formuyla TypeScript'te kuracak, sonra ASP.NET Core MVC'deki Web MVC ile karşılaştırıp aynı üç harfin neden iki farklı akışı anlattığını göreceğiz.