Mert Özen Her Satırda Daha İleri
Yazılım Mimarisi Domain-Driven Design — #02

Ubiquitous Language: Domain Expert'le Aynı Dili Konuşan Kod

Mert Özen 7 Kas 2026 12 dk 5 görüntülenme
Ubiquitous Language: Domain Expert'le Aynı Dili Konuşan Kod

Ubiquitous language, bir bounded context içinde domain uzmanının ve developer'ların konuştuğu, class ve method adlarına birebir yansıyan tek ve kesin dildir: Status = 4 yerine order.Cancel(reason) ve RequestReturn. Belirsiz kelimeleri (ürün, durum, iade) ayrıştırmak, Türkçe konuşup İngilizce kod yazan ekip için çeviri sütunlu glossary, reflection ile glossary kontrolü ve HttpStatusCode'un RFC 9110 ile değişen dili.

Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore'un haftalık toplantısından bir not. Müşteri hizmetlerinden Grace şöyle diyor: "Ada'nın siparişini iade ettik, durumu 4'e çektim, ürün depoya dönünce Linus para iadesini yapacak." Aynı gün bir developer, ödeme ekranındaki bir hatayı düzeltirken Status == 4 olan siparişlerin parasının çoktan geri ödendiğini varsayan bir sorgu yazıyor.

Bu tek cümlede kaç ayrı kavram var? "İade" kaç farklı şey anlatıyor? "Durum" hangi object'in durumu: siparişin mi, ödemenin mi, gönderinin mi? "Ürün" depoda neye karşılık geliyor, siparişte neye? Ve o sorguyu yazan developer haklı mı? Bir sayı tahmin et; yazının sonunda aynı cümleyi koda çevirdiğimizde kaç ayrı isim çıktığını sayacağız.

Evans'ın Tanımı: Dil Modelin Omurgası

DDD (Domain-Driven Design) Reference, ubiquitous language'i şöyle tanımlıyor: domain modelinin etrafında kurulan ve bir bounded context içinde ekipteki herkesin, ekibin tüm faaliyetlerini yazılıma bağlamak için kullandığı dil. Tanımdaki üç parça önemli: dil modelin etrafında kurulur, herkes kullanır (developer da, domain uzmanı da), ve bir sınırın içinde geçerlidir.

Evans'ın tarif ettiği sorun tanıdık. Domain uzmanları kendi jargonlarını konuşur, developer'lar tasarım terimleriyle konuşur, kodun içindeki isimler ise ikisinden de kopuktur. Arada sürekli çeviri yapılır ve Evans'a göre çeviri iletişimi köreltir, bilgi öğütme sürecini (knowledge crunching) zayıflatır. Önerisi net: modeli dilin omurgası yap, aynı dili diyagramlarda, yazıda ve özellikle konuşmada kullan. Dildeki bir değişikliğin modelde bir değişiklik olduğunu kabul et; sonra class, method ve module'leri yeni modele uyacak şekilde yeniden adlandır.

Son cümle pratikte şu demek: toplantıda "iade" kelimesinin iki ayrı şey olduğu anlaşıldıysa, kodda da iki ayrı isim olmalı. Bunu yapmayan ekip, her yeni kişiye "aslında 4, para iadesi değil ürün iadesi demek" diye anlatmak zorunda kalır.

Kodda: Status = 4'ten RequestReturn'e

Bookstore'un eski sipariş modeli, toplantıdaki cümlenin koddaki karşılığı:

namespace Bookstore.Legacy;

public class Order
{
    public int Id { get; set; }
    // 0 = yeni, 1 = ödendi, 2 = kargoda, 3 = iptal, 4 = iade mi? para iadesi mi? (Grace'e sor)
    public int Status { get; set; }
    public string? Note { get; set; }
}

public class OrderManager
{
    public void UpdateStatus(Order order, int status, string? note = null)
    {
        order.Status = status;
        order.Note = note;
    }

    public void IadeEt(Order order) => UpdateStatus(order, 4, "returned");

    public decimal GetProductPrice(int productId) => 20.00m;
}

Durum bir sayı, anlamı bir yorum satırında ve o yorum bile emin değil. OrderManager adı ne yaptığını söylemiyor; IadeEt Türkçe bir kelimeyi İngilizce kodun ortasına koyuyor ve hangi iadeyi kastettiğini yine söylemiyor. GetProductPrice ise Bookstore'da kimsenin kullanmadığı bir kelimeyi kullanıyor: ekip "kitap" diyor, "ürün" demiyor.

Aynı kavramları toplantıda kullanılan fiillerle yazalım. Grace ve kampanya sorumlusuyla konuşunca kurallar netleşiyor: iptal yalnız kargoya verilmeden önce olur ve bir nedeni vardır; teslim edilmiş bir siparişteki kitap için iade talebi açılır; para iadesi ise ödeme tarafının işidir.

namespace Bookstore.Ordering;

public enum OrderStatus { Placed, Paid, Shipped, Delivered, Cancelled }

public enum CancellationReason { CustomerRequest, PaymentFailed, OutOfStock }

public sealed record ReturnRequest(string Title, string Reason);

public sealed class Order(int id)
{
    private readonly List<ReturnRequest> _returnRequests = [];

    public int Id { get; } = id;
    public OrderStatus Status { get; private set; } = OrderStatus.Placed;
    public CancellationReason? CancellationReason { get; private set; }
    public string? TrackingNumber { get; private set; }
    public IReadOnlyList<ReturnRequest> ReturnRequests => _returnRequests;

    public void MarkPaid() => Status = OrderStatus.Paid;

    public void MarkShipped(string trackingNumber)
    {
        if (Status is not OrderStatus.Paid)
            throw new InvalidOperationException($"Order {Id} is {Status}; only a paid order can be shipped.");
        TrackingNumber = trackingNumber;
        Status = OrderStatus.Shipped;
    }

    public void MarkDelivered() => Status = OrderStatus.Delivered;

    public void Cancel(CancellationReason reason)
    {
        if (Status is OrderStatus.Shipped or OrderStatus.Delivered)
            throw new InvalidOperationException($"Order {Id} is {Status}; it cannot be cancelled. Request a return instead.");
        CancellationReason = reason;
        Status = OrderStatus.Cancelled;
    }

    public ReturnRequest RequestReturn(string title, string reason)
    {
        if (Status is not OrderStatus.Delivered)
            throw new InvalidOperationException($"Order {Id} is {Status}; only a delivered order can be returned.");
        ReturnRequest request = new(title, reason);
        _returnRequests.Add(request);
        return request;
    }
}

Çalıştıralım. Program.cs'in son kısmı, biraz aşağıda göreceğimiz LanguageCheck'i çağırıyor:

using System.Reflection;
using Bookstore.Ordering;
using Bookstore.Tools;
using Legacy = Bookstore.Legacy;

// Önce: toplantıda "Ada'nın siparişi iade edildi" dendi, kod 4 diyor
Legacy.Order legacyOrder = new() { Id = 42 };
new Legacy.OrderManager().IadeEt(legacyOrder);
Console.WriteLine($"Legacy order {legacyOrder.Id}: Status = {legacyOrder.Status}, Note = {legacyOrder.Note}");

// Sonra: kod toplantıda söyleneni söylüyor
Order order = new(43);
try
{
    order.RequestReturn("Dune", "Damaged cover");
}
catch (InvalidOperationException ex)
{
    Console.WriteLine(ex.Message);
}

order.MarkPaid();
order.MarkShipped("TRK-1001");
order.MarkDelivered();
ReturnRequest request = order.RequestReturn("Dune", "Damaged cover");
Console.WriteLine($"Order {order.Id}: {order.Status}, return requested for {request.Title} ({request.Reason})");

try
{
    order.Cancel(CancellationReason.CustomerRequest);
}
catch (InvalidOperationException ex)
{
    Console.WriteLine(ex.Message);
}

Console.WriteLine();
foreach (string ns in new[] { "Bookstore.Legacy", "Bookstore.Ordering" })
{
    List<string> findings = [.. LanguageCheck.Scan(Assembly.GetExecutingAssembly(), ns)];
    Console.WriteLine($"{ns}: {findings.Count} finding(s)");
    findings.ForEach(f => Console.WriteLine($"  {f}"));
}
Legacy order 42: Status = 4, Note = returned
Order 43 is Placed; only a delivered order can be returned.
Order 43: Delivered, return requested for Dune (Damaged cover)
Order 43 is Delivered; it cannot be cancelled. Request a return instead.

Eski modelde 4 sayısının ne olduğu yine belirsiz ve henüz teslim edilmemiş bir sipariş de rahatça 4 olabilir. Tahmin sorusundaki developer'ın sorgusu da bu yüzden yanlış: Grace'in 4'ü "kitap geri istendi" demek, "para geri ödendi" değil; Linus'un para iadesi henüz yapılmadı. Yeni modelde her geçiş bir method: MarkPaid, MarkShipped(trackingNumber), MarkDelivered, Cancel(reason), RequestReturn(title, reason). Status property'sinin setter'ı private; durumu değiştirmenin tek yolu iş dilindeki bir fiil. Teslim edilmemiş siparişte iade talebi açmak ve teslim edilmiş siparişi iptal etmek, iş tarafının cümleleriyle hata veriyor.

Enum tamamen kaybolmadı; OrderStatus hâlâ var, ama artık sorgulanan bir sonuç, doğrudan atanan bir değer değil. Değişen şey, durumun hangi kelimelerle değiştiği. Bu, bir önceki yazıda andığımız eShop'un SetCancelledStatus() adlandırmasından da bir adım ileride: kural korunuyor, ad da iş dilinde.

Belirsiz Kelimeleri Ayrıştırmak

Ubiquitous language'in en değerli çıktısı çoğu zaman yeni kelimeler değil, ayrıştırılmış eski kelimelerdir. Bookstore'da en çok sorun çıkaranlar şunlar:

  • "Ürün": Catalog'da satıştaki kitap kaydı (Book), depoda fiziksel nüsha sayısı (StockItem), siparişte satın alınan satır (OrderItem). Ekip "ürün" demeyi bırakıp hangisini kastettiğini söylüyor.
  • "Durum": siparişin durumu (OrderStatus), gönderinin durumu (Shipping'deki ShipmentStatus), ödemenin durumu. Her birinin değerleri ve geçişleri farklı.
  • "İade": kitabın geri gönderilmesi (Return, Ordering'de ReturnRequest) ve paranın geri ödenmesi (Refund, Payment'ta). Biri olmadan diğeri olabilir: hasarlı kitap için müşteri kitabı geri göndermeden kısmi para iadesi alabilir.
  • "Müşteri": Ordering'de alıcı (Customer), kimlik doğrulama tarafında bir kullanıcı hesabı, Notification'da e-postanın alıcısı. Aynı kişi, üç farklı model.

Bu son madde önemli bir ipucu: bazı kelimeler tek bir modelin içinde ayrıştırılamaz, çünkü farklı yerlerde gerçekten farklı şeylerdir. "Müşteri"nin Ordering'deki ve Notification'daki anlamını tek bir class'ta birleştirmeye çalışmak, dördüncü yazının konusu olan bounded context'e götürür. Ubiquitous language "her yerde tek dil" demek değil; "bir sınırın içinde tek ve kesin dil" demek.

Türkçe Konuşan, İngilizce Kod Yazan Ekip

Türk ekiplerin çoğunda domain dili Türkçe, kod İngilizce. Toplantıda "kısmi iptal", "iade talebi", "kampanya" denir; kodda CancelItem, ReturnRequest, Promotion yazılır. Bu durum DDD ile çelişmez, ama çeviriyi bir kerelik ve ortak bir karar yapmayı gerektirir. Çözüm, glossary'ye bir çeviri sütunu eklemek:

Domain dili (TR)Kod adıContextAnlamı
siparişOrderOrderingMüşterinin verdiği ve ödemesi beklenen ya da alınmış sipariş
sipariş satırıOrderItemOrderingBir kitap, adet ve o anki birim fiyat
iptalCancel(reason)OrderingKargoya verilmeden önce siparişi geri almak
iade (ürün)ReturnRequestOrderingTeslim edilmiş kitabın geri gönderilmesi talebi
iade (para)RefundPaymentParanın müşteriye geri ödenmesi
kampanyaPromotionOrderingOkul açılışı, yıl sonu gibi fiyat indirimi kuralı
kargoShipment, Parcel, carrierShippingGönderi süreci, fiziksel koli ve kargo firması: üç ayrı kavram

Tablodaki iki satır, çevirinin neden kişilere bırakılmaması gerektiğini gösteriyor. "Kampanya" kelimesini sözlükten çeviren biri Campaign yazar; ama İngilizcede campaign çoğunlukla bir pazarlama kampanyasını (e-posta gönderimi, reklam) anlatır, fiyat indirimi kuralı ise promotion'dır. "Kargo" ise Türkçede hem gönderiyi, hem koliyi, hem de kargo firmasını anlatabilir; kodda bunlar üç ayrı isim olmalı.

Birkaç kural işe yarıyor. Her Türkçe terimin tek bir İngilizce karşılığı olsun ve bu karşılık domain uzmanıyla birlikte, bir kere seçilsin. Identifier'larda Türkçe ve İngilizce karıştırılmasın: IadeEt ya da SiparisIptal gibi isimler hem dili hem aramayı bozar. Kelimesi kelimesine çeviri yerine işin fiili seçilsin: "sipariş durumunu güncelle" UpdateOrderStatus değil, ne olduysa odur: Cancel, MarkShipped. Toplantıda da kod adları rahatça kullanılabilir; "ReturnRequest açıldı mı?" cümlesi, Türkçe konuşan bir ekipte gayet doğaldır.

Dili Kodda Korumak: Glossary Kontrolü

Glossary bir dokümanda kalırsa, ilk acil işte unutulur. Kuralların bir kısmını otomatik kontrol edilebilir hâle getirmek mümkün. Aşağıdaki küçük araç, bir namespace'teki public tip ve member adlarını reflection ile tarıyor ve glossary'nin yasakladığı kelimeleri buluyor:

using System.Reflection;

namespace Bookstore.Tools;

public static class LanguageCheck
{
    // Glossary kuralları: kodda geçmemesi gereken kelime -> ekibin yerine söylediği
    private static readonly Dictionary<string, string> Forbidden = new()
    {
        ["Product"] = "say Book, StockItem or OrderItem",
        ["Manager"] = "name the responsibility",
        ["Iade"] = "say Return (the book) or Refund (the money)",
    };

    public static IEnumerable<string> Scan(Assembly assembly, string ns)
    {
        foreach (Type type in assembly.GetTypes().Where(t => t.Namespace == ns))
        {
            IEnumerable<string> names = type.GetMembers(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)
                .Select(m => m.Name)
                .Prepend(type.Name);

            foreach (string name in names.Distinct())
                foreach ((string word, string advice) in Forbidden)
                    if (name.Contains(word, StringComparison.OrdinalIgnoreCase))
                        yield return $"{type.Name}.{name}: '{word}' -> {advice}";

            PropertyInfo? status = type.GetProperty("Status");
            if (status?.SetMethod?.IsPublic == true)
                yield return $"{type.Name}.Status has a public setter -> use domain methods (Cancel, MarkShipped...)";
        }
    }
}

Yukarıdaki programın aynı çalıştırmasının ikinci kısmı:

Bookstore.Legacy: 4 finding(s)
  Order.Status has a public setter -> use domain methods (Cancel, MarkShipped...)
  OrderManager.OrderManager: 'Manager' -> name the responsibility
  OrderManager.IadeEt: 'Iade' -> say Return (the book) or Refund (the money)
  OrderManager.GetProductPrice: 'Product' -> say Book, StockItem or OrderItem
Bookstore.Ordering: 0 finding(s)

Bu kontrolü bir unit test'e koyarsan, Bookstore.Ordering'e ProductManager adında bir class ekleyen pull request CI'da (Continuous Integration) kırmızıya döner ve tartışma kod review'dan önce başlar. Elbette araç dili anlamaz; yalnız ekibin daha önce verdiği kararları hatırlatır. "Bu kelimeyi neden kullanmıyoruz?" sorusunun cevabı da yorum olarak yanında durur. Evans'ın Reference'taki Continuous Integration pattern'i de aynı fikri söyler: bir bounded context içinde model kavramlarını ve terimleri sık birleştirme ve otomatik test'lerle tutarlı tut.

.NET'in Kendi Dili: HttpStatusCode ve RFC 9110

Ubiquitous language'in iyi bir örneği BCL'in (Base Class Library) içinde duruyor. System.Net.HttpStatusCode enum'ının dokümantasyonu, değerlerin RFC (Request for Comments) 9110'un, yani HTTP (Hypertext Transfer Protocol) Semantics standardının 15. bölümünde tanımlandığı gibi olduğunu söyler. Burada domain HTTP'nin kendisi, domain uzmanları da standardı yazan IETF (Internet Engineering Task Force) çalışma grubu; .NET'in isimleri onların dilini izliyor: NotFound, Conflict, TooManyRequests.

Peki domain dili değişince ne oluyor? RFC 9110, 2022'de 422 kodunun adını "Unprocessable Entity" yerine "Unprocessable Content" yaptı. .NET de enum'a UnprocessableContent adını ekledi, ama eski UnprocessableEntity adını kaldırmadı; public bir API'den (Application Programming Interface) isim silmek, onu kullanan herkesin kodunu kırar. Sonuç:

using System.Net;

HttpStatusCode code = HttpStatusCode.UnprocessableContent;
Console.WriteLine($"{(int)code} {code}");
Console.WriteLine(HttpStatusCode.UnprocessableContent == HttpStatusCode.UnprocessableEntity);
422 UnprocessableEntity
True

Değişkene yeni adla atadığımız değer, ekrana eski adla çıkıyor. İki isim aynı sayıya karşılık geldiği için ToString bunlardan birini seçiyor ve log'larda, hata mesajlarında eski kelime yaşamaya devam ediyor. Ders iki yönlü. Birincisi, dildeki bir değişiklik gerçekten modeldeki bir değişiklik ve maliyeti var. İkincisi, kendi bounded context'inin içinde bu maliyet düşük: kodun dışarıya açılmadığı yerde yeniden adlandırma bir IDE (Integrated Development Environment) refactoring'i kadar ucuz. Dil netleştiği anda adı değiştir; eşanlamlıları yan yana yaşatma.

Glossary Nerede Yaşar?

Ekiplerin çoğunda glossary bir wiki sayfasında başlar ve orada ölür. Ubiquitous language'in yeri kodun yanıdır: her bounded context'in klasöründe, kodla birlikte versiyonlanan ve pull request'te kodla birlikte gözden geçirilen kısa bir dosya. Yeni bir terim ilk kez bir class adı olarak ortaya çıktığında, aynı pull request glossary'ye de bir satır ekler. Domain uzmanı kodu okumuyor olabilir, ama bu dosyayı okuyabilir ve itiraz edebilir.

Glossary'nin sahibi ekip ve domain uzmanı birlikte. Satır başına dört şey yeterli: Türkçe terim, kod adı, hangi context'te geçerli olduğu ve tek cümlelik anlamı. "Kullanma" listesi de değerli: "ürün", "durum", tek başına "iade" ve "Manager", "Data", "Info" gibi hiçbir şey anlatmayan ekler. Dördüncü yazıda göreceğimiz Bounded Context Canvas'ta da ubiquitous language için ayrı bir alan var; glossary orada context'in kimliğinin bir parçası olur.

Sık Düşülen Tuzaklar

Dilin yalnız wiki'de yaşaması. Glossary kodla birlikte değişmiyorsa, birkaç ay içinde ikisi ayrı dilleri konuşmaya başlar ve yeni gelen kişi hangisine inanacağını bilemez.

Çeviriyi her developer'ın kendine bırakmak. Aynı "iade" bir class'ta Return, başka bir class'ta Refund, üçüncüsünde Reimbursement olur. Çeviri bir kerelik ekip kararıdır ve glossary'ye yazılır.

Teknik dili toplantıya taşımak. "OrderManager status'u 4'e set ediyor" cümlesini domain uzmanı anlayamaz ve itiraz edemez. Kod adları iş dilinden gelmeli; tersi değil.

Kelimeleri her yerde aynı anlama zorlamak. "Müşteri"nin Ordering'de ve Notification'da farklı şey olması bir hata değil, iki ayrı modelin işareti. Tek bir dev Customer class'ı bu farkı gizler; sınırlar dördüncü yazının konusu.

Enum'u doğrudan atanabilir bırakmak. order.Status = OrderStatus.Cancelled yazılabildiği sürece, iş dilindeki Cancel method'unun kuralları atlanabilir. Durumu yalnız anlamlı method'lar değiştirsin.

Küçük Bir Deneme

★☆☆ Tahmin sorusundaki cümleye dön ve yazıdaki kod adlarıyla yeniden yaz. Kaç ayrı isim çıktı? (Bizim saydığımız: Order, ReturnRequest, Refund, OrderStatus, StockItem; "durumu 4'e çektim" ise hiçbir isme karşılık gelmiyor, çünkü artık bir method çağrısı.)

★★☆ LanguageCheck'i bir xUnit test'ine taşı ve Bookstore.Ordering için sıfır bulgu bekleyen bir assertion yaz. Sonra glossary kurallarını koddaki dictionary yerine repository'deki bir glossary dosyasından okumasını sağla.

★★★ Kendi projendeki en çok kullanılan beş domain kelimesini seç. Her biri için ekipten üç kişiye ayrı ayrı "bu kelime tam olarak ne demek?" diye sor ve cevapları karşılaştır. Farklı çıkan kelimeler için glossary satırını yaz ve koddaki karşılıklarını yeniden adlandırmanın maliyetini çıkar.

Bu yazıda dili kodla aynı hizaya getirdik. Ama glossary yazarken bir şey fark ettin: bazı konularda daha çok konuşuyor, daha çok tartışıyor, kuralları daha sık değiştiriyorsun; bazılarında ise tek bir satır yeterli. Bir sonraki yazıda bu farkı subdomain'lerle adlandırıyoruz: core, supporting ve generic ayrımıyla Bookstore'da yatırımın nereye yapılması gerektiğini.