Object mi Data Structure mı? Record, DTO ve Primitive Obsession
Object verisini saklayıp davranış açar, data structure veriyi açar ve davranış taşımaz; ikisinin arasındaki hybrid en pahalısıdır. DTO'lar için record, domain için encapsulation, string ve decimal ile taşınan kavramlar için Money ve Email gibi value object'ler kullan; Law of Demeter'a da kısa bir giriş yapıyoruz.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.
Önce Tahmin Et: İki VIP Müşteri, İki Farklı Fiyat
Serinin legacy OrderService'ine iki yeni VIP (Very Important Person) müşteri ekliyoruz, ikisi de İstanbul'da oturuyor. Sonra ikisi için aynı sepetin fiyat teklifini alıyoruz: tek bir Emma, 180 TRY (Türk lirasının uluslararası para birimi kodu).
// İmza: CreateCustomer(string name, string email, string street, string city,
// string zip, string country, string phone, string type, bool newsletter)
int linus = service.CreateCustomer("Linus Torvalds", "linus@example.com", "Istanbul", "Bagdat Cd. 7",
"34710", "Turkey", "5550000000", "VIP", false);
int barbara = service.CreateCustomer("Barbara Liskov", "barbara@example.com", "Bagdat Cd. 9", "Istanbul",
"34710", "Turkey", "5550000001", "VIP", false);
var cart = new Dictionary<string, int> { ["978-0141439518"] = 1 }; // Emma, 180 TRY
Console.WriteLine($"Linus: {service.GetQuote(linus, cart, null, 1)}");
Console.WriteLine($"Barbara: {service.GetQuote(barbara, cart, null, 1)}");
Aynı kitap, aynı müşteri tipi, aynı şehir. İki satır aynı sayıyı mı basar? Kod derlenir mi, derlenirse derleyici bir şey söyler mi?
Object ve Data Structure: Birbirinin Tersi İki Şey
Çıktı:
Linus: 228.00
Barbara: 218.00
Kod uyarısız derleniyor ve Linus on lira fazla ödüyor. Sebebi ilk çağrıda: sokak ile şehir yer değiştirmiş. Linus'un şehri artık "Bagdat Cd. 7", yani KargoUcretiHesapla içindeki city == "Istanbul" indirimi ona hiç uygulanmıyor: 171 TRY'lik ara toplam, %10 vergiyle 188,10, üstüne 39,90 kargo. Barbara'nınki 29,90. Dokuz parametrenin yedisi string; derleyicinin gözünde sokak, şehir, telefon ve müşteri tipi aynı şey. Bu yazının iki konusu buradan çıkıyor: veriyi nasıl modellediğimiz ve o veriyi taşıyan tiplerin ne kadar "konuştuğu".
Robert C. Martin (Uncle Bob), Clean Code kitabının (2008; ikinci baskısı 2025) objects and data structures bölümünde iki kavramı keskin bir çizgiyle ayırır. Object verisini bir abstraction'ın arkasına saklar ve dışarıya davranış açar; ona "ne yapmasını" söylersin. Data structure ise verisini açar ve anlamlı bir davranışı yoktur; onu okuyan kod ne yapılacağına kendisi karar verir. İkisi de meşru. Sorun, hangisini yazdığını bilmemekte.
Legacy koddaki Order saf bir data structure:
public class Order
{
public int Id;
public Customer Customer;
public DateTime Date;
public int Status; // 0 yeni, 1 ödendi, 2 onaylandı, 3 kargoda, 4 iptal, 9 ödeme hatası
public List<OrderItem> Items;
public decimal Discount;
public decimal Shipping;
public decimal Total;
public string PaymentRef;
public string Note;
}
Hiç method yok, her field public. İndirimi, vergiyi, kargoyu ve Total'ı 600 satırlık OrderService hesaplıyor. Bu kendi başına hata değil; bir DTO (Data Transfer Object) tam olarak böyle görünmeli. Hata, bu yapının domain'in kalbinde durması: Total'ın Items ile tutarlı olduğunu garanti eden tek şey, herkesin OrderService'i doğru sırayla çağırması.
Anti-Symmetry: Yeni Tip mi Ucuz, Yeni İşlem mi?
Martin'in "data/object anti-symmetry" dediği şey, iki yaklaşımın değişime tam ters tepki vermesi. Legacy'deki int t kargo tipini önce bir enum'a ve procedural fonksiyonlara çevirelim:
public enum ShippingMethod
{
Standard,
Express,
StorePickup,
Locker
}
// Data structure + procedural fonksiyonlar
public static class ShippingRules
{
public static decimal Fee(ShippingMethod method, decimal orderAmount) => method switch
{
ShippingMethod.Standard => orderAmount >= 500m ? 0m : 39.90m,
ShippingMethod.Express => 89.90m,
ShippingMethod.StorePickup => 0m,
_ => throw new ArgumentOutOfRangeException(nameof(method), method, "Unknown shipping method.")
};
public static int EstimatedDays(ShippingMethod method) => method switch
{
ShippingMethod.Standard => 3,
ShippingMethod.Express => 1,
ShippingMethod.StorePickup => 2,
_ => throw new ArgumentOutOfRangeException(nameof(method), method, "Unknown shipping method.")
};
}
Bu tasarımda yeni bir işlem eklemek ucuz: CarbonFootprint(ShippingMethod) yazarsın, mevcut hiçbir şeye dokunmazsın. Yeni bir tip eklemek ise pahalı: enum'a Locker'ı ekledim ama switch'leri güncellemeyi unuttum. Derleme temiz geçti, hata çalışma zamanında geldi:
Standard: 39.90
Express: 89.90
StorePickup: 0
Locker: Unknown shipping method. (Parameter 'method')
Actual value was Locker.
Derleyici neden sustu? Discard (_) kolu her şeyi yakaladığı için. Discard'ı kaldırırsan C# bu kez CS8524 (C# derleyicisinin uyarı kodu) ile "isimsiz enum değerleri" için uyarır, çünkü bir enum'a (ShippingMethod)42 da atanabilir; çoğu ekip bu uyarıyı susturmak için discard'ı geri koyar ve bedavaya gelen korumayı kaybeder. Alternatif yol, yalnız CS8524'ü kapatıp discard'sız yazmak: project dosyasına <NoWarn>$(NoWarn);CS8524</NoWarn> ekleyip switch'lerden _ kolunu silince, yeni bir enum değeri eklendiği anda derleyici her eksik switch'i gösteriyor:
ShippingRules.cs(13,85): warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Bookstore.Shipping.ShippingMethod.Locker' is not covered.
ShippingRules.cs(20,70): warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Bookstore.Shipping.ShippingMethod.Locker' is not covered.
Aynı problemi object tarafından çözelim:
// Object'ler: davranış tipin içinde
public interface IShippingOption
{
string Name { get; }
int EstimatedDays { get; }
decimal Fee(decimal orderAmount);
}
public sealed class StandardShipping : IShippingOption
{
public string Name => "Standard";
public int EstimatedDays => 3;
public decimal Fee(decimal orderAmount) => orderAmount >= 500m ? 0m : 39.90m;
}
public sealed class LockerShipping(decimal lockerFee) : IShippingOption
{
public string Name => "Locker";
public int EstimatedDays => 2;
public decimal Fee(decimal orderAmount) => lockerFee;
}
Standard: 39.90, 3d
Express: 89.90, 1d
Locker: 19.90, 2d
Burada simetri tersine dönüyor. Locker tek bir yeni class; başka hiçbir dosya değişmedi. Ama interface'e CarbonFootprint eklemek istersen her implementation'ı açman gerekiyor. Philip Wadler'ın (Haskell ve Java generics'in tasarımcılarından) 1998'de "expression problem" adını verdiği gerilim bu: hem yeni tipleri hem yeni işlemleri ucuz yapan bir yapı yok, hangisinin daha sık değişeceğine sen karar veriyorsun. Kargo seçenekleri yılda bir değişiyor ama her biri farklı kurallar taşıyorsa object; kargo tipleri sabit ama raporlama, etiket, fiyat gibi işlemler sürekli ekleniyorsa data structure.
.NET'in kendisi bilinçli olarak ikinci yolu seçtiği yerlerde bunu açıkça gösteriyor. System.Linq.Expressions'daki BinaryExpression, MethodCallExpression gibi node'lar Left, Right, NodeType property'lerini açan data structure'lardır; yeni işlemler ExpressionVisitor'dan türeyen class'larla eklenir. Entity Framework Core'un (EF Core) sorgu çevirisi node class'larına tek satır eklemeden SQL (Structured Query Language) üretebiliyor, çünkü node tipleri neredeyse hiç değişmiyor, onları işleyen kod ise her provider'da yeniden yazılıyor. C# İleri serisinin "C#'ta Expression Tree: Lambda'yı Veri Olarak Tutmak ve EF Core'a Köprü" yazısı bu yapıyı ayrıntılı anlatıyor.
DTO İçin record, Domain İçin Encapsulation
En pahalı seçenek ikisinin arası. Legacy Order'a birisi "artık biraz davranış ekleyelim" diye bir method koyuyor ama field'lara dokunmuyor:
public record OrderItem(string Isbn, decimal Price, int Quantity);
// Yarı object, yarı data structure
public class Order
{
public List<OrderItem> Items { get; set; } = [];
public decimal Total { get; set; }
public void Recalculate() => Total = Items.Sum(i => i.Price * i.Quantity);
}
var hybrid = new Bookstore.Hybrid.Order();
hybrid.Items.Add(new("978-0441013593", 450m, 1));
hybrid.Recalculate();
hybrid.Items.Add(new("978-0141439518", 180m, 1));
Console.WriteLine($"Items: {hybrid.Items.Count}, Total: {hybrid.Total}");
Items: 2, Total: 450
Martin bu yapıya hybrid der ve iki dünyanın kötü yanlarını birleştirdiğini söyler: davranışı var, ama veri açık olduğu için o davranışın koruması gereken kural (Total, Items'ın toplamıdır) her yerden delinebiliyor. Yeni bir işlem eklemek de yeni bir tip eklemek de zorlaşıyor. Gerçek projede bu, EF Core entity'sine birkaç "helper" method eklenip setter'ların public kalmasıyla oluşur ve bir gün raporda toplamı tutmayan siparişler görünür.
Domain tarafında çözüm encapsulation: veri içeride, kural davranışın içinde.
public sealed record OrderLine(Isbn Isbn, Money UnitPrice, int Quantity)
{
public Money LineTotal => UnitPrice * Quantity;
}
public sealed class Order
{
private readonly List<OrderLine> _lines = [];
public Order(int id, Email customerEmail, string currency)
{
Id = id;
CustomerEmail = customerEmail;
Currency = currency;
}
public int Id { get; }
public Email CustomerEmail { get; }
public string Currency { get; }
public IReadOnlyList<OrderLine> Lines => _lines;
public Money Total => _lines.Aggregate(Money.Zero(Currency), (sum, line) => sum + line.LineTotal);
public void AddLine(Isbn isbn, Money unitPrice, int quantity)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);
if (unitPrice.Currency != Currency)
throw new InvalidOperationException($"Order {Id} is in {Currency}, price is in {unitPrice.Currency}.");
int index = _lines.FindIndex(l => l.Isbn == isbn);
if (index >= 0)
_lines[index] = _lines[index] with { Quantity = _lines[index].Quantity + quantity };
else
_lines.Add(new OrderLine(isbn, unitPrice, quantity));
}
}
Total artık saklanmıyor, her okunduğunda satırlardan hesaplanıyor; tutarsız olması imkânsız. Lines dışarıya IReadOnlyList<OrderLine> olarak açılıyor, satır eklemenin tek yolu AddLine ve o method miktarı, para birimini ve aynı ISBN'in (International Standard Book Number) birleştirilmesini tek yerde topluyor. C# OOP (Object-Oriented Programming) serisinin "C#'ta Encapsulation ve Access Modifier'lar: public, private, protected, internal" yazısındaki kuralların sipariş ölçeğindeki karşılığı bu.
API'nin (Application Programming Interface) dışarı verdiği şey ise yine bir data structure olmalı, ama bu kez bilerek. Burada record doğru araç: kısa, immutable, value equality'li ve serialize edilmeye hazır.
public sealed record OrderLineDto(string Isbn, decimal UnitPrice, int Quantity);
public sealed record OrderSummaryDto(int Id, string CustomerEmail, decimal Total, string Currency, IReadOnlyList<OrderLineDto> Lines);
public static class OrderMapping
{
public static OrderSummaryDto ToSummaryDto(this Order order) => new(
order.Id,
order.CustomerEmail.Value,
order.Total.Amount,
order.Currency,
[.. order.Lines.Select(l => new OrderLineDto(l.Isbn.Value, l.UnitPrice.Amount, l.Quantity))]);
}
var order = new Order(1042, Email.Create(" Ada@Example.com "), "TRY");
order.AddLine(new Isbn("978-0441013593"), new Money(450m, "TRY"), 1);
order.AddLine(new Isbn("978-0141439518"), new Money(180m, "TRY"), 2);
order.AddLine(new Isbn("978-0441013593"), new Money(450m, "TRY"), 1);
Console.WriteLine($"Lines: {order.Lines.Count}, Total: {order.Total}, Minor: {order.Total.ToMinorUnits()}");
OrderSummaryDto dto = order.ToSummaryDto();
Console.WriteLine(JsonSerializer.Serialize(dto, JsonSerializerOptions.Web));
Console.WriteLine(dto == order.ToSummaryDto());
Lines: 2, Total: 1260.00 TRY, Minor: 126000
{"id":1042,"customerEmail":"ada@example.com","total":1260,"currency":"TRY","lines":[{"isbn":"978-0441013593","unitPrice":450,"quantity":2},{"isbn":"978-0141439518","unitPrice":180,"quantity":2}]}
False
Son satıra dikkat: aynı siparişten üretilen iki DTO eşit değil. Record'un ürettiği Equals her property'yi EqualityComparer<T>.Default ile karşılaştırır ve IReadOnlyList<T> için bu reference equality demek; her mapping yeni bir collection object'i üretiyor. Lines'ı iki tarafta da [] ile değiştirince sonuç True oluyor, çünkü boş collection expression her seferinde aynı boş array'i kullanıyor. DTO'ları testte ya da cache key olarak karşılaştıracaksan collection property'leri bu davranışı bilerek kullan. Record'un geri kalan kuralları C# OOP serisinin "C# record ve record struct: Value Equality ve with Expression'ı" yazısında; elle mapping ile kütüphane karşılaştırması ASP.NET Core Web API serisinin "ASP.NET Core Web API'de DTO ve Mapping: Elle Mapping, AutoMapper Lisansı ve Mapperly" yazısında.
Primitive Obsession'dan Value Object'e
Martin Fowler'ın Refactoring kitabındaki (1999; ikinci baskı 2018) smell kataloğunda yer alan primitive obsession, domain kavramlarının string, int, decimal gibi primitive tiplerle taşınması. Legacy imzalar bunun katalog örneği gibi: CreateCustomer dokuz parametre alıyor, yedisi string; ProcessData kargo tipini int t ile, müşteri tipini "vip" ile, ödeme sağlayıcısına tutarı long amountMinor ile veriyor. E-postanın doğrulaması email.Contains("@"), para birimi "TRY" literal'i.
Value object bu kavramlara kendi tipini verir: kimliği yoktur, değeriyle eşitlenir, immutable'dır ve geçersiz bir hâlde kurulamaz. Para için readonly record struct iyi bir başlangıç:
public readonly record struct Money
{
public Money(decimal amount, string currency)
{
ArgumentOutOfRangeException.ThrowIfNegative(amount);
if (currency is not { Length: 3 } || !currency.All(char.IsAsciiLetterUpper))
throw new ArgumentException($"'{currency}' is not an ISO 4217 code.", nameof(currency));
Amount = amount;
Currency = currency;
}
public decimal Amount { get; }
// default(Money) constructor'ı atlar; Currency null kalır
public string Currency { get => field ?? throw new InvalidOperationException("Money was not initialized."); }
public static Money Zero(string currency) => new(0m, currency);
public static Money operator +(Money left, Money right)
{
if (left.Currency != right.Currency)
throw new InvalidOperationException($"Cannot add {left.Currency} and {right.Currency}.");
return new Money(left.Amount + right.Amount, left.Currency);
}
public static Money operator *(Money price, int quantity) => new(price.Amount * quantity, price.Currency);
public long ToMinorUnits() => (long)Math.Round(Amount * 100m, MidpointRounding.AwayFromZero);
public override string ToString() => $"{Amount.ToString("0.00", CultureInfo.InvariantCulture)} {Currency}";
}
Legacy'de (long)(o.Total * 100) ifadesi iki ayrı yerde yazılıyordu; artık kuruşa çevirme kuralının tek bir evi var. Siparişe EUR (euro) fiyatlı bir satır eklemeye çalışınca Order reddediyor: Order 1042 is in TRY, price is in EUR. Kuruş dağıtımı, KDV (katma değer vergisi) yuvarlaması ve veritabanında saklama gibi Money'nin daha derin tarafları Çözüm Desenleri serisinin "Para ve Yuvarlama: decimal, Money Value Object, KDV ve Kuruş Dağıtımı" yazısında.
E-posta için struct yerine sealed record seçtim, sebebini birazdan tuzaklarda göreceğiz. Doğrulamayı BCL'deki (Base Class Library, .NET'in temel class kütüphanesi) System.Net.Mail.MailAddress'e bırakıp üstüne domain kuralını ekliyorum:
public sealed record Email
{
private Email(string value) => Value = value;
public string Value { get; }
public static bool TryCreate(string? input, [NotNullWhen(true)] out Email? email)
{
email = null;
string candidate = input?.Trim() ?? "";
if (!MailAddress.TryCreate(candidate, out MailAddress? parsed)
|| parsed.Address != candidate
|| !parsed.Host.Contains('.'))
return false;
email = new Email(candidate.ToLowerInvariant());
return true;
}
public static Email Create(string input) =>
TryCreate(input, out Email? email) ? email : throw new FormatException($"'{input}' is not a valid email address.");
public override string ToString() => Value;
}
[ada@example.com] -> ada@example.com
[ ADA@Example.com ] -> ada@example.com
[ada@example] -> invalid
[Ada Lovelace <ada@example.com>] -> invalid
[ada] -> invalid
MailAddress tek başına ada@example'ı ve Ada Lovelace <ada@example.com>'u geçerli sayıyor; RFC (Request for Comments, internet standartlarını tanımlayan doküman serisi) açısından haklı, ama Bookstore için müşteri e-postası bir display name taşımamalı ve noktasız bir domain'e mail gitmez. Bu yüzden iki ek kural var. BCL'nin kendisi de value object'lerle dolu: TimeSpan, DateOnly, Guid, Uri, Version, IPAddress ve MailAddress. İlginç bir ayrıntı: MailAddress bir class ve == operatörünü override etmiyor; iki aynı adres == ile False, Equals ile (büyük/küçük harf duyarsız) True dönüyor. Kendi value object'ini record ile yazınca bu tutarsızlık hiç oluşmuyor.
Kazancın derleyiciye yansıyan kısmı açılıştaki hatayı imkânsız kılıyor. Yeni kayıt method'u Register(int id, string name, Email email, PostalAddress address) imzasını taşıyor ve e-posta yerine düz bir string verince:
Bookstore.Customers.CustomerRegistration.Register(4, "Linus Torvalds", "linus@example.com", new("Bagdat Cd. 7", "34710", new("Istanbul", "Turkey")));
Program.cs(79,72): error CS1503: Argument 3: cannot convert from 'string' to 'Bookstore.Domain.Email'
Dürüst olmak gerekirse PostalAddress(string Street, string Zip, City City) içinde sokak hâlâ bir string. Value object'ler primitive obsession'ı bir katman yukarı taşır, tamamen yok etmez. Şehri ayrı bir City tipi yapmak açılıştaki sokak/şehir karışıklığını çözüyor; sokak ile posta kodunu karıştırma riski ise named argument'larla (new PostalAddress(Street: ..., Zip: ...)) ya da bir sonraki tip ayrımıyla kapatılabilir. Her string'e tip açmak gerekmiyor; karıştırılması pahalı olanlara açıyorsun.
Law of Demeter ve Train Wreck: Kısa Giriş
Legacy kodda bir pattern daha tekrar ediyor: c.Address.City.Name (fiyat teklifi), o.Customer.Address.City.Country (kargo), o.Customer.Address.Street (fatura). Martin bu zincirlere train wreck der. Adresi olmadan import edilmiş bir müşteri için fiyat teklifi istenince ne olduğuna bakalım:
NullReferenceException: Object reference not set to an instance of an object.
at Bookstore.Legacy.OrderService.GetQuote(Int32 cId, Dictionary`2 d, String coupon, Int32 t) in .../OrderService.cs:line 190
Satır numarası var ama zincirdeki hangi halkanın null olduğu yok. Asıl sorun bundan da büyük: OrderService müşterinin adresinin, adresin şehrinin, şehrin ülkesinin nasıl tutulduğunu biliyor. Adres modeli değiştiği gün fiyat, kargo ve fatura kodu birlikte değişecek.
Law of Demeter 1987'de Northeastern Üniversitesi'ndeki Demeter projesinde Ian Holland'ın önerisiyle doğdu ve Karl Lieberherr ile Holland'ın 1988–89 makaleleriyle yayıldı. Kabaca şunu söyler: bir method yalnız kendi object'inin, parametrelerinin, kendi oluşturduğu object'lerin ve field'larının method'larını çağırmalı; "yabancıların yabancılarıyla" konuşmamalı. Çözüm genelde soruyu bilgisinin sahibine sormaktır:
public sealed class Customer(int id, string name, Email email, PostalAddress? shippingAddress)
{
public int Id { get; } = id;
public string Name { get; } = name;
public Email Email { get; } = email;
public bool ShipsDomestically => shippingAddress?.City.Country == "Turkey";
public string ShippingCity =>
shippingAddress?.City.Name ?? throw new InvalidOperationException($"Customer {Id} has no shipping address.");
}
False
Customer 6 has no shipping address.
Artık hata mesajı neyin eksik olduğunu ve kimde eksik olduğunu söylüyor, adresin iç yapısını da yalnız Customer biliyor. İki önemli sınır var. Birincisi, Martin'e göre kural object'ler için geçerlidir; DTO gibi saf data structure'larda dto.Customer.Address.City okumak ihlal değildir, çünkü saklanan bir davranış yoktur. İkincisi, LINQ (Language Integrated Query) ve fluent API zincirleri her adımda aynı türden yeni bir object döndürdüğü için bu kuralın konusu değildir. Tell, Don't Ask, connascence ile coupling derecelendirmesi ve kuralın ne zaman gereksiz wrapper'lara dönüştüğü ilerideki SOLID (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion) ve Design Principles serisinin "Coupling ve Cohesion: İyi Tasarımın İki Ölçüsü, Connascence ve Law of Demeter" yazısında derinlemesine anlatılacak.
Sık Düşülen Tuzaklar
Birinci tuzak, readonly record struct'ın constructor'ının her zaman çalıştığını sanmak. Struct'larda default her zaman vardır: new Money[2] dizisinin element'leri, bir class'ın atanmamış Money field'ı ya da new Money() constructor'daki doğrulamaların hiçbirinden geçmez. Yukarıdaki Currency getter'ı C# 14'ün field keyword'üyle bunu yakalıyor:
0
Money was not initialized.
Amount sessizce 0 dönüyor, ToString() ise exception fırlatıyor. Bu kabul edilebilir bir uzlaşma, çünkü hata ilk kullanımda ve anlamlı bir mesajla geliyor. "Geçersiz hâl kesinlikle var olamaz" diyorsan struct yerine class (sealed record) seç; Email'de bunu yaptım, çünkü boş bir e-postanın hiçbir makul varsayılanı yok.
İkinci tuzak with expression'ı. Property'leri { get; init; } yazarsan price with { Amount = -5m } constructor'ı atlayıp negatif bir para üretir; denedim, Money { Amount = -5, Currency = TRY } çıktı. Doğrulama yalnız constructor'daysa property'leri { get; } bırak; o zaman derleyici with'i CS0200 ile reddeder.
Üçüncü tuzak, value object'i EF Core ve JSON (JavaScript Object Notation) boundary'sinde unutmak. Money bir entity property'si olduğunda EF Core'a nasıl map edileceğini söylemen gerekir (complex type, owned type ya da value converter); EF Core serisinin "EF Core'da Owned Type ve Value Converter: Adres, Enum ve Strongly Typed Id" ve "EF Core 8 Yenilikleri: Complex Type, Primitive Collection ve SqlQuery<T>" yazıları bu kısmı anlatıyor. API boundary'sinde ise domain tipini doğrudan serialize etmek yerine DTO'ya çevirmek, JSON contract'ını domain'den bağımsız tutar.
Dördüncü tuzak, DTO'ya davranış eklemek. OrderSummaryDto'ya bir CalculateTax() method'u koyduğun an hybrid'e geri dönersin. DTO'nun işi taşımaktır; kural domain'de kalmalı.
Ne Zaman Kullanma
Admin panelindeki bir lookup ekranı, bir raporun satır modeli ya da yalnız veritabanından okuyup ekrana basan bir sorgu için encapsulation ve value object kurmak çoğu zaman masraftır. Kuralı olmayan veride data structure yeterli ve dürüst bir seçim; record ile yaz ve geç.
Her primitive için tip açma. Money, Email, Isbn gibi kavramlar birden çok yerde geçiyor, karıştırılması pahalı ve kendi kuralları var. Customer.Name için CustomerName tipi çoğu projede hiçbir hatayı önlemez, sadece mapping yükü getirir. Ölçü basit: bu değeri yanlış yere vermek ya da yanlış biçimde kurmak bir bug üretmiş mi, üretebilir mi?
Anti-symmetry kararını da tek seferlik verme. Kargo tiplerini bugün enum ve switch ile başlatmak gayet makul. Her yeni seçenek beş farklı switch'i açmayı gerektirmeye başladığında, o an object'e geçmenin zamanıdır.
Küçük Bir Deneme
★☆☆ Legacy ProcessData'nın int t ve string strCoupon parametrelerini ShippingMethod enum'ı ve bir CouponCode value object'iyle değiştir. CouponCode büyük/küçük harf farkını kendisi normalize etsin ve "WELCOME10" ile "welcome10" eşit olsun.
★★☆ Money'yi sealed record class'a çevir ve iki sürümü karşılaştır: default tuzağı ne oluyor, Order.Total hesaplamasındaki allocation'lar değişiyor mu? BenchmarkDotNet ile 1.000 satırlık bir sipariş toplamını ölç.
★★★ ShippingMethod enum'ını IShippingOption hiyerarşisine taşı ama legacy'deki "İstanbul'a 10 TRY indirim" kuralını kaybetme. Kural kargo seçeneğine mi, müşteriye mi, ayrı bir kampanya object'ine mi ait? Seçimini Law of Demeter açısından gerekçelendir.
Bu yazıda verinin hangi tiple taşınacağına karar verdik. Bir sonraki yazı aynı OrderService'in en gürültülü kısmına bakıyor: -1'den -99'a giden return code'lar, static LastError field'ı ve hiçbir şey yapmayan catch blokları. Exception, Try pattern ve Result pattern'i yan yana koyup hangi hatanın hangisini hak ettiğini konuşacağız.