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

Entity ve Value Object: Identity, Equality ve C# record ile Immutable Modeller

Mert Özen 7 Kas 2026 14 dk 20 görüntülenme
Entity ve Value Object: Identity, Equality ve C# record ile Immutable Modeller

Entity'yi zaman içinde süren kimliği, value object'i ise yalnızca değeri tanımlar: iki Money aynı tutar ve para birimindeyse aynıdır, iki Order ise Id'leri aynıysa. C# record ve readonly record struct ile Money, Isbn ve strongly typed id'ler yazıyor, EF Core 10'un owned type yerine önerdiği complex type'larla map'liyor ve record'daki collection, struct default'u ve with tuzaklarını gerçek çıktılarla gösteriyoruz.

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

Önce Tahmin Et

Bookstore'da kampanya ekibi kitap paketleri tanımlamak istiyor. Developer'lardan biri paketi, değer eşitliği "bedava" geldiği için bir record olarak yazıyor:

public sealed record Bundle(string Name, List<BookId> Books);

Bundle a = new("Classics", [dune, emma]);
Bundle b = new("Classics", [dune, emma]);
Console.WriteLine(a == b);

Aynı ad, aynı iki kitap, aynı sıra. Ekranda True mu görürsün, False mu? İkinci soru: Money bir readonly record struct ve constructor'ı para birimini doğruluyor. Money empty = default; satırından sonra empty.Currency ne olur?

Entity: Zaman İçinde Süren Bir Kimlik

Eric Evans'ın DDD (Domain-Driven Design) Reference'ındaki tanımı kendi cümlelerimle söylersem: bazı object'ler attribute'larıyla değil, zaman içinde süren bir kimlikle tanımlanır. Ada e-posta adresini değiştirir, taşınır, soyadını değiştirir; yine aynı müşteridir. Bir sipariş Placed'den Shipped'e geçer, satırlarından biri iptal edilir; yine aynı sipariştir. Bu object'ler entity'dir ve onları ayırt eden şey bir identity'dir: CustomerId, OrderId.

.NET'in kendisinde de bu ayrımı görürsün. System.Diagnostics.Process bir entity gibi davranır: WorkingSet64 ya da Threads sürekli değişir, ama süreç Id'siyle aynı süreçtir. Buna karşılık DateOnly, TimeSpan, decimal ya da Guid value'dur: new DateOnly(2026, 11, 8) ile başka bir yerde yaratılan new DateOnly(2026, 11, 8) arasında hiçbir fark yoktur, biri diğerinin yerine geçer ve hiçbiri değişmez; AddDays yeni bir değer döndürür.

Entity'nin C#'taki karşılığı genellikle bir class ve bir Id property'si. Burada çoğu ekibin ilk refleksi, bir Entity<TId> base class'ı yazıp Equals'ı Id üzerinden override etmek. Buna çoğu zaman gerek yok: Entity Framework Core (EF Core) bir DbContext içinde aynı satır için tek bir instance tutar (identity resolution), domain kodu da iki siparişi karşılaştırırken a.Id == b.Id yazabilir. Id'si henüz atanmamış iki yeni entity'nin "eşit" çıkması gibi sürprizler base class'ın Equals'ından gelir.

Value Object: Değeri Neyse Odur

Value object ise yalnızca değeriyle anlamlıdır. Martin Fowler'ın ValueObject yazısındaki ölçüt basit: iki object'i kimlikleriyle değil, içerikleriyle karşılaştırıyorsan o bir value object'tir. Bookstore'da para, adres, ISBN (International Standard Book Number) böyle. 12,50 EUR ile başka bir yerde yaratılan 12,50 EUR aynı şeydir. Value object'in iki kuralı daha var: immutable olur ve kendi kurallarını kendisi korur. Bookstore'un Money'si, serinin context map'inde Ordering ile Payments arasındaki Shared Kernel'in tek tipi:

using System.Globalization;

namespace Bookstore.SharedKernel;

public readonly record struct Money
{
    public Money(decimal amount, string currency)
    {
        ArgumentOutOfRangeException.ThrowIfNegative(amount);
        if (currency is not ("EUR" or "TRY"))
            throw new ArgumentException($"Unsupported currency '{currency}'.", nameof(currency));

        Amount = decimal.Round(amount, 2, MidpointRounding.ToEven);
        Currency = currency;
    }

    public decimal Amount { get; }
    public string Currency { get; }

    public static Money Zero(string currency) => new(0m, currency);

    public static Money operator +(Money left, Money right)
    {
        EnsureSameCurrency(left, right);
        return new Money(left.Amount + right.Amount, left.Currency);
    }

    public static Money operator *(Money price, int quantity) => new(price.Amount * quantity, price.Currency);

    private static void EnsureSameCurrency(Money left, Money right)
    {
        if (left.Currency != right.Currency)
            throw new InvalidOperationException($"Cannot combine {left.Currency} and {right.Currency}.");
    }

    public override string ToString() => $"{Amount.ToString("0.00", CultureInfo.InvariantCulture)} {Currency}";
}

Para birimi kontrolü constructor'da, toplama ve çarpma operator'lerde. Money'yi kullanan hiçbir kod "bu iki tutar aynı para biriminde mi?" diye sormak zorunda değil; soran kod zaten derlenmiş tek bir yerde. Deneyelim:

using Bookstore.Catalog;
using Bookstore.Ordering;
using Bookstore.SharedKernel;

Money price = new(12.50m, "EUR");
Money same = new(12.5m, "EUR");
Console.WriteLine($"{price == same} {price.Equals(same)} {price}");
Console.WriteLine(price * 3 + new Money(4.99m, "EUR"));

try { Console.WriteLine(price + new Money(450m, "TRY")); }
catch (InvalidOperationException ex) { Console.WriteLine(ex.Message); }

Money empty = default;
Console.WriteLine($"[{empty.Amount}] [{empty.Currency ?? "null"}]");

Isbn dune = Isbn.Parse("978-0-441-17271-9");
Console.WriteLine($"{dune} {dune == Isbn.Parse("9780441172719")} {Isbn.TryParse("978-0-441-17271-8", out _)}");

OrderId orderId = OrderId.New();
Console.WriteLine(orderId);
True True 12.50 EUR
42.49 EUR
Cannot combine EUR and TRY.
[0] [null]
9780441172719 True False
OrderId { Value = 01a1b804-3171-7a3b-834f-207362fec040 }

12.50 ile 12.5 eşit, çünkü record struct'ın ürettiği Equals her field'ı kendi tipinin eşitliğiyle karşılaştırıyor ve decimal ölçeği umursamıyor. EUR ile TRY (Türk lirası) toplanmıyor. default satırı ise açılıştaki ikinci sorunun cevabı: Currency null. Struct'ın default değeri hiçbir constructor çalıştırmadan, tüm field'ları sıfırlayarak oluşur ve "EUR ya da TRY olmalı" kuralı tamamen atlanır.

record, record struct ya da class?

C# value object yazmak için üç araç veriyor. Record'ların ürettiği eşitliği ve with expression'ını C# serisinin "C# record ve record struct: Value Equality ve with Expression'ı" yazısında, struct'ların kopyalama semantiğini "C#'ta struct ve class Farkı: Kopyalama Semantiği ve readonly struct" yazısında ayrıntılı işledik. DDD açısından seçim şu kriterlere dayanıyor:

SeçenekArtısıBedeli
sealed recordDeğer eşitliği, ToString, with hazır; null olabilir ama default sürprizi yokHer değer bir heap allocation; with init property'lerdeki doğrulamayı atlayabilir
readonly record structAllocation yok, null olamaz, EF Core 10'da complex type olabilirdefault constructor'ı atlar; EF Core complex collection'larında desteklenmez
class + IEquatable<T>Eşitliğin tam kontrolü (ör. collection içeren value object)Equals, GetHashCode, operator'leri elle yazmak ve test etmek

Pratik kuralım: küçük, sık yaratılan ve her zaman bir değeri olan tipler (Money, strongly typed id'ler) readonly record struct; içinde string ya da daha karmaşık doğrulama olan tipler (Isbn, Address) sealed record. default riskini struct seçtiğin her yerde bilerek alıyorsun; Money asla tek başına bir API (Application Programming Interface) sınırından gelmediği, hep bir factory ya da operator'den çıktığı için bu riski kabul ediyoruz.

Isbn: Primitive Obsession'dan Kurtulmak

Bir ISBN'i string olarak taşımak, her method'un "bu gerçekten bir ISBN mi?" sorusunu kendisinin sorması demek. Primitive obsession'ı, DTO (Data Transfer Object) ile domain object'i ayırırken Clean Code serisinin "Object mi Data Structure mı? Record, DTO ve Primitive Obsession" yazısında genel olarak konuşmuştuk; DDD tarafında bunun adı "kavramı modelde görünür kılmak". ISBN-13'ün bir checksum kuralı var ve bu kural tek bir yerde yaşamalı:

using System.Diagnostics.CodeAnalysis;

namespace Bookstore.Catalog;

public sealed record Isbn
{
    private Isbn(string value) => Value = value;

    public string Value { get; }

    public static Isbn Parse(string text) =>
        TryParse(text, out Isbn? isbn) ? isbn : throw new FormatException($"'{text}' is not a valid ISBN-13.");

    public static bool TryParse(string? text, [NotNullWhen(true)] out Isbn? isbn)
    {
        isbn = null;
        string digits = new((text ?? "").Where(char.IsAsciiDigit).ToArray());
        if (digits.Length != 13 || !(digits.StartsWith("978") || digits.StartsWith("979")))
            return false;

        int sum = 0;
        for (int i = 0; i < 12; i++)
            sum += (digits[i] - '0') * (i % 2 == 0 ? 1 : 3);
        if ((10 - sum % 10) % 10 != digits[12] - '0')
            return false;

        isbn = new Isbn(digits);
        return true;
    }

    public override string ToString() => Value;
}

Constructor private; bir Isbn elde etmenin tek yolu Parse ya da TryParse'tan geçmek. Böylece tire ile yazılmış "978-0-441-17271-9" ile düz "9780441172719" aynı değere normalize oluyor, yukarıdaki çıktıda gördüğün gibi eşit çıkıyor ve son hanesi yanlış olan ISBN reddediliyor. Pattern'in kendisi BCL'den (Base Class Library) tanıdık: Guid.Parse/Guid.TryParse ve IPAddress.Parse/IPAddress.TryParse aynı ikiliyi sunar.

Strongly Typed Id'ler

Entity'lerin kimlikleri de primitive obsession'ın en sık görüldüğü yer. Guid orderId ile Guid bookId derleyici için aynı tip; method'a yanlış Id'yi vermek runtime'da "sipariş bulunamadı" olarak ortaya çıkar. Glossary'deki id'leri tek satırlık record struct'lar olarak yazıyoruz:

namespace Bookstore.Ordering;

public readonly record struct OrderId(Guid Value)
{
    public static OrderId New() => new(Guid.CreateVersion7());
}

public readonly record struct BookId(Guid Value)
{
    public static BookId New() => new(Guid.CreateVersion7());
}
using Bookstore.Ordering;

BookId bookId = BookId.New();
CancelOrder(bookId);

static void CancelOrder(OrderId id) => Console.WriteLine($"Cancelling {id}");
Program.cs(4,13): error CS1503: Argument 1: cannot convert from 'Bookstore.Ordering.BookId' to 'Bookstore.Ordering.OrderId'

Hata artık derleme zamanında. Guid.CreateVersion7() .NET 9'dan beri var ve zamana göre sıralı bir UUID (Universally Unique Identifier) üretiyor; veritabanı index'inde rastgele Guid'e göre çok daha az sayfa bölünmesine yol açar. Strongly typed id'lerin EF Core tarafındaki value converter mekaniğini EF Core serisinin "EF Core'da Owned Type ve Value Converter: Adres, Enum ve Strongly Typed Id" yazısında ayrıntılı anlattık; burada yalnızca kullanacağız.

EF Core 10'da Value Object: Complex Type

Value object'i veritabanına yazarken EF Core'da iki seçenek var: owned entity type ve complex type. Owned type'ı EF Core serisinin 20. yazısında, complex type'ın EF Core 8'deki ilk halini "EF Core 8 Yenilikleri: Complex Type, Primitive Collection ve SqlQuery<T>" yazısında, EF Core 10'daki JSON (JavaScript Object Notation) ve collection desteğini de "EF Core 10 Yenilikleri: Named Query Filter, LeftJoin ve JSON'da Complex Type" yazısında gördük. DDD açısından önemli olan şu: owned type arka planda gizli bir key'i olan bir entity'dir, complex type ise gerçekten kimliksizdir. Microsoft'un EF Core 10 dokümanı da artık table splitting ve JSON için owned type kullananlara complex type'a geçmeyi öneriyor. Yani value object'in DDD'deki tanımı ile EF'teki karşılığı ilk kez birebir örtüşüyor.

07'nin küçük Order'ı yalnızca value object property'lerine odaklanıyor; aggregate olarak 09 ve 10'da büyüyecek:

using Bookstore.SharedKernel;

namespace Bookstore.Ordering;

public readonly record struct CustomerId(Guid Value)
{
    public static CustomerId New() => new(Guid.CreateVersion7());
}

public sealed record Address(string Street, string City, string PostalCode, string Country);

public sealed class Order
{
    private Order() { }

    public Order(CustomerId customerId, Address shippingAddress, Money total)
    {
        Id = OrderId.New();
        CustomerId = customerId;
        ShippingAddress = shippingAddress;
        Total = total;
    }

    public OrderId Id { get; private set; }
    public CustomerId CustomerId { get; private set; }
    public Address ShippingAddress { get; private set; } = null!;
    public Address? BillingAddress { get; private set; }
    public Money Total { get; private set; }

    public void ChangeShippingAddress(Address address) => ShippingAddress = address;
    public void UseShippingAddressForBilling() => BillingAddress = ShippingAddress;
}
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace Bookstore.Ordering.Infrastructure;

public sealed class OrderingDbContext(bool log = false) : DbContext
{
    public DbSet<Order> Orders => Set<Order>();

    protected override void OnConfiguring(DbContextOptionsBuilder options)
    {
        options.UseSqlite("Data Source=orders07.db");
        if (log) options.LogTo(Console.WriteLine, [DbLoggerCategory.Database.Command.Name], LogLevel.Information);
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Order>(order =>
        {
            order.HasKey(o => o.Id);
            order.Property(o => o.Id).HasConversion(id => id.Value, value => new OrderId(value));
            order.Property(o => o.CustomerId).HasConversion(id => id.Value, value => new CustomerId(value));
            order.ComplexProperty(o => o.ShippingAddress);
            order.ComplexProperty(o => o.BillingAddress);
            order.ComplexProperty(o => o.Total, money =>
            {
                money.Property(m => m.Amount).HasColumnName("TotalAmount").HasPrecision(18, 2);
                money.Property(m => m.Currency).HasColumnName("TotalCurrency").HasMaxLength(3);
            });
        });
    }
}

Üç şeye dikkat. BillingAddress nullable ve EF Core 10 bunu optional complex type olarak map'liyor; bu, en az bir zorunlu (non-nullable) property'si olan complex type'larda çalışıyor. Total bir struct ve kolon adlarını kendimiz veriyoruz. Id'ler converter ile Guid'e iniyor. Programı çalıştırdığımda üretilen tablo ve sonrasındaki sorgular:

using Bookstore.Ordering;
using Bookstore.Ordering.Infrastructure;
using Bookstore.SharedKernel;
using Microsoft.EntityFrameworkCore;

File.Delete("orders07.db");
await using (OrderingDbContext db = new())
{
    await db.Database.EnsureCreatedAsync();
    Console.WriteLine(db.Database.GenerateCreateScript());
}

OrderId id;
await using (OrderingDbContext db = new())
{
    Address home = new("1 Baker St", "London", "NW1 6XE", "GB");
    Order order = new(CustomerId.New(), home, new Money(42.49m, "EUR"));
    order.UseShippingAddressForBilling();
    db.Orders.Add(order);
    await db.SaveChangesAsync();
    id = order.Id;
}

await using (OrderingDbContext db = new(log: true))
{
    Order order = await db.Orders.SingleAsync(o => o.Id == id);
    Console.WriteLine($"{order.Total} | {order.ShippingAddress == order.BillingAddress}");
    order.ChangeShippingAddress(order.ShippingAddress with { Street = "221B Baker St" });
    await db.SaveChangesAsync();
}

await using (OrderingDbContext db = new())
{
    int sameAddress = await db.Orders.CountAsync(o => o.ShippingAddress == o.BillingAddress);
    Money total = new(42.49m, "EUR");
    int byTotal = await db.Orders.CountAsync(o => o.Total == total);
    Console.WriteLine($"{sameAddress} {byTotal}");
}
CREATE TABLE "Orders" (
    "Id" TEXT NOT NULL CONSTRAINT "PK_Orders" PRIMARY KEY,
    "CustomerId" TEXT NOT NULL,
    "BillingAddress_City" TEXT NULL,
    "BillingAddress_Country" TEXT NULL,
    "BillingAddress_PostalCode" TEXT NULL,
    "BillingAddress_Street" TEXT NULL,
    "ShippingAddress_City" TEXT NOT NULL,
    "ShippingAddress_Country" TEXT NOT NULL,
    "ShippingAddress_PostalCode" TEXT NOT NULL,
    "ShippingAddress_Street" TEXT NOT NULL,
    "TotalAmount" TEXT NOT NULL,
    "TotalCurrency" TEXT NOT NULL
);

42.49 EUR | True

UPDATE "Orders" SET "ShippingAddress_Street" = @p0
WHERE "Id" = @p1
RETURNING 1;

0 1

Aynı Address instance'ını iki property'ye vermek sorunsuz çalıştı; owned type'la bu senaryo exception fırlatıyordu. Adres değişince with ile yeni bir değer yarattık ve EF yalnızca değişen kolonu güncelledi. Son satırda iki LINQ (Language Integrated Query) sorgusu complex value'ları bütün olarak karşılaştırdı: adresler artık farklı olduğu için 0, toplamı 42.49 EUR olan sipariş 1.

Sık Düşülen Tuzaklar

Record'da collection. Açılıştaki sorunun cevabı False. Record'un ürettiği Equals List<BookId>'i referansıyla karşılaştırıyor; aynı elemanlara sahip iki farklı liste eşit değil. Eşitliği kendin yazman gerekir:

using Bookstore.Ordering;

BookId dune = BookId.New();
BookId emma = BookId.New();

Bundle a = new("Classics", [dune, emma]);
Bundle b = new("Classics", [dune, emma]);
Console.WriteLine(a == b);

SeriesBundle c = new("Classics", [dune, emma]);
SeriesBundle d = new("Classics", [dune, emma]);
Console.WriteLine(c == d);

public sealed record Bundle(string Name, List<BookId> Books);

public sealed record SeriesBundle(string Name, IReadOnlyList<BookId> Books)
{
    public bool Equals(SeriesBundle? other) =>
        other is not null && Name == other.Name && Books.SequenceEqual(other.Books);

    public override int GetHashCode()
    {
        HashCode hash = new();
        hash.Add(Name);
        foreach (BookId id in Books) hash.Add(id);
        return hash.ToHashCode();
    }
}
False
True

Struct'ın default'u. Yukarıda gördük: Money empty = default; doğrulamasız bir değer üretir. Struct value object'leri bir DTO property'si ya da parametresiz bir deserializer'ın hedefi yapma.

with doğrulamayı atlayabilir. Positional bir record'da doğrulamayı property initializer'a koymak çok yaygın, ama with primary constructor'ı çağırmaz; object'i kopyalar ve yalnızca değişen property'nin init accessor'ını çalıştırır:

Address home = new("1 Baker St", "London", "NW1 6XE", "GB");
Address broken = home with { PostalCode = "" };
Console.WriteLine($"[{broken.PostalCode}]");

try { _ = home with { City = " " }; }
catch (ArgumentException ex) { Console.WriteLine(ex.Message); }

public sealed record Address(string Street, string City, string PostalCode, string Country)
{
    public string PostalCode { get; init; } = string.IsNullOrWhiteSpace(PostalCode)
        ? throw new ArgumentException("Postal code is required.", nameof(PostalCode))
        : PostalCode;

    public string City
    {
        get;
        init => field = string.IsNullOrWhiteSpace(value)
            ? throw new ArgumentException("City is required.", nameof(City))
            : value;
    } = City;
}
[]
City is required. (Parameter 'City')

PostalCode'un kuralı yalnızca constructor yolunda çalıştı, with boş posta kodunu geçirdi. City'de doğrulama C# 14'ün field keyword'üyle init accessor'ın içinde ve with da ondan geçmek zorunda. field'ı C# serisinin "C# 14 Yenilikleri: Extension Members, field ve Null-Conditional Atama" yazısında anlattık.

Setter'ı olmayan struct ve EF Core. Money'nin property'leri yalnızca getter'a sahip ve EF Core bunları convention ile map'lemiyor. order.ComplexProperty(o => o.Total); satırını kolon ayarları olmadan bıraktığımda model oluşurken şu hatayı aldım:

System.InvalidOperationException: No suitable constructor was found for the type 'Order.Total#Money'. The following constructors had parameters that could not be bound to properties of the type: 
    Cannot bind 'amount', 'currency' in 'Order.Total#Money(decimal amount, string currency)'
Note that only mapped properties can be bound to constructor parameters. Navigations to related entities, including references to owned types, cannot be bound.

Çözüm, yukarıdaki gibi Property(m => m.Amount) ve Property(m => m.Currency)'yi açıkça yazmak. EF o zaman constructor'ı kullanıyor; bu da veritabanından okunan her değerin yine doğrulamadan geçmesi demek. Bunun iyi ve kötü yanını 10. yazıda göreceğiz.

Struct collection. Bir kampanyanın eşik tutarlarını List<Money> olarak JSON'a yazmayı denedim:

modelBuilder.Entity<Promotion>().ComplexCollection(p => p.Thresholds, money =>
{
    money.ToJson();
    money.Property(m => m.Amount);
    money.Property(m => m.Currency);
});
InvalidOperationException: The complex type collection 'Promotion.Thresholds' cannot be configured because complex value type collections are not supported. See https://github.com/dotnet/efcore/issues/31411 for more information.

EF Core 10'da complex collection'ların elemanları reference type olmak zorunda. Collection içinde yaşayacak value object'leri sealed record olarak yaz.

Ne Zaman Kullanma

Her string'i value object'e çevirmek, kodu bir tip müzesine dönüştürür. Bir kavramın kuralı yoksa (ör. sipariş notu, kitabın alt başlığı) string yeterli. Strongly typed id'lerin de bir bedeli var: model binding, JSON serialization, Swagger şemaları ve EF converter'ları için ek ayar. Serinin 03. yazısındaki ayrımla, supporting ve generic subdomain'lerde ya da salt okuma yapan raporlama kodunda bu bedel çoğu zaman karşılığını vermez; kuralların yoğun olduğu Ordering gibi core bir context'te ise ilk haftada kendini öder.

Küçük Bir Deneme

★☆☆ Money'ye operator - ekle. Sonuç negatif çıkarsa ne olmalı: exception mı, negatif tutarlara izin veren ayrı bir tip mi (ör. iade için)? Kararını glossary'ye bir satır olarak yaz.

★★☆ Address'i City'deki gibi field ile doğrulayan bir init accessor'a çevir ve with { PostalCode = "" } denemesinin artık exception fırlattığını gör. Aynı sınıfı EF Core'un complex type olarak okuyabildiğini de test et.

★★★ SeriesBundle'ı EF Core 10'da bir entity'nin içinde complex collection olarak map'le. IReadOnlyList<BookId> property'si ve strongly typed id'nin converter'ı JSON içinde nasıl davranıyor?

Elimizde artık kendi kurallarını koruyan küçük parçalar var. Ama tek tek doğru olan Money'ler ve OrderItem'lar, birlikte yanlış bir sipariş oluşturabilir: satırı olmayan, iki para birimini karıştıran ya da iptal edildikten sonra değişen bir sipariş. Bir sonraki yazıda always-valid domain model'e geçiyoruz: invariant'ları constructor, factory ve guard clause'larla korumak.