İçeriğe geç

Zengin Domain Modeli — JPA Entity'de Kapsülleme

Orta 10 dk Çok sık karşılaşılır

Önce şunu oku: Nesne Yönelimli Tasarım — Kapsülleme ve Kompozisyon

30 saniyede özet

Her alanı setter'lı bir nesne ve bütün kuralları bilen bir servis, kuralı veriden ayırır; biri servisi atlarsa kural da atlanır. Kuralı nesnenin kendi metotlarına taşıdığında hangi kapıdan girilirse girilsin atlanamaz.

Bir kuralı yalnızca kapıdaki görevli biliyorsa, arka kapıdan giren onu hiç duymaz.

  1. Bayt: Bütün kuralları OrderService'e yazdım. Her şeyi tek yerden yönetmek harika!

  2. Sen: Peki gece çalışan toplu iş de o servisi mi kullanıyor?

  3. Bayt: Hayır, doğrudan entity'ye yazıyor! Eksi adetli sipariş satırları oluşmuş.

  4. Bayt: Demek ki kilit görevlide değil, kasanın kendisinde olmalı.

Spring Boot projelerinin çoğunda aynı ikili vardır: her alanı setter’lı bir @Entity ve bütün kuralları bilen bir OrderService. Çalışır — ta ki biri servisi atlayıp entity’ye doğrudan dokunana kadar.

Kural herkeste, sahibi kimsede

Tanıdık bir kod:

Order.java + OrderService.java
@Entity
@Getter @Setter
public class Order {
@Id @GeneratedValue private Long id;
@OneToMany(cascade = ALL) private List<OrderLine> lines = new ArrayList<>();
private BigDecimal total = BigDecimal.ZERO;
private Status status = Status.NEW;
}
@Service
public class OrderService {
public void addLine(Order order, Product p, int qty, BigDecimal price) {
if (qty <= 0) throw new IllegalArgumentException("miktar > 0 olmalı");
order.getLines().add(new OrderLine(p, qty, price));
order.setTotal(order.getTotal().add(price.multiply(BigDecimal.valueOf(qty))));
}
}

Buna anemik modelYalnızca alan ve getter/setter taşıyan, kuralları başka bir sınıfa (genellikle bir Service) bırakan domain nesnesi. Veri bir yerde, onu koruyan kural başka yerdedir.Sözlükte gör → denir: veri Order’da, onu koruyan kural OrderService’te. Kural ancak çağıran servisten geçerse çalışır.

Sorun şu: getLines() ve setTotal() herkese açık. Başka bir servis, bir batch işi ya da bir test aynı işi servisten geçmeden yapabilir. O zaman miktar kontrolü de toplam hesabı da atlanır.

Kafam karıştı, daha basit anlat

Kural beş ayrı serviste yazılıysa, biri mutlaka unutulur. Kuralın tek bir sahibi olmalı: koruduğu verinin kendisi.

Hızlı kontrolBaşlangıç

Setter'lı bir @Entity ve bütün kuralları içeren bir OrderService. Bu anemik modelin asıl sorunu nedir?

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

Satır satır: kuralı entity’ye taşımak

Aynı kural, bu kez koruduğu verinin yanında. Siparişin geçerli kalması için her zaman doğru olması gereken şeylere değişmez kuralNesne var olduğu sürece doğru kalması gereken şey. Kapsüllemenin ölçütü kaç alanın private olduğu değil, hangi kuralın dışarıdan bozulamadığıdır.Bir setter, koruduğu kural varsa onu atlanabilir kılar. Kuralı metodun içine koymak (withdraw gibi) nesneyi geçersiz duruma düşürmeyi imkânsız hâle getirir.Sözlükte gör → denir ve bu kurallar artık yalnızca Order’ın metotlarından geçerek değiştirilebilir.

Bir servis kontrolü unutup `order.addLine(pen, -1, price)` çağırırsa ne olur? Cevabı göster

addLine içindeki kontrol IllegalArgumentException atar ve sipariş olduğu gibi kalır. Kontrolü unutmak artık mümkün değil, çünkü kontrol çağıranda değil, kapının kendisinde.

Kapıdaki görevli mi, kasanın kilidi mi?
Adım adım oku
  1. Anemik modelde kural OrderService'te, yani kapıdaki görevlide durur. Controller'dan gelen istek kontrol edilir.
  2. Gece çalışan toplu iş ise servisi atlayıp setQuantity ile entity'ye doğrudan yazar: adet eksi bir olur.
  3. Zengin modelde kural Order'ın kendi addLine metodundadır: kilit kasanın üstündedir.
  4. Controller'dan da gelse, toplu işten de gelse geçersiz adet reddedilir.

Kural kapıda

Order.java
1@Entity
2public class Order {
3 @OneToMany(cascade = ALL, orphanRemoval = true)
4 private List<OrderLine> lines = new ArrayList<>();
5 private BigDecimal total = BigDecimal.ZERO;
6 private Status status = Status.NEW;
7
8 protected Order() {} // yalnızca JPA için
9
şu an çalışan satır public void addLine(Product product, int qty, BigDecimal price) {
11 if (qty <= 0) throw new IllegalArgumentException("miktar > 0 olmalı");
12 if (status != Status.NEW) throw new IllegalStateException("gönderilmiş sipariş değişmez");
13 lines.add(new OrderLine(product, qty, price));
14 total = total.add(price.multiply(BigDecimal.valueOf(qty)));
15 }
16
17 public List<OrderLine> lines() { return List.copyOf(lines); }
18}

Debug

Adım 1/8

OrderService Servis kural bilmiyor, isteği Order'a iletiyor: 2 adet kitap, 100 TL.

qty
= 2
price
= 100
Java 21UTF-8LF10:1

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

Dikkat: setTotal yok. Toplam hesaplanır, atanmaz — bu yüzden onu satırlardan koparacak bir kod yazmak derlenmez bile.

Kafam karıştı, daha basit anlat

Kontrolü kapının kendisine koy. addLine çağrıldığında sipariş kendi kuralını kontrol eder; kimse bu kontrolü atlayamaz.

Hızlı kontrolOrta

Miktar kontrolünü OrderService'ten Order.addLine içine taşıdın. Bu neyi garanti eder?

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

Zengin Order sınıfında lines() metodu neden List.copyOf(lines) döndürüyor?

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

JPA ile uyumlu bir zengin entity

Zengin model JPA ile çatışmaz, yalnızca birkaç kurala uyman gerekir:

JPA’nın istediğiZengin modeldeki karşılığı
Argümansız kurucuprotected Order() {} — Hibernate kullanır, uygulama kodu boş sipariş oluşturamaz
Alanlara erişimAlan erişimi (@Id alanın üstünde). Hibernate setter’a ihtiyaç duymaz
Koleksiyonu yönetmekİç liste private. Dışarıya List.copyOf(lines)
final olmayan sınıf ve alanlarSınıf final değil, değişmezlik metotlarla sağlanır

Nesneyi oluşturmanın anlamlı yolu da bir fabrika metodu olabilir: Order.placeBy(customerId). Böylece “müşterisi olmayan sipariş” diye bir ara durum hiç oluşmaz.

Sipariş ve satırları birlikte tutarlı kalması gereken bir küme. Bu kümeye aggregateBirlikte tutarlı kalması gereken ve tek bir kök nesne üzerinden değiştirilen nesne kümesi. Dışarıdan yalnızca köke erişilir; başka aggregate'lara id ile referans verilir.Sözlükte gör → denir: dışarıdan yalnızca köke (Order) erişilir, OrderLine’a doğrudan dokunulmaz.

Aynı fikir bir banka hesabında da çok doğal durur. Bakiye ve hesap hareketleri birlikte değişir, kilit de hesabın kendisindedir.

Derinleş · Banka hesabı: kilit hesabın kendisinde 4 dosya · ~110 satır · ilk okumada atlayabilirsin
Proje dosyaları

src/main/java/com/bank/account/ Account.java Hesap aggregate'ın kökü; bakiye ve hareket hep aynı metotta birlikte değişir.

src/main/java/com/bank/account/Account.java
@Entity
public class Account {
@Id @GeneratedValue private Long id;
private Long customerId; // other aggregates are referenced by id
private Iban iban;
@Embedded private Money balance;
@Enumerated(EnumType.STRING) private AccountStatus status;
@OneToMany(mappedBy = "account", cascade = CascadeType.ALL, orphanRemoval = true)
private List<AccountEntry> entries = new ArrayList<>();
protected Account() {
// for JPA only
}
public static Account open(Long customerId, Iban iban, Currency currency) {
Account account = new Account();
account.customerId = customerId;
account.iban = iban;
account.balance = new Money(BigDecimal.ZERO, currency);
account.status = AccountStatus.ACTIVE;
return account;
}
public void deposit(Money amount, String description) {
requireActive();
balance = balance.plus(amount);
entries.add(new AccountEntry(this, amount, EntryType.CREDIT, description));
}
public void withdraw(Money amount, String description) {
requireActive();
if (balance.isLessThan(amount)) throw new IllegalStateException("insufficient balance");
balance = balance.minus(amount);
entries.add(new AccountEntry(this, amount, EntryType.DEBIT, description));
}
public void freeze() {
status = AccountStatus.FROZEN;
}
public Money balance() { return balance; }
public List<AccountEntry> entries() { return List.copyOf(entries); }
private void requireActive() {
if (status != AccountStatus.ACTIVE) throw new IllegalStateException("account is " + status);
}
}

src/main/java/com/bank/account/ AccountEntry.java Hesap hareketi yalnızca hesabın içinden oluşturulur.

src/main/java/com/bank/account/AccountEntry.java
@Entity
public class AccountEntry {
@Id @GeneratedValue private Long id;
@ManyToOne(fetch = FetchType.LAZY) private Account account;
@Embedded private Money amount;
@Enumerated(EnumType.STRING) private EntryType type;
private String description;
protected AccountEntry() {
// for JPA only
}
// Package-private: only Account, in the same package, creates entries.
AccountEntry(Account account, Money amount, EntryType type, String description) {
this.account = account;
this.amount = amount;
this.type = type;
this.description = description;
}
}

src/main/java/com/bank/account/ WithdrawalService.java Servis kural bilmez; transaction açar, fraud kontrolünü çağırır, işi hesaba bırakır.

src/main/java/com/bank/account/WithdrawalService.java
@Service
public class WithdrawalService {
private final AccountRepository accounts;
private final FraudCheck fraudCheck;
WithdrawalService(AccountRepository accounts, FraudCheck fraudCheck) {
this.accounts = accounts;
this.fraudCheck = fraudCheck;
}
// The service opens the transaction and talks to other systems.
// The balance rule is not here: it lives in Account.withdraw.
@Transactional
public AtmDecision withdrawAtAtm(Iban iban, Money amount, AtmId atm) {
Account account = accounts.findByIban(iban).orElseThrow();
if (fraudCheck.isSuspicious(iban, amount, atm)) {
account.freeze(); // committed with the transaction; the ATM declines
return AtmDecision.DECLINED;
}
account.withdraw(amount, "ATM " + atm.value());
return AtmDecision.APPROVED;
}
}

src/main/java/com/bank/account/ SetterAccount.java Şöyle de yazılabilirdi: setter'lı bir hesap. Çalışır, ama servisi atlayan bir iş bakiyeyi eksiye düşürebilir.

src/main/java/com/bank/account/SetterAccount.java
// The setter version: works while every caller goes through the service.
@Entity
@Getter @Setter
public class SetterAccount {
@Id @GeneratedValue private Long id;
private BigDecimal balance;
private String currency;
private String status;
}
// A nightly fee job that skips the service: no status check, no entry, and
// setBalance happily stores a negative number.
// account.setBalance(account.getBalance().subtract(monthlyFee));
Hızlı kontrolOrta

Zengin bir JPA entity'sinde argümansız kurucu neden protected olmalı?

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

Bir JPA entity'sine Lombok @Data koymanın riski nedir?

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

Kendin gör

Aynı sekiz çağrı üç farklı modele karşı çalışıyor. Sağdaki liste siparişin kurallarını, alttaki sayaç kötü bir çağrının nerede durduğunu gösteriyor.

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. Anemik modelle oynat. Hiçbir çağrı hata vermiyor — ve kuralların çoğu kırmızıya dönüyor. Hatasız çalışmak doğru çalışmak demek değil.
  2. Zengin entity’ye geç. Negatif miktar, dışarıdan liste ve iptal artık metot seviyesinde duruyor. setTotal hiç derlenmiyor.
  3. Ama iki kural hâlâ kırmızı. Para birimi ve yer değiştirmiş argümanlar geçiyor, çünkü BigDecimal ve int anlam taşımıyor. Bu, bir sonraki dersin konusu: 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 →.
  4. Görevleri tamamla. Sonuncusu sıfır bozulma istiyor ve bunu yalnızca üçüncü model yapabiliyor.
Kafam karıştı, daha basit anlat

Simülatörde kötü bir çağrının nerede durduğuna bak. Zengin modelde kötü çağrı ilk kapıda durur, veritabanına hiç ulaşmaz.

Hızlı kontrolOrta

Simülatörde zengin entity'ye geçtin. Hangi kötü çağrılar yine de sessizce geçiyor?

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

Tuzaklar

Entity’de Lombok @Data. Her alana setter açar — kapsülleme bir anotasyonla geri alınmış olur. Üstelik lazy koleksiyonlar dahil bütün alanlar üzerinden equals, hashCode ve toString üretir: beklenmedik sorgular, LazyInitializationException ya da çift yönlü ilişkilerde sonsuz özyineleme.

Entity’yi doğrudan JSON’a bağlamak. @RequestBody Order Jackson’ın setter’lara ya da alanlara yazmasını gerektirir ve istemcinin total göndermesine izin verir. İstek için bir record DTO al, entity’yi metotlarla değiştir.

Dev aggregate. Customer içinde bütün siparişleri List<Order> olarak tutmak, bir siparişi değiştirmek için müşteriyi ve tüm siparişlerini yüklemek demek. Aggregate’lar birbirine id ile referans verir.

Servisi yok etmek. Zengin model “servis yok” demek değil.

Servis transaction’ı açar, siparişi yükler, ödeme gibi dış sistemleri çağırır. Yalnızca kural taşımayı bırakır.

Kendini sına

Şimşek turu1/5

Setter'lı bir entity'de, onu değiştiren her kod kuralları kendisi hatırlamak zorundadır.

Soru 1/3Orta

Her sorumluluğu ait olduğu yere yerleştir.

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

Sınıflandırılmamış

Order (entity)

Siparişin kendi tutarlılığıyla ilgili kurallar

    OrderService (uygulama servisi)

    Koordinasyon: transaction, yükleme, dış sistemler

      Aklında kalacak üç şey

      1. 1 Setter'lı modelde kuralın sahibi yoktur: Order'ı değiştiren her kod kuralları kendisi hatırlamak zorundadır. Zengin modelde kural, verinin tek kapısında durur.
      2. 2 Zengin bir JPA entity'si niyet bildiren metotlarla (addLine, cancel), yalnızca çerçeve için protected bir boş kurucuyla ve dışarıya değiştirilemez kopya veren listelerle kurulur.
      3. 3 Servis yok olmaz, rolü değişir: transaction açar, yükler, dış sistemleri çağırır. Siparişin kendi tutarlılığı ise Order'ın işidir.
      Sonraki kapı Bir ilaç kutusunda yalnızca 5 yazıyorsa: 5 miligram mı, 5 tablet mi? Value Object ve Record — Anlamı Tipe Taşımak · 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.