Mert Özen Her Satırda Daha İleri
Yazılım Mimarisi UI Mimari Pattern'leri — #02

MVC Pattern'i: Orijinal Smalltalk MVC ile Web MVC Aynı Şey Değil

Mert Özen 8 Kas 2026 11 dk 4 görüntülenme
MVC Pattern'i: Orijinal Smalltalk MVC ile Web MVC Aynı Şey Değil

Smalltalk-80'deki orijinal MVC'de view'lar model'i observe eder ve controller yalnızca kullanıcı input'unu yorumlar; Web MVC'de ise view model'i hiç observe etmez, her HTTP request'te controller bir kez çalışır ve view bir kez render edilir. Aynı sipariş formunu önce TypeScript'te observable bir model ile, sonra ASP.NET Core MVC ile kurup iki akışı, fat controller ve anemic view model tuzaklarını karşılaştı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 iş görüşmesinde sık sorulan soru: "MVC (Model-View-Controller) nedir?" Cevap çoğu zaman şöyle gelir: "Request controller'a gelir, controller model'den veriyi alır, view'a verir, view HTML (HyperText Markup Language) üretir." ASP.NET Core ile çalışan biri için bu cevap doğru görünüyor.

Şimdi aynı soruyu Bookstore'un sipariş formuna sor. Kullanıcı adet input'una "3" yazıyor. Bu tuş vuruşu hangi rolün işi: model, view, controller? Toplam ve "10% off" rozeti ekranda iki ayrı yerde gösteriliyorsa, ikisini kim günceller? Ve ASP.NET Core'daki OrdersController bu tanımdaki controller ile aynı şey mi? Cevabını bir kenara yaz; yazının sonunda iki farklı cevabın ikisinin de "doğru" olduğunu, ama farklı pattern'leri anlattığını göreceğiz.

Reenskaug'un Üç Rolü

Bir önceki yazıda gördüğümüz gibi MVC, Trygve Reenskaug'un 1978–79'da Xerox PARC'ta (Palo Alto Research Center) Smalltalk için yaptığı çalışmadan çıktı ve Smalltalk-80 ile yaygınlaştı. Bu MVC'de üç rolün sorumluluğu net. Model domain'i temsil ediyor ve ekrandan tamamen habersiz; Fowler'ın Separated Presentation dediği ayrım bu. View, model'in bir kısmını ekranda gösteriyor. Controller ise kullanıcının klavye ve mouse hareketlerini alıp ne anlama geldiğine karar veriyor ve model'e bildiriyor.

İki ayrıntı bugünkü yaygın anlayıştan farklı. Birincisi, her view'ın kendi controller'ı vardı; view ve controller çift olarak çalışıyordu ve bir ekranda düzinelerce view-controller çifti olabiliyordu. İkincisi, controller view'ı güncellemiyordu. View'ı güncel tutan şey model'in kendisiydi: model değişince "değiştim" diye duyuru yapıyor, onu izleyen her view kendini yeniden çiziyordu.

Observer Synchronization: Model'i Observe Eden View'lar

Fowler bu mekanizmaya Observer Synchronization diyor. Sipariş formunu bu fikirle TypeScript'te kuralım. Model, bir önceki yazıdaki pricing.ts'i kullanıyor ve değiştiğinde listener'larına haber veriyor:

import { catalog, priceOrder, type Book } from '../domain/pricing';

type Listener = () => void;

export class OrderModel {
  #book: Book = catalog[0];
  #quantity = 1;
  readonly #listeners = new Set<Listener>();

  get book(): Book { return this.#book; }
  get quantity(): number { return this.#quantity; }
  get total(): number { return priceOrder(this.#book, this.#quantity); }

  subscribe(listener: Listener): () => void {
    this.#listeners.add(listener);
    return () => this.#listeners.delete(listener);
  }

  selectBook(id: number): void {
    this.#book = catalog.find(b => b.id === id)!;
    this.#changed();
  }

  changeQuantity(quantity: number): void {
    this.#quantity = quantity;
    this.#changed();
  }

  #changed(): void {
    this.#listeners.forEach(listener => listener());
  }
}

OrderModel hiçbir DOM (Document Object Model) tipi import etmiyor; HTMLElement kelimesi bu dosyada geçmiyor. Şimdi aynı model'e iki view bağlayalım: biri toplamı, diğeri indirim rozetini gösteriyor.

import type { OrderModel } from './order-model';

export class OrderTotalView {
  constructor(private readonly element: HTMLElement, private readonly model: OrderModel) {
    model.subscribe(() => this.render()); // model'i observe ediyor
    this.render();
  }

  render(): void {
    this.element.textContent = `Total: ${this.model.total.toFixed(2)}`;
  }
}

export class DiscountBadgeView {
  constructor(private readonly element: HTMLElement, private readonly model: OrderModel) {
    model.subscribe(() => this.render());
    this.render();
  }

  render(): void {
    this.element.hidden = this.model.quantity < 3;
    this.element.textContent = '10% off';
  }
}

İki view birbirini tanımıyor. İkisi de model'e abone oluyor ve model her değiştiğinde kendini model'den okuyarak yeniden çiziyor. Controller ise input'u yorumluyor:

import { parseQuantity } from '../domain/pricing';
import type { OrderModel } from './order-model';

export class QuantityController {
  constructor(
    private readonly input: HTMLInputElement,
    private readonly errorLabel: HTMLElement,
    private readonly model: OrderModel,
  ) {
    input.addEventListener('input', () => this.onInput());
  }

  private onInput(): void {
    const check = parseQuantity(this.input.value);
    if (!check.ok) {
      this.errorLabel.textContent = check.error; // model değişmiyor, hata yalnız ekranda
      return;
    }
    this.errorLabel.textContent = '';
    this.model.changeQuantity(check.quantity);
  }
}

Controller, "abc" gibi geçersiz bir metni model'e hiç göndermiyor. Hata mesajını doğrudan ekrana yazıyor, çünkü "input'a şu an geçersiz bir şey yazılmış" bilgisi domain'e ait değil; bir önceki yazıdaki ayrımla söylersek view state. Klasik MVC'nin bu tür state için ayrı bir yeri yok; ya controller'a ya view'a dağılıyor. Bu boşluk, MVP (Model-View-Presenter) ve Presentation Model'in doğmasının sebeplerinden biri.

Üç testle davranışı doğrulayalım:

import { beforeEach, expect, test } from 'vitest';
import { OrderModel } from './order-model';
import { DiscountBadgeView, OrderTotalView } from './views';
import { QuantityController } from './quantity-controller';

const $ = (id: string) => document.getElementById(id)!;

let model: OrderModel;
beforeEach(() => {
  document.body.innerHTML = `
    <input id="quantity" value="1">
    <p id="error"></p>
    <p id="total"></p>
    <span id="badge"></span>`;
  model = new OrderModel();
  new OrderTotalView($('total'), model);
  new DiscountBadgeView($('badge'), model);
  new QuantityController($('quantity') as HTMLInputElement, $('error'), model);
});

test('one input event updates both views through the model', () => {
  const input = $('quantity') as HTMLInputElement;
  input.value = '3';
  input.dispatchEvent(new Event('input'));
  expect($('total').textContent).toBe('Total: 37.80');
  expect($('badge').hidden).toBe(false);
});

test('a change from code reaches the views too', () => {
  model.selectBook(2);
  model.changeQuantity(4);
  expect($('total').textContent).toBe('Total: 34.20');
});

test('invalid text never reaches the model', () => {
  model.changeQuantity(2);
  const input = $('quantity') as HTMLInputElement;
  input.value = 'abc';
  input.dispatchEvent(new Event('input'));
  expect($('error').textContent).toBe('Quantity must be between 1 and 10');
  expect(model.quantity).toBe(2);
  expect($('total').textContent).toBe('Total: 28.00');
});
 ✓ src/p02/mvc.spec.ts > one input event updates both views through the model 3ms
 ✓ src/p02/mvc.spec.ts > a change from code reaches the views too 1ms
 ✓ src/p02/mvc.spec.ts > invalid text never reaches the model 0ms

 Test Files  1 passed (1)
      Tests  3 passed (3)

İkinci test MVC'nin asıl gücünü gösteriyor. model.selectBook(2) ve model.changeQuantity(4) çağrıları ne bir controller'dan ne de bir event'ten geliyor; yine de toplam view'ı güncelleniyor. Model'i kim değiştirirse değiştirsin (bir input, bir zamanlayıcı, sunucudan gelen bir mesaj) tüm view'lar tutarlı kalıyor. Aynı veriyi gösteren üç pencereyi elle senkron tutmaya çalışmış biri bunun neden önemli olduğunu bilir.

Fowler aynı yazıda bedelini de not ediyor: Observer davranışı implicit olduğu için anlaşılması ve debug edilmesi zor. changeQuantity'ye breakpoint koyduğunda ekranın hangi parçalarının yeniden çizileceğini koddan okuyamazsın; abone listesi runtime'da oluşuyor.

Controller Bir Input Handler'dır, Router Değil

Smalltalk MVC'deki controller, bugünkü anlamıyla bir input handler. "Kullanıcı bu input'a bir şey yazdı, bu ne demek?" sorusunu cevaplıyor. Hangi sayfanın açılacağına, URL'in (Uniform Resource Locator) hangi class'a gideceğine karar vermiyor; Smalltalk'ta URL yoktu. Grafik arayüzlerin gelişmesiyle bu rol giderek görünmez oldu: işletim sistemi ve widget'lar klavye ve mouse'u kendileri yönetmeye başladı. MVVM'i (Model-View-ViewModel) ilk duyuran John Gossman 2005'teki yazısında bunu, controller'ın kaybolmadığını ama 1979'daki kadar düşünmemize gerek kalmadığını söyleyerek anlatıyor.

Angular'da bunun karşılığı (input) event binding'i ve onu karşılayan method. Input'u yorumlama işi framework ile senin yazdığın küçük bir method arasında paylaşılıyor ve kimse ona controller demiyor.

GoF'un Gözünden MVC: Observer, Composite, Strategy

GoF (Gang of Four: Gamma, Helm, Johnson ve Vlissides'in 1994 tarihli Design Patterns kitabı), giriş bölümünde pattern'leri anlatmak için örnek olarak tam da Smalltalk MVC'yi kullanıyor ve onu üç pattern'in birleşimi olarak okuyor. View ile model arasındaki abonelik ilişkisi Observer. İç içe view'lar (bir pencerenin içinde paneller, panellerin içinde butonlar) Composite. View'ın kullanıcı input'una nasıl tepki vereceğini controller'a bırakması ve controller'ı değiştirerek davranışı değiştirebilmesi Strategy.

Bu yüzden MVC'yi tek bir pattern olarak değil, bir pattern ailesi ya da mimari pattern olarak düşünmek daha doğru. Design Patterns serisinin "Observer Pattern Derinlemesine: event, IObservable<T>, IChangeToken ve Domain Event'ler", "Composite Pattern: Ağaç Yapıları Tek Interface ile Gezmek" ve "Strategy Pattern Derinlemesine: Keyed Services, IEnumerable<IStrategy> ve Func ile Runtime Seçim" yazıları bu üç parçayı tek tek anlatıyor.

Web'e Taşınınca: Model 2 ve Web MVC

02-classic-vs-web-mvc-tr.png

1990'ların sonunda Java tarafında JSP (JavaServer Pages) spesifikasyonu, isteği bir servlet'in karşılayıp sonucu bir JSP sayfasına render ettirdiği yaklaşıma "Model 2" adını verdi ve bu yaklaşım kısa sürede MVC olarak anılmaya başladı. Struts, Ruby on Rails, Spring MVC ve 2009'da ASP.NET MVC bu çizgide. Rollerin adları aynı ama akış başka: bir HTTP (Hypertext Transfer Protocol) request'i geliyor, routing onu bir controller action'ına veriyor, action model'i çağırıyor, sonucu bir view'a veriyor, view HTML üretiyor ve response gidiyor.

Figürdeki fark tek cümle: Web MVC'de kesikli ok yok. View model'i observe etmiyor; zaten request bitince ortada observe edilecek bir view kalmıyor. Senkronizasyonun tamamı "her request'te baştan render et" ile çözülüyor. Fowler'ın PoEAA (Patterns of Enterprise Application Architecture, Martin Fowler, 2002) kitabındaki adlarla ASP.NET Core'u okursak: routing ve middleware pipeline'ı Front Controller, controller action'ı request'i karşılayan input controller, Razor da bir Template View.

ASP.NET Core MVC'de Aynı Sipariş Formu

Aynı formun Web MVC hâli. Domain tarafında Pricing.PriceOrder ve Catalog, TypeScript'tekiyle aynı kuralı C# ile uyguluyor. Form'un taşıdığı veri ve sonuç:

using System.ComponentModel.DataAnnotations;

namespace Bookstore.Web.Models;

public sealed class OrderForm
{
    public int BookId { get; set; } = 1;

    [Range(1, 10, ErrorMessage = "Quantity must be between 1 and 10")]
    public int Quantity { get; set; } = 1;
}

public sealed record OrderSummary(string Title, int Quantity, decimal Total);

Controller iki action'dan oluşuyor; biri boş formu gösteriyor, diğeri gönderilen formu işliyor:

using Bookstore.Web.Domain;
using Bookstore.Web.Models;
using Microsoft.AspNetCore.Mvc;

namespace Bookstore.Web.Controllers;

public sealed class OrdersController : Controller
{
    [HttpGet]
    public IActionResult Create() => View(new OrderForm());

    [HttpPost]
    [ValidateAntiForgeryToken]
    public IActionResult Create(OrderForm form)
    {
        if (!ModelState.IsValid)
        {
            return View(form);
        }

        Book book = Catalog.Books.Single(b => b.Id == form.BookId);
        OrderSummary summary = new(book.Title, form.Quantity, Pricing.PriceOrder(book, form.Quantity));
        return View("Summary", summary);
    }
}

View'lar, layout'suz ve olabildiğince kısa:

@model Bookstore.Web.Models.OrderForm
@{ Layout = null; }
<form asp-action="Create" method="post">
    <select asp-for="BookId" asp-items="@(new SelectList(Catalog.Books, "Id", "Title"))"></select>
    <input asp-for="Quantity" />
    <span asp-validation-for="Quantity"></span>
    <button type="submit">Place order</button>
</form>
@model Bookstore.Web.Models.OrderSummary
@{ Layout = null; }
<p>@Model.Quantity x @Model.Title</p>
<p>Total: @Model.Total.ToString("0.00", CultureInfo.InvariantCulture)</p>

Uygulamayı çalıştırıp formu curl ile gönderiyoruz. İlk GET'ten antiforgery cookie'sini ve token'ı alıp $TOKEN değişkenine koydum:

$ curl -s -b jar.txt -d "BookId=1&Quantity=3&__RequestVerificationToken=$TOKEN" http://localhost:5391/Orders/Create
<p>3 x The Hobbit</p>
<p>Total: 37.80</p>
$ curl -s -b jar.txt -d "BookId=1&Quantity=11&__RequestVerificationToken=$TOKEN" http://localhost:5391/Orders/Create | grep valmsg-for
    <span class="field-validation-error" data-valmsg-for="Quantity" data-valmsg-replace="true">Quantity must be between 1 and 10</span>
$ curl -s -o /dev/null -w "%{http_code}\n" -d "BookId=1&Quantity=3" http://localhost:5391/Orders/Create
400

Üç çağrı Web MVC'nin karakterini gösteriyor. Geçerli bir form tek seferde Summary view'ını üretiyor. Geçersiz formda ModelState hatayı taşıyor ve aynı view hata mesajıyla yeniden render ediliyor; Data Annotations doğrulamasının ayrıntısı ASP.NET Core Web API (Application Programming Interface) serisinin "ASP.NET Core'da Model Validation: Data Annotations ve FluentValidation" yazısında. Token'sız istek ise 400 dönüyor; [ValidateAntiForgeryToken] CSRF (Cross-Site Request Forgery) saldırısına karşı koruma.

TypeScript sürümünde kullanıcı "3" yazdığı anda toplam değişiyordu; burada butona basıp sunucuya gitmek gerekiyor. View'ın model'le bağı yok, yalnızca bir kere aldığı OrderSummary record'unu gösteriyor. Bu bir eksiklik değil, farklı bir problem için farklı bir tasarım: sunucu binlerce kullanıcıya hizmet ediyor ve her biri için açık duran bir view tutması imkânsız. Controller ve action kavramının Web API tarafı, CRUD (Create, Read, Update, Delete) iskeletiyle birlikte ASP.NET Core Web API serisinin "ASP.NET Core'da Controller ve Action: [ApiController] ile CRUD İskeleti" yazısında; Razor ve tag helper ile uygulama geliştirmeyi ilerideki Backend › ASP.NET Core MVC ve Razor Pages serisi anlatacak.

Fat Controller ve Anemic View Model

Web MVC'nin en bilinen hastalığı fat controller. Doğrulama, fiyat hesabı, stok kontrolü, e-posta gönderimi ve loglama tek bir action'a dolunca controller hem test edilemez hem de başka bir giriş noktasından (bir background job, bir API endpoint'i) yeniden kullanılamaz hâle geliyor. Jamis Buck'ın 2006'da Rails dünyası için yazdığı "Skinny Controller, Fat Model" yazısı bu yüzden hâlâ alıntılanıyor. Yukarıdaki action'da hesap Pricing.PriceOrder'da; controller yalnızca HTTP'yi domain'e tercüme ediyor. ASP.NET Core Web API serisinin "ASP.NET Core Web API'de Katmanlı Yapı: Service Katmanı ve Repository Tartışması" yazısı aynı tartışmayı service katmanı açısından ele alıyor.

Daha az konuşulan karşı hastalık anemic view model. Web MVC'de view model'in bir veri taşıyıcısı olması normal; sorun, view'ın ihtiyaç duyduğu kararların view model'de değil Razor'ın içinde verilmesi. "İndirim uygulandıysa rozeti göster, tutar sıfırsa ücretsiz yaz, kullanıcı admin değilse butonu gizle" gibi @if blokları Razor'a dolunca o mantık hiçbir unit test'in erişemeyeceği bir yerde kalıyor. Çözüm view model'e ShowDiscountBadge gibi hazır kararlar koymak; view yalnız okusun.

Sık Düşülen Tuzaklar

"Angular MVC'dir, çünkü service'ler model, template view, component controller" demek. Smalltalk anlamında controller input handler'dır; Web MVC anlamında request karşılayıcısıdır. Angular component'i ikisi de değil; beşinci yazıda bu soruya ayrıntılı döneceğiz.

Model'i "veritabanı tablosu" sanmak. Her iki MVC'de de model domain'in kendisi: kurallar, hesaplar, invariant'lar. Entity Framework Core (EF Core) entity'si bunun bir parçası olabilir ama model'in tamamı değildir.

Observer aboneliklerini temizlememek. subscribe bir unsubscribe fonksiyonu dönüyor; ekrandan kalkan bir view aboneliği bırakmazsa model onu sonsuza kadar referans olarak tutar ve hem bellek sızıntısı hem de "görünmeyen view güncelleniyor" hatası doğar.

Web MVC'de POST'tan sonra doğrudan view dönmek. Örnekte sadelik için View("Summary", summary) döndüm; gerçek bir sipariş formunda PRG (Post/Redirect/Get) ile önce RedirectToAction dönersin ki kullanıcı sayfayı yenilediğinde sipariş ikinci kez gönderilmesin.

Ne Zaman Kullanma

Klasik Smalltalk MVC'yi bugün elle kurmaya nadiren ihtiyacın olur. Angular, React ve Blazor gibi framework'ler view ile state arasındaki senkronizasyonu zaten üstleniyor; üstüne bir observable model ve controller katmanı eklemek iki senkronizasyon mekanizmasını yan yana çalıştırmak demek. Framework'süz bir widget, bir canvas uygulaması ya da birden fazla bağımsız view'ın aynı veriyi gösterdiği bir masaüstü aracı yazıyorsan hâlâ en sade çözümlerden biri.

Web MVC'yi ise her sunucu tarafı sayfa için seçmek zorunda değilsin. Tek bir formdan ve onun sonucundan oluşan sayfa odaklı senaryolarda Razor Pages daha az tören istiyor; beşinci yazıda iki modeli yan yana koyacağız. Sayfa yerine yalnızca JSON (JavaScript Object Notation) dönen bir backend yazıyorsan view rolü tamamen istemciye geçiyor ve Web MVC'nin "V"si ortadan kalkıyor; o durumda ihtiyacın Web API.

Küçük Bir Deneme

★☆☆ OrderModel'e üçüncü bir view ekle: seçili kitabın adını gösteren bir başlık. Controller'a ya da diğer view'lara dokunman gerekti mi?

★★☆ DiscountBadgeView'ın subscribe'dan dönen unsubscribe fonksiyonunu saklayıp destroy() method'unda çağır. Sonra bir test yaz: view yok edildikten sonra model.changeQuantity(5) çağrısı rozetin hidden property'sini değiştirmemeli.

★★★ ASP.NET Core sürümüne PRG ekle: POST başarılı olunca RedirectToAction ile sipariş özetine yönlendir. Özet sayfası veriyi nereden alacak? TempData, route parametresi ve kayıtlı sipariş seçeneklerini karşılaştır.

Klasik MVC'de view state'in bir evi yoktu ve view'ı test etmek hâlâ zordu. Bir sonraki yazıda MVP'ye geçiyoruz: view'ı bir interface'in arkasına saklayıp bütün ekran mantığını, view olmadan test edebildiğimiz bir presenter'a taşıyacağız.