İçeriğe geç

Value Object ve Record — Anlamı Tipe Taşımak

Orta 10 dk Sık karşılaşılır

Önce şunu oku: Zengin Domain Modeli — JPA Entity'de Kapsülleme

30 saniyede özet

Bir ilaç kutusunda yalnızca '5' yazması tehlikelidir: 5 mg mı, 5 tablet mi? long ve BigDecimal de böyle anlamsızdır. Değeri ve kuralını kendi tipine (Money, Quantity) taşıdığında karışıklık derleme hatasına döner.

Bir sayı tek başına pek bir şey anlatmaz; yanında ne olduğu yazmıyorsa herkes onu başka türlü okur.

Birimi olmayan sayı, sırası karışabilen parametredir.
Adım adım oku
  1. İlaç kutusunda yalnızca 5 yazıyor: 5 miligram mı, 5 tablet mi, belli değil.
  2. transfer metodu üç long alıyor. Tutar ile hesap numaralarının yeri karışsa da kod derlenir ve para yanlış yere gider.
  3. Parametreler AccountId ve Money gibi kendi tiplerine taşınınca her değer ne olduğunu yanında taşır.
  4. Aynı karışıklık artık derleme hatasıdır: Money, AccountId'nin yerine geçemez.
  1. Bayt: Para transferi metodum üç long alıyor: gönderen, alan, tutar.

  2. Sen: Çağırırken sırayı karıştırırsam ne olur?

  3. Bayt: Kod derlenir ve para yanlış hesaba gider! Derleyici üç long'u birbirinden ayıramaz.

  4. Bayt: Sayının yanına ne olduğunu yazınca karışıklık derleme hatasına döner.

Önceki derste kuralları entity’ye taşıdık ve iki çağrı yine de geçti: farklı para birimi ve yer değiştirmiş argümanlar. İkisinin de sebebi aynı: int ve BigDecimal ne olduklarını bilmiyorlar.

Her şey long olunca

Şu imza, Spring Boot servislerinde sık görülür:

TransferService.java
public void transfer(long fromAccountId, long toAccountId, BigDecimal amount) { … }
transfer(toId, fromId, amount); // derlenir. Para ters yöne gider.

Bu duruma primitive obsessionAnlamı ve kuralı olan bir değeri (para, e-posta, hesap numarası) çıplak bir ilkel tiple (long, String, BigDecimal) taşımak. Derleyici iki long'u birbirinden ayıramaz.Sözlükte gör → denir: anlamı olan bir değeri çıplak bir ilkel tiple taşımak. Derleyici “iki long” görür, “gönderen ve alıcı” görmez.

BigDecimal da aynı sorunu taşır. 15’in lira mı dolar mı olduğunu bilmez, bu yüzden 15 TRY + 5 USD = 20 hesabına hiçbir itirazı olmaz.

Kafam karıştı, daha basit anlat

Her şey long olunca hesap numarasıyla müşteri numarasını karıştırmak çok kolaydır, derleyici de fark etmez. Her birine kendi tipini verirsen karışıklık derlemede yakalanır.

Hızlı kontrolBaşlangıç

transfer(long fromAccountId, long toAccountId, BigDecimal amount) imzasının asıl riski nedir?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Satır satır: record ile Money

Kimliği olmayan, değeriyle eşit sayılan ve değiştirilemeyen küçük nesneye value objectKimliği olmayan, değeriyle eşit sayılan ve değiştirilemeyen küçük nesne: Money, Email, Quantity. Kendi geçerlilik kuralını kurucusunda taşır.Sözlükte gör → denir. Java 21’de bunun doğal kalıbı bir record’dur. Kural ise compact constructorJava record'larında parametre listesi yazılmadan tanımlanan kurucu. Alanlar atanmadan önce çalışır; doğrulama ve normalleştirme için kullanılır.Sözlükte gör →’a yazılır.

`Money.tl(100).equals(new Money(new BigDecimal("100.0"), TRY))` true mu döner, false mu? Cevabı göster

true döner — ama yalnızca compact constructor ölçeği sabitlediği için. BigDecimal.equals değerle birlikte ölçeği de karşılaştırır: 100.0 ile 100.00 ona göre farklıdır. Normalleştirme olmasaydı aynı tutardaki iki Money eşit sayılmazdı.

Kural değerin içinde

Money.java
1public record Money(BigDecimal amount, Currency currency) {
2
3 public Money {
4 Objects.requireNonNull(currency, "para birimi zorunlu");
5 if (amount.signum() < 0) throw new IllegalArgumentException("negatif tutar");
6 amount = amount.setScale(2, RoundingMode.HALF_EVEN);
7 }
8
şu an çalışan satır public static Money tl(long value) {
10 return new Money(BigDecimal.valueOf(value), Currency.getInstance("TRY"));
11 }
12
13 public Money plus(Money other) {
14 if (!currency.equals(other.currency)) throw new IllegalArgumentException("para birimi uyuşmuyor");
15 return new Money(amount.add(other.amount), currency);
16 }
17}

Debug

Adım 1/7

main Money.tl(100) çağrılıyor. Fabrika metodu hem tutarı hem para birimini veriyor.

Java 21UTF-8LF9:1

Sol/sağ ok tuşlarıyla da gezebilirsin.

Record sana equals, hashCode, toString ve değişmez alanları bedava verir. Kuralı vermez. plus içindeki kontrolü yazmazsan, Money sadece iki alanlı bir kılıftır.

Kafam karıştı, daha basit anlat

Value object, iki 5 liralık banknot gibidir: hangisinin elinde olduğu önemli değildir, değerleri aynıysa eşittirler. Üzerlerine yazı yazılıp değiştirilemezler.

Hızlı kontrolOrta

record Money(BigDecimal amount, Currency currency) içindeki compact constructor ne işe yarar?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Bu kod ne yazdırır?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.
Scale.java
1var a = new BigDecimal("2.0");
2var b = new BigDecimal("2.00");
3System.out.println(a.equals(b) + " " + (a.compareTo(b) == 0));
Java 21UTF-8LF

Spring Boot’ta saklamak ve taşımak

Value object’in kimliği yok, bu yüzden kendi tablosu da yok. Sahibi olan entity’nin kolonlarında yaşar:

DurumAraçÖrnek
Çok alanlı@Embeddable + @EmbeddedMoney → price_amount, price_currency
Tek alanlıAttributeConverter<Email, String>Email → email kolonu
İstek / cevap gövdesirecord DTO + Bean Validationrecord PlaceOrder(@NotNull Long productId, @Positive int quantity)

Hibernate 6.2 ve sonrası (Spring Boot 3.1+) record’ları doğrudan @Embeddable olarak kabul eder. AttributeConverter ise kolondan okurken new Email(...) çağırır; yani veritabanından gelen değer de aynı doğrulamadan geçer.

Controller’da entity yerine record DTO almanın sebebi aynı fikrin öbür yüzü: istemci yalnızca tanımladığın alanları gönderebilir. total ya da status gibi alanları dışarıdan yazmak mümkün olmaz.

Üçünü bir arada görmek için küçük bir havale ekranı düşünelim. IBAN tek alanlı, tutar ise iki alanlı bir value object; ikisi de tabloya kendi kurallarıyla girip çıkıyor.

Derinleş · Havale: IBAN ve Money tabloda ve istekte 4 dosya · ~79 satır · ilk okumada atlayabilirsin
Proje dosyaları

src/main/java/com/bank/transfer/ Iban.java IBAN tek alanlı bir value object; geçersiz bir IBAN hiç oluşmaz.

src/main/java/com/bank/transfer/Iban.java
public record Iban(String value) {
public Iban {
Objects.requireNonNull(value, "IBAN is required");
value = value.replace(" ", "").toUpperCase(Locale.ROOT);
if (!value.matches("[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}") || !hasValidCheckDigits(value)) {
throw new IllegalArgumentException("invalid IBAN");
}
}
// ISO 13616 mod-97 check: move the first four characters to the end, A=10 ... Z=35.
private static boolean hasValidCheckDigits(String iban) {
String rearranged = iban.substring(4) + iban.substring(0, 4);
int remainder = 0;
for (char c : rearranged.toCharArray()) {
int n = Character.getNumericValue(c);
remainder = (remainder * (n < 10 ? 10 : 100) + n) % 97;
}
return remainder == 1;
}
}

src/main/java/com/bank/transfer/ IbanConverter.java Converter, IBAN'ı tek kolona yazar ve okurken yine kurucudan geçirir.

src/main/java/com/bank/transfer/IbanConverter.java
// autoApply: every Iban field in every entity is stored through this converter.
@Converter(autoApply = true)
class IbanConverter implements AttributeConverter<Iban, String> {
@Override
public String convertToDatabaseColumn(Iban iban) {
return iban == null ? null : iban.value();
}
@Override
public Iban convertToEntityAttribute(String column) {
// A bad value in the table fails here, not three services later.
return column == null ? null : new Iban(column);
}
}

src/main/java/com/bank/transfer/ OutgoingTransfer.java Havale entity'si: Money iki kolona gömülür, IBAN'lar birer kolondur.

src/main/java/com/bank/transfer/OutgoingTransfer.java
@Entity
@Table(name = "outgoing_transfer")
public class OutgoingTransfer {
@Id
@GeneratedValue
private Long id;
private Iban debtorIban;
private Iban creditorIban;
// The Money record from this lesson, marked @Embeddable: two columns, no table of its own.
@Embedded
@AttributeOverride(name = "amount", column = @Column(name = "amount", precision = 19, scale = 2))
@AttributeOverride(name = "currency", column = @Column(name = "currency", length = 3))
private Money amount;
@Enumerated(EnumType.STRING)
private TransferStatus status;
protected OutgoingTransfer() {
// for JPA
}
public OutgoingTransfer(Iban debtorIban, Iban creditorIban, Money amount) {
this.debtorIban = debtorIban;
this.creditorIban = creditorIban;
this.amount = amount;
this.status = TransferStatus.PENDING;
}
}

src/main/java/com/bank/transfer/ TransferRequest.java İstek gövdesi bir record DTO; gönderen hesap ve durum dışarıdan gelmez.

src/main/java/com/bank/transfer/TransferRequest.java
// The body carries only what the customer may choose.
// The debtor account comes from the logged-in customer; status is set by the bank.
public record TransferRequest(
@NotBlank String creditorIban,
@NotNull @Positive BigDecimal amount,
@NotBlank @Pattern(regexp = "[A-Z]{3}") String currency) {
public OutgoingTransfer toTransfer(Iban debtorIban) {
Money money = new Money(amount, Currency.getInstance(currency));
return new OutgoingTransfer(debtorIban, new Iban(creditorIban), money);
}
}
Kafam karıştı, daha basit anlat

Value object’in kendi tablosu olmaz. Sahibinin satırında birkaç kolon olarak yaşar, okunurken yine kendi kurallarından geçer.

Hızlı kontrolOrta

Email value object'ini veritabanında tek bir VARCHAR kolonda saklamak istiyorsun. En uygun yol hangisi?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Controller'da @RequestBody olarak entity yerine record bir DTO almanın asıl kazancı nedir?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Kendin gör

Önceki dersin simülatörü, bu kez son iki modeli karşılaştırmak için.

Aynı çağrılar, üç model — kural nerede duruyor?

Tohum 449889

Anemik entity + servis çağrı 0/8

OrderController.java
1order.getLines().add(new OrderLine(book, 2, price));
2order.setTotal(order.getTotal().add(price.multiply(2)));
Java 21UTF-8LF
  1. Geçerli bir satır eklesırada
  2. Negatif miktarla satır eklesırada
  3. Satır listesine dışarıdan eklesırada
  4. TRY siparişine USD fiyatlı satır eklesırada
  5. Miktar ile fiyatı yer değiştirerek versırada
  6. Siparişi göndersırada
  7. Gönderilmiş siparişi iptal etsırada
  8. Toplamı elle sıfırlasırada

Siparişin kuralları

  • Her satırın miktarı sıfırdan büyük — geçerli
  • Toplam, satırların toplamına eşit — geçerli
  • Siparişte tek para birimi var — geçerli
  • Satır, çağıranın kastettiği değerlerle eklendi — geçerli
  • Gönderilmiş sipariş iptal edilemez — geçerli

Kötü çağrı nerede durdu?

derleme
0
kurucu
0
metot
0
sessizce geçti
0
Hız
Adım 0

Şu an ne oldu?

Anemik entity + servis — sekiz çağrı sırada

Aynı çağıran kod, sipariş üzerinde sekiz işlem deneyecek. Bazıları geçerli, bazıları hiç izin verilmemesi gereken işlemler.

Aklında kalsın: Bak: her kötü işlem nerede durduruluyor, yoksa hiç durdurulmuyor mu?

Görevler0/3

  • Anemik modelde üç farklı kuralı sessizce bozaçık

    İpucu

    Anemik modelle sonuna kadar oynat. Hiçbir işlem hata vermiyor — sorun da tam olarak bu.

  • Bir kuralın derleme zamanında yakalandığını göraçık

    İpucu

    Zengin modele geç. Setter’ı olmayan bir alanı atamaya çalışan kod derlenmez.

  • Sekiz çağrının hiçbiri kuralı bozmadan bitsinaçık

    İpucu

    Zengin entity tek başına yetmiyor: para birimi ve argüman sırası hâlâ geçiyor. Değerlerin kendisine de tip ver.

Olay günlüğü (0)

Henüz olay yok. Oynat veya adımla.

  1. Modeli Zengin entity yap ve sonuna kadar oynat. Kuralların beşinden ikisi hâlâ kırmızı: para birimi ve argüman sırası.
  2. Zengin entity + value object’e geç. Negatif miktar artık metotta değil, Quantity.of(-1) kurucusunda duruyor. Kural bir adım sola kaydı.
  3. Yer değiştirmiş argümanlara bak. Bu kez kod derlenmiyor: Money bir Quantity değil. En ucuz hata, hiç çalışmayan koddur.
  4. Sıfır bozulma görevini tamamla.
Hızlı kontrolOrta

Simülatörde "Zengin entity + value object" modelinde yer değiştirmiş miktar ve fiyat neden derlenmiyor?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Tuzaklar

BigDecimal.equals ve ölçek. new BigDecimal("2.0").equals(new BigDecimal("2.00")) false döner.

Record’un üretilen equals’ı da bunu kullanır. Ölçeği compact constructor’da sabitle ya da karşılaştırmada compareTo kullan.

Yüzeysel değişmezlik. record Basket(List<Item> items) değişmez görünür ama dışarıdan verilen liste hâlâ değiştirilebilir. Compact constructor’da items = List.copyOf(items); yaz.

Her şeyi sarmalamak. Kuralı ya da karıştırılma riski olmayan bir alan (serbest bir “not”) için value object yalnızca dönüştürme kodu ekler. Soru şu: bu değerin bir kuralı var mı, başka bir değerle karışabilir mi?

Value object’e kimlik vermek. Money’yi @Entity yapıp id vermek onu bir entity’ye çevirir. İki aynı tutar artık “farklı” nesneler olur.

Kendini sına

Şimşek turu1/5

İki long parametrenin yeri karışırsa derleyici hata verir.

Soru 1/3İleri

Value object ne zaman gereksiz karmaşa olur?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Aklında kalacak üç şey

  1. 1 Her şey long ve String olunca derleyici anlamı göremez: iki long'u yer değiştirmek derlenir. Money, Quantity, AccountId gibi tipler bu karışıklığı derleme hatasına çevirir.
  2. 2 Record bir value object için doğru kalıptır ama tek başına yetmez. Kural record'un kurucusunda ve plus gibi metotlarda yazılmalıdır.
  3. 3 Spring Boot'ta çok alanlı value object @Embeddable, tek alanlı olan AttributeConverter ile saklanır. İstek gövdesi entity değil, record bir DTO olur.
Sonraki kapı Bir olay dinleyicisi, kayıt veritabanına yazılmadan önce mi çalışır, sonra mı? Spring'in İçindeki Kalıplar — Strategy, Template Method, Proxy, Observer · 10 dk

5 kart sonraki derste seni bekliyor

0/5 kart bu dersten toplandı

Bu dersin üstüne kurulanlar

Bunlar bu dersi temel alıyor; hazır olduğunda devam edebilirsin.