Mert Özen Her Satırda Daha İleri
Backend ASP.NET Core MVC ve Razor Pages — #02

ASP.NET Core MVC Pattern'i Pratikte: Controller, View, ViewModel ve Conventional Routing

Mert Özen 8 Kas 2026 11 dk 2 görüntülenme
ASP.NET Core MVC Pattern'i Pratikte: Controller, View, ViewModel ve Conventional Routing

ASP.NET Core MVC'de controller Controller'dan türer, view'ı adıyla Views/<Controller> ve Shared klasörlerinde bulur, sayfanın verisini ViewBag yerine tip güvenli bir ViewModel ile alır. dotnet new mvc yapısını, conventional routing'i, RedirectToAction'ı, Admin area'sını ve route sırası değişince link'lerin sessizce yanlış adres ürettiği gerçek bir hatayı Bookstore üzerinde görüyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 (SDK 10.0.402) ve Microsoft.EntityFrameworkCore.Sqlite 10.0.14 ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore'da iki BooksController var: biri halka açık katalogda, diğeri Admin area'sında. Admin listesindeki her satırda şu link duruyor: <a asp-controller="Books" asp-action="Details" asp-route-id="1">. Program.cs'te önce area route'unu, sonra default route'u kaydettiğinde bu link /Admin/Books/Details/1 adresini üretiyor. Şimdi yalnızca iki MapControllerRoute çağrısının sırasını değiştiriyorsun. Link aynı mı kalır? Uygulama açılırken hata verir mi? Ya da daha kötüsü, hiçbir şey söylemeden başka bir adres mi üretir?

dotnet new mvc Ne Kuruyor?

MVC (Model-View-Controller) pattern'inin teorisini, yani Smalltalk'tan web'e nasıl dönüştüğünü UI (User Interface) Mimari Pattern'leri serisinin "MVC Pattern'i: Orijinal Smalltalk MVC ile Web MVC Aynı Şey Değil" yazısında anlattık. Bu yazı pratik tarafı: ASP.NET Core MVC'de bu üç rol hangi dosyalara, hangi class'lara, hangi kurallara karşılık geliyor? dotnet new mvc -n Bookstore.Web komutunun ürettiği dosyalar (wwwroot/lib altındaki Bootstrap, jQuery ve jQuery Validation dışında):

./Bookstore.Web.csproj
./Controllers/HomeController.cs
./Models/ErrorViewModel.cs
./Program.cs
./Properties/launchSettings.json
./Views/Home/Index.cshtml
./Views/Home/Privacy.cshtml
./Views/Shared/Error.cshtml
./Views/Shared/_Layout.cshtml
./Views/Shared/_Layout.cshtml.css
./Views/Shared/_ValidationScriptsPartial.cshtml
./Views/_ViewImports.cshtml
./Views/_ViewStart.cshtml
./appsettings.Development.json
./appsettings.json
./wwwroot/css/site.css
./wwwroot/favicon.ico
./wwwroot/js/site.js

Klasör adları birer kural: controller'lar Controllers'ta, view'lar Views/<ControllerName> altında, her controller'ın paylaştığı layout ve partial'lar Views/Shared'de. .csproj yalnızca Microsoft.NET.Sdk.Web SDK'sını (Software Development Kit), net10.0'ı, Nullable ve ImplicitUsings'i açıyor; template'teki Bootstrap 5.3.3, jQuery 3.7.1, jQuery Validation 1.21.0 ve jQuery Validation Unobtrusive 4.0.0 paket olarak değil, wwwroot/lib altına kopyalanmış dosyalar olarak geliyor.

Bookstore için template'in Program.cs'ine Entity Framework Core (EF Core) ile SQLite'ı, bir service'i ve Admin area'sının route'unu ekledik:

using Bookstore.Web.Data;
using Bookstore.Web.Services;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllersWithViews();
builder.Services.AddDbContext<BookstoreDbContext>(options =>
    options.UseSqlite(builder.Configuration.GetConnectionString("Bookstore")));
builder.Services.AddScoped<IBookCatalog, BookCatalog>();

var app = builder.Build();

// Demo için: veritabanını ve seed verisini oluştur (gerçek projede migration)
using (IServiceScope scope = app.Services.CreateScope())
{
    BookstoreDbContext db = scope.ServiceProvider.GetRequiredService<BookstoreDbContext>();
    db.Database.EnsureCreated();
}

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Home/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseRouting();
app.UseAuthorization();

app.MapStaticAssets();

app.MapControllerRoute(
    name: "areas",
    pattern: "{area:exists}/{controller=Home}/{action=Index}/{id?}")
    .WithStaticAssets();

app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}")
    .WithStaticAssets();

app.Run();

AddControllersWithViews(), Web API (Application Programming Interface) projelerindeki AddControllers()'ın üstüne view desteğini ekler: Razor view engine, tag helper'lar, TempData, antiforgery. MapStaticAssets() ve WithStaticAssets() .NET 9 ile gelen static asset altyapısı; ayrıntısı bu serinin 9. yazısında.

Conventional Routing: Adresler Nereden Geliyor?

Web API serisinde attribute routing kullandık: her action kendi route'unu [HttpGet("{id}")] gibi bir attribute'la taşıyordu. MVC uygulamalarında yaygın olan ise conventional routing: tek bir template, bütün controller'lar için adres kuralını koyar. {controller=Home}/{action=Index}/{id?} template'i üç parçadan oluşur ve her parça bir route değeri üretir. =Home ve =Index varsayılan değer, ? opsiyonel demek. Uygulamaya attığım isteklerin gerçek sonuçları:

$ for u in / /Books /books/details/2 /Books/Details/6 /Books/Details/abc /Home/Index/7; do
    curl -s -o /dev/null -w "%{http_code} %{url_effective}\n" "http://localhost:17711$u"; done
200 http://localhost:17711/
200 http://localhost:17711/Books
200 http://localhost:17711/books/details/2
404 http://localhost:17711/Books/Details/6
404 http://localhost:17711/Books/Details/abc
200 http://localhost:17711/Home/Index/7

/ varsayılanlar sayesinde HomeController.Index'e, /books/details/2 BooksController.Details(2)'ye gidiyor. /Books/Details/6 taslak bir kitap; service null döndüğü için 404. Üç ayrıntı daha önemli. Route eşleşmesi büyük küçük harfe duyarsız. /Books/Details/abc route'la eşleşiyor, çünkü {id?} parçasında tip kısıtı yok; model binding abc'yi int'e çeviremeyince id varsayılan değeri 0 ile action'a giriyor, ModelState'e de bir hata yazılıyor. 0 numaralı kitap olmadığı için 404 aldık, ama bu tesadüf. {id:int?} yazsaydın istek routing aşamasında 404 olurdu. Son satır da conventional routing'in bir özelliğini gösteriyor: Index action'ı id almadığı için 7 değeri sessizce yok sayılır.

Controller Sınıfı: Controller mı ControllerBase mi?

Katalog controller'ı:

using Bookstore.Web.Services;
using Bookstore.Web.ViewModels;
using Microsoft.AspNetCore.Mvc;

namespace Bookstore.Web.Controllers;

public class BooksController(IBookCatalog catalog) : Controller
{
    public async Task<IActionResult> Index(string? search, int page = 1, CancellationToken cancellationToken = default)
    {
        BookCatalogViewModel model = await catalog.ListAsync(search, page, cancellationToken);
        return View(model);
    }

    public async Task<IActionResult> Details(int id, CancellationToken cancellationToken)
    {
        BookDetailsViewModel? book = await catalog.GetDetailsAsync(id, cancellationToken);
        if (book is null)
        {
            return NotFound();
        }

        return View(book);
    }

    // Eski arama adresi: /Books/Search?q=Ink
    public IActionResult Search(string? q) => RedirectToAction(nameof(Index), new { search = q });
}

Web API'de controller'lar ControllerBase'den türüyordu. MVC'de Controller'dan türetiyoruz. Controller, ControllerBase'in üstüne view'a özgü üyeleri ekleyen bir alt class: View(), PartialView(), ViewComponent(), ViewData, ViewBag, TempData ve Json(). Aynı action'ı ControllerBase'den türeyen bir class'a yazınca derleyici şunu söylüyor:

Controllers/ApiProbeController.cs(7,37): error CS0103: The name 'View' does not exist in the current context

Action'ların dönebileceği sonuçlardan view tarafında en sık kullandıkların: View() (layout'la birlikte tam sayfa), PartialView() (layout'suz parça, 4. ve 11. yazıda), RedirectToAction() (302 ile başka bir action'a gönderme) ve NotFound(). Search action'ı eski bir adresi yeniye taşımak için RedirectToAction kullanıyor. Gerçek yanıt:

HTTP/1.1 302 Found
Content-Length: 0
Date: Sun, 08 Nov 2026 02:03:24 GMT
Server: Kestrel
Location: /Books?search=Ink

Location header'ını biz yazmadık. RedirectToAction route tablosunu kullanarak URL'i (Uniform Resource Locator) üretti; search template'te bir parça olmadığı için query string'e düştü. Adresi elle birleştirmek yerine routing'e ürettirmek, ileride route template'i değiştiğinde bütün link'lerin kendiliğinden düzelmesi demek. Bu, aynı zamanda 6. yazıdaki Post-Redirect-Get'in temel taşı.

View Nasıl Bulunuyor?

View(model) çağrısı bir dosya adı vermiyor. MVC, view adını action adından alır ve önce controller'ın klasörüne, sonra Shared'a bakar. Bunu en net, olmayan bir view istediğimizde verilen hata gösteriyor. View dosyası olmayan bir Featured action'ı çağırınca log'a düşen gerçek exception:

System.InvalidOperationException: The view 'Featured' was not found. The following locations were searched:
/Views/Books/Featured.cshtml
/Views/Shared/Featured.cshtml

Aynı şey Admin area'sındaki bir action için üç yere bakıyor; area'nın kendi klasörlerinden sonra uygulamanın ortak Shared klasörüne de iniyor:

System.InvalidOperationException: The view 'Report' was not found. The following locations were searched:
/Areas/Admin/Views/Books/Report.cshtml
/Areas/Admin/Views/Shared/Report.cshtml
/Views/Shared/Report.cshtml

Bu sıranın pratik sonucu şu: Views/Shared/_Layout.cshtml area'lardan da görünür, ama area içinde aynı adla bir dosya açarsan o kazanır. View("Edit", model) gibi bir ad verdiğinde de aynı arama yapılır; View("~/Views/Books/Edit.cshtml", model) gibi tam yol verirsen arama yapılmaz.

ViewModel: View'a Tam İhtiyacı Kadar Veri

View'a ne göndereceğiz? Kestirme yol, EF Core entity'sini olduğu gibi göndermek. Book entity'sinde Stock, IsFeatured, Status, AuthorId ve navigation property'si Author var. Katalog satırının bunlardan ihtiyacı olan dört alan. Bunun için view'a özel, değişmez record'lar tanımlıyoruz:

using Bookstore.Web.Models;

namespace Bookstore.Web.ViewModels;

public record BookListItemViewModel(int Id, string Title, string AuthorName, decimal Price, BookStatus Status);

public record BookCatalogViewModel(
    IReadOnlyList<BookListItemViewModel> Books, string? Search, int Page, int TotalPages);

public record BookDetailsViewModel(
    int Id, string Title, string AuthorName, decimal Price, int Stock, DateOnly PublishedOn, Genre Genre, BookStatus Status)
{
    public bool InStock => Stock > 0;
}

ViewModel üç iş görüyor. View'ın neye ihtiyacı olduğunu tip olarak belgeliyor. Entity'nin view'a sızmasını, dolayısıyla view render edilirken lazy loading ile sürpriz sorgular atılmasını engelliyor. InStock gibi küçük sunum mantığına da view yerine test edilebilir bir yer açıyor. View'ın ilk satırındaki @model direktifi bu tipi bağlar ve Model property'si tip güvenli olur:

@model BookCatalogViewModel
@{
    ViewData["Title"] = "Books";
}
<h1>Books</h1>
<table class="table">
    <thead>
        <tr><th>Title</th><th>Author</th><th>Price</th></tr>
    </thead>
    <tbody>
        @foreach (BookListItemViewModel book in Model.Books)
        {
            <tr>
                <td><a asp-action="Details" asp-route-id="@book.Id">@book.Title</a></td>
                <td>@book.AuthorName</td>
                <td>@book.Price.ToString("0.00")</td>
            </tr>
        }
    </tbody>
</table>
<p>Page @Model.Page of @Model.TotalPages</p>

GET /Books?search=Ink isteğinin gerçek çıktısında tablonun gövdesi:

    <tbody>
            <tr>
                <td><a href="/Books/Details/2">Rivers of Ink</a></td>
                <td>Alan Brooks</td>
                <td>265.50</td>
            </tr>
    </tbody>
</table>
<p>Page 1 of 1</p>

Peki ViewData ve ViewBag? İkisi aynı sözlüğe iki farklı yoldan erişir: ViewData["Heading"] string key'li bir dictionary, ViewBag.Heading ise onun dynamic sarmalayıcısı. Sorun, derleyicinin ikisini de kontrol edememesi. Controller'da ViewBag.Heading = "Staff picks"; yazıp view'da yanlışlıkla <h1>@ViewBag.Headline</h1> yazdım. Uygulama derlendi, sayfa 200 döndü, gerçek çıktı:

            <h1></h1>
<p>Coming soon.</p>

Ne derleme hatası, ne exception, ne log. Başlık sessizce kayboldu. ViewModel'de aynı hatayı yapsaydın Model.Headline satırı derlenmezdi. Bu yüzden kuralım şu: ViewData yalnızca layout'un sayfadan beklediği küçük şeyler için (ViewData["Title"] gibi), sayfanın asıl verisi her zaman ViewModel'de.

Fat Controller Yerine Service

Controller'ımızda tek bir LINQ (Language Integrated Query) satırı yok; işi IBookCatalog yapıyor:

public class BookCatalog(BookstoreDbContext db) : IBookCatalog
{
    private const int PageSize = 4;

    public async Task<BookCatalogViewModel> ListAsync(string? search, int page, CancellationToken cancellationToken = default)
    {
        IQueryable<Book> query = db.Books.AsNoTracking().Where(b => b.Status != BookStatus.Draft);
        if (!string.IsNullOrWhiteSpace(search))
        {
            query = query.Where(b => b.Title.Contains(search));
        }

        int total = await query.CountAsync(cancellationToken);
        int totalPages = Math.Max(1, (int)Math.Ceiling(total / (double)PageSize));
        page = Math.Clamp(page, 1, totalPages);

        List<BookListItemViewModel> books = await query
            .OrderBy(b => b.Title)
            .Skip((page - 1) * PageSize)
            .Take(PageSize)
            .Select(b => new BookListItemViewModel(b.Id, b.Title, b.Author.Name, b.Price, b.Status))
            .ToListAsync(cancellationToken);

        return new BookCatalogViewModel(books, search, page, totalPages);
    }

    public Task<BookDetailsViewModel?> GetDetailsAsync(int id, CancellationToken cancellationToken = default) =>
        db.Books.AsNoTracking()
            .Where(b => b.Id == id && b.Status != BookStatus.Draft)
            .Select(b => new BookDetailsViewModel(
                b.Id, b.Title, b.Author.Name, b.Price, b.Stock, b.PublishedOn, b.Genre, b.Status))
            .FirstOrDefaultAsync(cancellationToken);
}

"Taslak kitap katalogda görünmez" kuralı controller'da değil, service'te; aynı kural ileride bir Razor Page ya da bir API endpoint'i tarafından da kullanılacak. Projeksiyon (Select) doğrudan ViewModel'e yapıldığı için veritabanından yalnız gereken kolonlar geliyor ve entity hiç track edilmiyor. Service katmanını ve "repository gerekir mi" tartışmasını ASP.NET Core Web API serisinin "ASP.NET Core Web API'de Katmanlı Yapı: Service Katmanı ve Repository Tartışması" yazısında konuştuk; aynı gerekçeler burada da geçerli. Tek fark, bu service'in doğrudan ViewModel dönmesi. Bu bilinçli bir tercih: BookCatalog Web projesinin içinde yaşayan, ekrana hizmet eden bir sorgu service'i. İş kuralları ayrı bir domain katmanında olsaydı, o katman ViewModel'i bilmezdi.

Area'lar: Yönetim Panelini Ayırmak

Yönetim paneli katalogla aynı varlıklarla çalışıyor ama farklı kullanıcıya, farklı yetkiyle, farklı layout'la hizmet ediyor. Area, uygulamanın içinde kendi Controllers, Views ve route'u olan bir alt bölüm açar:

using Bookstore.Web.Data;
using Bookstore.Web.ViewModels;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace Bookstore.Web.Areas.Admin.Controllers;

[Area("Admin")]
public class BooksController(BookstoreDbContext db) : Controller
{
    public async Task<IActionResult> Index(CancellationToken cancellationToken)
    {
        List<BookListItemViewModel> books = await db.Books.AsNoTracking()
            .OrderBy(b => b.Id)
            .Select(b => new BookListItemViewModel(b.Id, b.Title, b.Author.Name, b.Price, b.Status))
            .ToListAsync(cancellationToken);
        return View(books);
    }
}

İki BooksController aynı adı taşıyor ama namespace'leri farklı ve [Area("Admin")] attribute'u ikincisine area = "Admin" route değerini veriyor. Dosya düzeni: Areas/Admin/Controllers/BooksController.cs ve Areas/Admin/Views/Books/Index.cshtml. Area'nın view'ları uygulamanın Views/_ViewImports.cshtml dosyasını görmez; bu yüzden Areas/Admin/Views/_ViewImports.cshtml ve _ViewStart.cshtml dosyalarını da ekledik. {area:exists} kısıtı yalnız gerçekten var olan bir area adıyla eşleşir.

Area view'ı içinde link üretirken bir incelik var. Admin listesinde iki link yazdım:

<td><a asp-controller="Books" asp-action="Details" asp-route-id="@book.Id">Admin view</a></td>
<td><a asp-area="" asp-controller="Books" asp-action="Details" asp-route-id="@book.Id">Store page</a></td>

Gerçek çıktı:

<td><a href="/Admin/Books/Details/1">Admin view</a></td>
<td><a href="/Books/Details/1">Store page</a></td>

İlk link'te area belirtmedik, ama mevcut isteğin area = "Admin" değeri (ambient value) link üretimine taşındı. İkincisinde asp-area="" ile bu değeri bilerek sildik. Template'in _Layout.cshtml'indeki menü link'lerinde asp-area="" yazmasının sebebi bu: layout area içinde de kullanılıyor ve "Home" link'i admin panelinden basıldığında /Admin/Home'a gitmemeli.

Üretimde Dikkat

Baştaki sorunun cevabı. Route sırasını değiştirip default route'u önce kaydettim. Uygulama sorunsuz açıldı, /Admin/Books hâlâ 200 döndü, ama aynı sayfanın HTML'i (HyperText Markup Language) şuna dönüştü:

<td><a href="/Books/Details/1">Admin view</a></td>
<td><a href="/Books/Details/1">Store page</a></td>

"Admin view" link'i artık halka açık katalog sayfasını gösteriyor. Ne hata, ne uyarı. Conventional route'lar sıraya bağlıdır ve link üretimi de bu sırayı kullanır; daha genel olan default route önce geldiğinde area değerini taşımayan bir URL üretebildi. Microsoft'un routing dokümanının kuralı kısa: area route'larını daha spesifik oldukları için önce kaydet. Bu tür hatalar ancak link'leri test eden bir integration test ya da dikkatli bir göz yakalar.

İkincisi 404'ler. /Books/Details/6 boş gövdeli bir 404 döndü, çünkü status code sayfası yapılandırmadık. Kullanıcıya dönük bir uygulamada app.UseStatusCodePagesWithReExecute("/Home/StatusCode/{0}") gibi bir middleware ile layout'lu bir "bulunamadı" sayfası göstermek gerekiyor. Üçüncüsü CancellationToken: kullanıcı sayfayı yüklenirken kapatırsa ya da başka bir link'e tıklarsa, action'a gelen token iptal olur ve EF Core sorgusu da yarıda kesilir. Bunun ayrıntısı Web API serisinin Async/Await ve CancellationToken yazısında.

Sık Düşülen Tuzaklar

Entity'yi view'a göndermek. View Model.Author.Name'e dokunduğunda lazy loading açıksa her satır için ayrı sorgu atılır, kapalıysa null reference alırsın. Bir gün entity'ye eklenen hassas bir property de kimse fark etmeden view'a kadar gelir.

ViewBag ile sayfa verisi taşımak. Yukarıdaki boş <h1> örneği bunun en masum hâli. Aynı yazım hatasını @foreach (string tag in ViewBag.Tagz) gibi bir döngüde yaptığımda sayfa 500 döndü ve log'da System.NullReferenceException vardı; hata derlemede değil, kullanıcının ekranında ortaya çıktı.

Fat controller. Her action'da 20 satırlık LINQ sorgusu, iş kuralı ve mapping. Aynı kural ikinci bir ekrana lazım olduğunda kopyalanır ve iki kopya zamanla ayrışır.

Route değerine tip kısıtı koymamak. {id?} her şeyi kabul eder; abc 0'a dönüşür ve tesadüfen doğru davranan bir 404 görürsün. {id:int?} ya da action'da ModelState.IsValid kontrolü niyeti açık eder.

Area'da _ViewImports.cshtml'i unutmak. @model List<BookListItemViewModel> satırı error CS0246: The type or namespace name 'BookListItemViewModel' could not be found ile derlenmez. Tipi tam adıyla yazıp bu hatayı geçtiğimde daha sinsi olanı çıktı: tag helper'lar çalışmadı ve HTML'e <a asp-controller="Books" asp-action="Details" asp-route-id="1"> olduğu gibi basıldı; link'in href'i hiç oluşmadı.

Küçük Bir Deneme

★☆☆ Default route template'ini {controller=Home}/{action=Index}/{id:int?} yap. /Books/Details/abc isteğinin sonucu değişiyor mu? Log'da action çalışıyor mu?

★★☆ Admin area'sına Details action'ı ve view'ı ekle. Sonra route sırasını bilerek ters çevir ve "Admin view" link'ine tıkla. Hangi controller'ın action'ı çalışıyor?

★★★ app.MapAreaControllerRoute(name: "admin", areaName: "Admin", pattern: "admin/{controller=Books}/{action=Index}/{id?}") ile area route'unu yeniden yaz ve {area:exists}'li route'u kaldır. /Admin adresi artık nereye gidiyor? Link üretimi route sırasından hâlâ etkileniyor mu?

Controller'lar, ViewModel'ler ve area hazır. Ama view'larımız henüz çıplak HTML'e serpiştirilmiş birkaç @ ifadesinden ibaret. Bir sonraki yazıda Razor syntax'ının kendisine iniyoruz: implicit ve explicit expression'lar, varsayılan HTML encoding'in bizi XSS'ten (Cross-Site Scripting) nasıl koruduğu, @Html.Raw'ın tehlikesi, layout'lar, section'lar, _ViewStart ve _ViewImports hiyerarşisi.