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

Server-Side ve Client-Side Validation: DataAnnotations, ModelState, Unobtrusive Validation ve Custom Attribute'lar

Mert Özen 8 Kas 2026 12 dk 2 görüntülenme
Server-Side ve Client-Side Validation: DataAnnotations, ModelState, Unobtrusive Validation ve Custom Attribute'lar

MVC'de validation iki katmanlı: DataAnnotations ve ModelState sunucuda zorunlu, data-val-* ile çalışan jQuery Unobtrusive Validation yalnız kullanıcı deneyimi. IValidatableObject, AddModelError, [Remote], IClientModelValidator'lı custom attribute ve Türkçe mesajları gerçek tarayıcıda deniyor; Türkçe kültürde "1.5" fiyatının sessizce 15 olarak kaydedildiğini ve type="number" ile __Invariant'ın bunu nasıl düzelttiğini gösteriyoruz.

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

Önce Tahmin Et

Bookstore'un admin paneli Türkçe kültürde çalışan bir sunucuda. Editör, fiyatı 265,50 TL olan bir kitabın düzenleme sayfasını açıyor ve hiçbir şeye dokunmadan "Save"e basıyor. Form gönderilmiyor; fiyat alanının altında "The field Price must be a number." yazıyor. Editör virgülü noktaya çevirip 265.50 yazıyor ve form bu kez gidiyor. Veritabanına hangi fiyat yazılır? Yazılmazsa, kullanıcı ne görür?

Sunucu Tarafı: DataAnnotations ve ModelState

Validation'ın temeli Web API (Application Programming Interface) serisinin Model Validation yazısında anlatıldı: DataAnnotations attribute'ları, ModelState ve FluentValidation. Orada [ApiController] geçersiz modeli kendiliğinden 400'e çeviriyordu. MVC'de (Model-View-Controller) böyle bir otomatik davranış yok ve olmamalı da; geçersiz formda kullanıcıya aynı sayfayı hata mesajlarıyla göstermek isteriz. Bu yüzden her POST action'ı ModelState.IsValid'i kendisi kontrol eder. Bu yazı aynı konunun view tarafı.

Kitap formunun modeli, önceki iki yazıdaki BookFormViewModel; bu yazıda iki attribute ve bir arayüz daha eklendi:

public class BookFormViewModel : IValidatableObject
{
    public int? Id { get; set; }

    [Required]
    [StringLength(120)]
    public string Title { get; set; } = "";

    [Required]
    [Display(Name = "ISBN")]
    [RegularExpression(@"^\d{13}$", ErrorMessage = "ISBN must be 13 digits.")]
    [Remote(action: "CheckIsbn", controller: "Books", areaName: "Admin", AdditionalFields = nameof(Id))]
    public string Isbn { get; set; } = "";

    [Range(1, 10000)]
    public decimal Price { get; set; }

    [Range(0, 1000)]
    public int Stock { get; set; }

    [Display(Name = "Published on")]
    [DataType(DataType.Date)]
    [NotInFuture]
    public DateOnly PublishedOn { get; set; } = DateOnly.FromDateTime(DateTime.Today);

    public Genre Genre { get; set; }

    [Display(Name = "Author")]
    public int AuthorId { get; set; }

    public IEnumerable<SelectListItem> Authors { get; set; } = [];

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        // Birden fazla alana bakan kural: hata tek bir alana değil, formun bütününe ait
        if (Genre == Genre.Programming && Price < 100)
        {
            yield return new ValidationResult("Programming books cost at least 100 TL.");
        }
    }
}

Boş başlıkla gönderilen formun gerçek yanıtından iki satır: input'a input-validation-error class'ı eklendi, mesaj span'i field-validation-valid'den field-validation-error'a döndü ve içi doldu. CSS'te (Cascading Style Sheets) bu iki class'ı stillendirmen yeterli:

<input class="form-control input-validation-error" type="text" data-val="true" ... id="Title" name="Title" value="" />
<span class="text-danger field-validation-error" data-valmsg-for="Title" data-valmsg-replace="true">The Title field is required.</span>

IValidatableObject.Validate, birden fazla alana bakan kurallar için. ValidationResult'a alan adı vermediğimizde hata ModelState'e boş key ile girer ve yalnız validation summary'de görünür. Formun tepesinde <div asp-validation-summary="ModelOnly"> var; ModelOnly alan hatalarını tekrar etmez, yalnız bu tür form geneli hataları basar (All ikisini de basar). Programlama kitabını 50 TL'ye kaydetmeye çalıştığımda:

<div class="text-danger validation-summary-errors"><ul><li>Programming books cost at least 100 TL.</li>
</ul></div>

Bir incelik: aynı istekte başlığı da boş bıraktığımda bu mesaj gelmedi. Validate yalnızca property seviyesindeki attribute'ların hepsi geçerse çalışır. Kullanıcı önce alan hatalarını düzeltir, form genelindeki kuralı ancak ikinci denemede görür.

Veritabanına sormayı gerektiren kurallar attribute'a sığmaz. "Bu ISBN (International Standard Book Number) zaten kayıtlı mı?" sorusunu action soruyor ve hatayı ilgili alana bağlıyor:

[HttpPost]
public async Task<IActionResult> Create(BookFormViewModel model, CancellationToken cancellationToken)
{
    if (ModelState.IsValid && await books.IsbnExistsAsync(model.Isbn, null, cancellationToken))
    {
        ModelState.AddModelError(nameof(model.Isbn), "This ISBN is already registered.");
    }

    if (!ModelState.IsValid)
    {
        model.Authors = await GetAuthorItemsAsync(cancellationToken);
        return View(model);
    }

    int id = await books.CreateAsync(model, cancellationToken);
    TempData["StatusMessage"] = $"Book #{id} \"{model.Title}\" was created as a draft.";
    return RedirectToAction(nameof(Index));
}

Kontrolü ModelState.IsValid && ile başlatmak bilinçli: form zaten hatalıysa veritabanına boşuna gitmiyoruz. nameof(model.Isbn) key'i, mesajı asp-validation-for="Isbn" span'ine yönlendirir. Mevcut bir ISBN ile gönderdiğim formda yanıt 200 ve:

<span class="text-danger field-validation-error" data-valmsg-for="Isbn" data-valmsg-replace="true">This ISBN is already registered.</span>

Client-Side: Unobtrusive Validation Nasıl Çalışıyor?

5. yazıda asp-for'un DataAnnotations'ı data-val-* attribute'larına çevirdiğini gördük. Bu attribute'ları okuyan, template'te hazır gelen iki kütüphane: jQuery Validation kuralları çalıştırır, jQuery Validation Unobtrusive ise HTML'deki (HyperText Markup Language) data-val-required, data-val-range-min gibi attribute'ları bu kurallara çevirir. "Unobtrusive" adı buradan geliyor: sayfada tek satır validation JavaScript'i yazmıyorsun. Script'ler _ValidationScriptsPartial'dan geliyor ve form view'ında Scripts section'ına ekleniyor:

@section Scripts {
    <partial name="_ValidationScriptsPartial" />
    <script src="~/js/validation.js" asp-append-version="true"></script>
}

Unobtrusive kütüphanesi sayfa yüklendiğinde formları bir kez tarar. Sonradan DOM'a (Document Object Model) eklenen bir form (bu serinin 11. yazısındaki htmx yanıtları gibi) için $.validator.unobtrusive.parse(element)'i elle çağırman gerekir.

Gerçek bir tarayıcıda boş formu gönderip ne olduğuna baktım. Hata mesajları anında çıktı:

[
  "Title: The Title field is required.",
  "Isbn: The ISBN field is required.",
  "Price: The field Price must be between 1 and 10000."
]

ve tarayıcının network kaydında /Admin/Books/Create için tek bir istek vardı: sayfanın kendisini getiren GET. Form sunucuya hiç gitmedi. Client-side validation'ın bütün değeri bu: kullanıcıya anında geri bildirim ve gereksiz bir istek eksik. Ama güvenlik değeri sıfır; bunu birazdan konuşacağız.

[Remote] ile Sunucuya Sormak

ISBN'in kayıtlı olup olmadığını client bilemez. [Remote] attribute'u, alan değiştiğinde arka planda bir action'a sorup cevabı validation mesajı olarak gösterir. Yukarıdaki modelde Isbn'in üstünde duruyor; çağırdığı action:

[AcceptVerbs("GET", "POST")]
public async Task<IActionResult> CheckIsbn(string isbn, int? id, CancellationToken cancellationToken)
{
    bool exists = await books.IsbnExistsAsync(isbn, id, cancellationToken);
    return exists ? Json($"ISBN {isbn} is already registered.") : Json(true);
}

Sözleşme basit: geçerliyse true, değilse gösterilecek mesaj, ikisi de JSON (JavaScript Object Notation). AdditionalFields = nameof(Id) düzenleme ekranında kitabın kendi id'sini de gönderir; böylece kitabın kendi ISBN'i "kayıtlı" sayılmaz. Bunun için forma <input asp-for="Id" type="hidden" /> ekledik. Üretilen attribute'lar ve gerçek yanıtlar:

data-val-remote="&#x27;ISBN&#x27; is invalid." data-val-remote-additionalfields="*.Isbn,*.Id" data-val-remote-url="/Admin/Books/CheckIsbn"
$ curl -s "http://localhost:17711/Admin/Books/CheckIsbn?Isbn=9780000000028&Id="
"ISBN 9780000000028 is already registered."
$ curl -s "http://localhost:17711/Admin/Books/CheckIsbn?Isbn=9780000000028&Id=2"
true
$ curl -s "http://localhost:17711/Admin/Books/CheckIsbn?Isbn=9780000000099&Id="
true

Tarayıcıda ISBN alanına kayıtlı bir değer yazınca network kaydında GET http://localhost:17711/Admin/Books/CheckIsbn?Isbn=9780000000028&Id= → 200 OK göründü ve alanın altında sunucunun mesajı çıktı. İki şeye dikkat: remote doğrulama her değişiklikte bir istek atar, bu yüzden action ucuz olmalı (index'li bir kolona tek sorgu). Ve remote doğrulama bir garanti değildir: iki editör aynı ISBN'i aynı anda girebilir, biri JavaScript'i kapatabilir. Bu yüzden action'daki IsbnExistsAsync kontrolü ve veritabanındaki unique index yerinde kalıyor. Global antiforgery filter açık olduğu için remote isteği varsayılan GET ile gidiyor; POST'a çevirirsen token göndermen gerekir.

Kendi Attribute'un ve Client Karşılığı

"Yayın tarihi gelecekte olamaz" kuralı için hazır bir attribute yok. Sunucu tarafı bir ValidationAttribute; client tarafı için IClientModelValidator arayüzü, HTML'e hangi data-val-* attribute'larının yazılacağını söyler:

using System.ComponentModel.DataAnnotations;
using System.Globalization;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;

namespace Bookstore.Web.Validation;

[AttributeUsage(AttributeTargets.Property)]
public class NotInFutureAttribute : ValidationAttribute, IClientModelValidator
{
    public NotInFutureAttribute() : base("The field {0} cannot be in the future.")
    {
    }

    public override bool IsValid(object? value) =>
        value is not DateOnly date || date <= DateOnly.FromDateTime(DateTime.Today);

    public void AddValidation(ClientModelValidationContext context)
    {
        // jQuery Unobtrusive bu attribute'ları okuyup "notinfuture" kuralını bağlar
        MergeAttribute(context.Attributes, "data-val", "true");
        MergeAttribute(context.Attributes, "data-val-notinfuture",
            FormatErrorMessage(context.ModelMetadata.GetDisplayName()));
        MergeAttribute(context.Attributes, "data-val-notinfuture-today",
            DateOnly.FromDateTime(DateTime.Today).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
    }

    private static void MergeAttribute(IDictionary<string, string> attributes, string key, string value) =>
        attributes.TryAdd(key, value);
}

Attribute HTML'e kuralın adını, mesajını ve bir parametresini (bugünün tarihi) yazıyor. Bugünü sunucudan almak bilinçli: kullanıcının saat dilimi ya da yanlış ayarlı saati kuralı değiştirmesin. Client tarafında iki parça var. addMethod kuralın kendisi, adapters.add ise data-val-notinfuture-* attribute'larını bu kurala bağlayan köprü:

// Sunucudaki NotInFutureAttribute'un client karşılığı
$.validator.addMethod("notinfuture", function (value, element, params) {
    // input type="date" değeri her zaman yyyy-MM-dd; string karşılaştırması yeterli
    return this.optional(element) || value <= params.today;
});

$.validator.unobtrusive.adapters.add("notinfuture", ["today"], function (options) {
    options.rules.notinfuture = { today: options.params.today };
    options.messages.notinfuture = options.message;
});

Üretilen input:

<input class="form-control" type="date" data-val="true" data-val-notinfuture="The field Published on cannot be in the future." data-val-notinfuture-today="2026-11-08" data-val-required="The Published on field is required." id="PublishedOn" name="PublishedOn" value="2026-11-08" /><input name="__Invariant" type="hidden" value="PublishedOn" />

Tarayıcıda başlığı "Copy of Rivers", ISBN'i kayıtlı bir değer, tarihi 2027-01-01 ve fiyatı 1,5 yapıp alanları doğrulattığımda üç mesaj birden göründü:

[
  "Isbn: ISBN 9780000000028 is already registered.",
  "Price: The field Price must be a number.",
  "PublishedOn: The field Published on cannot be in the future."
]

JavaScript'i atlayıp aynı tarihi curl ile gönderdiğimde sunucu tarafı da yakaladı: yanıt 200 ve The field Published on cannot be in the future. İki taraf aynı kuralı ayrı dillerde uyguluyor; birini değiştirip diğerini unutmak bu yaklaşımın bedeli.

Hata Mesajlarını Türkçeleştirmek

Üç ayrı mesaj kaynağı var ve her biri farklı yoldan değişiyor. Birincisi DataAnnotations attribute'larının mesajları: [Required(ErrorMessage = "...")] ile tek tek, ya da resource dosyalarıyla toplu olarak. İkincisi model binding'in kendi mesajları: dönüştürülemeyen bir değer geldiğinde ("abc" bir sayı değil) attribute'lar hiç çalışmaz, mesajı binder üretir. Fiyata abc gönderdiğimde gelen mesaj The value 'abc' is not valid for Price. oldu. Bunları ModelBindingMessageProvider değiştirir:

builder.Services.AddControllersWithViews(options =>
{
    options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute());
    options.ModelBindingMessageProvider.SetAttemptedValueIsInvalidAccessor(
        (value, field) => $"'{value}' değeri {field} alanı için geçerli değil.");
    options.ModelBindingMessageProvider.SetValueMustBeANumberAccessor(
        field => $"{field} alanı sayı olmalı.");
});
<span class="text-danger field-validation-error" data-valmsg-for="Price" data-valmsg-replace="true">&#x27;abc&#x27; değeri Price alanı için geçerli değil.</span>
data-val-number="Price alanı sayı olmalı."

İkinci satır client tarafının mesajı: data-val-number da aynı sağlayıcıdan besleniyor. Üçüncü kaynak jQuery Validation'ın kendi mesajları; sunucu data-val-* ile mesaj gönderdiği sürece onlar görünmez. Alan adının hâlâ "Price" olduğuna dikkat et: [Display(Name = "Fiyat")] ya da localization gerekiyor. Mesajları sabit Türkçe string'lerle yazmak tek dilli bir uygulamada yeterli; çok dilli bir uygulamada .resx dosyaları ve AddDataAnnotationsLocalization devreye girer, o da bu serinin 10. yazısının konusu.

FluentValidation ile Client-Side'ın Sınırı

Web API serisinde FluentValidation'ı da kullandık. MVC'de iki şey bilmen gerekiyor. Birincisi, MVC pipeline'ına otomatik validation ekleyen FluentValidation.AspNetCore paketi artık bakımı yapılmayan, desteklenmeyen bir paket; README'si de çekirdek FluentValidation paketiyle validator'ı action içinde elle çağırmayı öneriyor. İkincisi, FluentValidation bir sunucu kütüphanesi. O paket bile yalnız basit kuralları (NotNull/NotEmpty, Matches, InclusiveBetween, Email, EqualTo, MaxLength, MinLength, Length) data-val-* attribute'larına çevirebiliyordu; When/Unless koşullu kurallar, Must ve custom validator'lar client'ta hiç çalışmıyordu.

Benim pratik tercihim şu: form ViewModel'inde basit, alan seviyesindeki kurallar DataAnnotations olarak kalır (client-side'ı bedavaya getirir), iş kuralları ve veritabanı gerektiren kontroller service'te ya da elle çağrılan bir FluentValidation validator'ında yaşar ve hataları ModelState.AddModelError ile forma döner. Aynı kuralı iki yerde tanımlamamaya dikkat et.

Kültür Tuzağı: "1,5" mi "1.5" mi?

Şimdi baştaki soru. Uygulamayı LANG=tr_TR.UTF-8 ile, yani Türkçe kültürde çalışan bir sunucu gibi başlattım. Düzenleme sayfasında fiyat alanı şöyle render edildi:

<input class="form-control" type="text" data-val="true" data-val-number="The field Price must be a number." ... id="Price" name="Price" value="265,50" />

Sunucu değeri kendi kültürüyle yazdı: 265,50. jQuery Validation'ın number kuralı ise yalnız noktalı ondalığı tanıyor. Tarayıcıda hiçbir şeye dokunmadan alanı doğrulattığımda sonuç false ve mesaj The field Price must be a number. oldu; değeri 265.50 yapınca true. Yani sunucunun kendi ürettiği değer client validation'dan geçemiyor ve kullanıcı virgülü noktaya çevirmeye zorlanıyor.

Noktalı değer sunucuya gidince ne oluyor? Türkçe kültürde nokta binlik ayracı. Önce yalnız fiyatı değiştiren hızlı düzenleme formuna Price=1.5 gönderdim, sonra Price=1,5; her isteğin status satırından sonra sqlite3 ile kitabın Id|Price değerini okudum:

HTTP/1.1 302 Found
2|15.0
HTTP/1.1 302 Found
2|1.5

1,5 TL'lik kitap sessizce 15 TL oldu. Tahminin cevabı da aynı mekanizma: 265.50 sunucuda 26550 olarak okundu. Bu kez kurtaran [Range(1, 10000)] oldu; yanıt 200 ve alanın altında The field Price must be between 1 and 10000. yazıyordu. Kullanıcı "ama ben 265.50 yazdım" diye şaşırıyor; aralık dışında kalmayan bir değerde (12.50 gibi) ise hiçbir şey fark edilmeden 1250 kaydedilirdi.

Çözümün bir kısmını 5. yazıda görmüştük. Fiyat input'unu type="number" yapıyoruz:

<input asp-for="Price" type="number" step="0.01" class="form-control" />
<input type="number" step="0.01" class="form-control" data-val="true" ... id="Price" name="Price" value="265.5" /><input name="__Invariant" type="hidden" value="Price" />

Tag helper değeri artık kültürden bağımsız (265.5) yazıyor ve yanına __Invariant alanını ekliyor. Tarayıcı number input'unun değerini her zaman noktayla gönderir; ekranda kullanıcının yerel ayarına göre virgülle gösterse bile. Model binding de __Invariant listesindeki alanları invariant culture ile parse ediyor. Aynı Türkçe sunucuda doğrulamasını yaptım: Price=1.5 ile __Invariant=Price birlikte gidince veritabanında 1.5, __Invariant olmadan 265.5 gönderince 2655.0. Düzenleme formunda client validation true döndü, kayıt 265.5 olarak kaldı. Tarih alanları için de aynısı geçerli; [DataType(DataType.Date)] ile type="date" render edilen alanlar zaten bu korumayı alıyor.

type="text" kalması gereken bir alanda (binlik ayraçlı gösterim gibi) ise iki taraf aynı kültürü konuşmalı: sunucuda request culture'ı bilinçli seçmek ve client'ta jQuery Validation'ın number method'unu o kültüre göre ezmek. Request culture'ın nasıl seçildiği, yine 10. yazıda.

Üretimde Dikkat

Client-side validation bir kullanıcı deneyimi özelliği, güvenlik önlemi değil. Bu yazıdaki her client kuralını curl ile atlayıp sunucunun da yakaladığını gösterdik; sunucu tarafı eksik bir kural, client'ta ne kadar güzel çalışırsa çalışsın açıktır.

Sunucuların kültürünü bir varsayıma bırakma. Geliştirici makinesi Türkçe, container imajı invariant, üretim sunucusu İngilizce olabilir ve aynı kod üç ortamda üç farklı sayı okur. Yukarıdaki 15 TL hatası tam olarak "bende çalışıyor"un kaynağıdır. Kültürü uygulamanın başında açıkça ayarla ve sayı, tarih alanlarını mümkün olduğunca number/date input'larıyla al.

[Remote] action'larını ölç. Her tuş vuruşunda değil ama her alan değişikliğinde bir istek gelir; yoğun bir formda bu, sayfa görüntülemesinden çok daha fazla istek demek. Rate limiting ve hızlı bir sorgu şart.

Sık Düşülen Tuzaklar

Yalnız client-side validation'a güvenmek. JavaScript kapatılabilir, istek curl ile atılabilir, remote kontrol yarış koşuluna girer. Her kural sunucuda da olmalı.

ModelState.IsValid'i kontrol etmeyi unutmak. MVC'de [ApiController]'ın otomatik 400'ü yok; kontrol etmezsen geçersiz model sessizce service'e gider.

Form geneli hataları göstermemek. asp-validation-summary olmayan bir formda boş key'li hatalar (IValidatableObject, AddModelError(string.Empty, ...)) hiçbir yerde görünmez; kullanıcı formun neden kaydedilmediğini anlamaz.

Kültüre duyarlı sayıları type="text" ile almak. "1.5" ile "1,5" farkı client'ta engel, sunucuda sessiz veri bozulması olarak çıkar.

Custom attribute'un client karşılığını yazıp script'i sayfaya eklemeyi unutmak. data-val-notinfuture HTML'de durur ama hiçbir adapter onu okumaz; kural client'ta sessizce çalışmaz, yalnız sunucu yakalar.

Küçük Bir Deneme

★☆☆ Stock alanına [Range(0, 1000, ErrorMessage = "Stok 0 ile 1000 arasında olmalı.")] yaz. Mesaj hem client'ta hem sunucuda değişiyor mu? data-val-range attribute'una bak.

★★☆ NotInFutureAttribute'a bir MaxDaysAhead property'si ekle: ön sipariş için yayın tarihi en fazla N gün ileride olabilsin. Parametreyi data-val-notinfuture-maxdays olarak client'a taşı ve adapter'ı güncelle.

★★★ Uygulamayı Türkçe kültürde başlat ve fiyat input'unu yeniden type="text" yap. jQuery Validation'ın $.validator.methods.number method'unu virgüllü sayıları kabul edecek şekilde ez. Client'ta 1,5 geçiyor mu, sunucuda 1.5 olarak kaydediliyor mu? Aynı sayfayı İngilizce kültürde açtığında ne bozuluyor?

Blok A'nın MVC tarafını bitirdik: controller'lar, Razor, partial ve view component'ler, tag helper'lar, formlar ve validation. Yönetim paneli çalışıyor ama her ekran için bir controller action'ı, bir view ve aradaki route kuralları gerekiyor. Bir sonraki yazıda aynı CRUD'u (Create, Read, Update, Delete) Razor Pages ile yeniden yazıyor; PageModel'i, handler method'larını, page routing'i ve MVC'ye göre dosya düzeninin nasıl değiştiğini görüyoruz.