Data Access & Transaction Management
Veritabanı tarafını “repository yaz, save çağır” seviyesinde bırakmıyoruz; production’da gerçekten hata çıkaran noktalara iniyoruz. JPA’nın bize ne kazandırdığını, lazy/eager loading davranışını, N+1 problemini, transaction boundary kararını ve propagation seviyelerini MiniBank üzerinden adım adım çalışacağız.
Bu modülde sık geçecek kavramlar
Data access konularında kelimeler birbirine çok yakın görünebilir: entity, repository, persistence context, transaction, propagation… Bu bölümde önce dili sadeleştiriyoruz; sonra derinliğe geçiyoruz.
Entity
Veritabanındaki bir tabloyu temsil eden Java sınıfıdır. Spring/JPA dünyasında entity, API response modeli değil; persistence modelidir.
Repository
Entity üzerinde veritabanı işlemleri yapmak için kullandığımız erişim katmanıdır. Service katmanı SQL veya EntityManager detayına doğrudan gömülmemelidir.
Persistence Context
JPA’nın takip ettiği managed entity alanıdır. Bir entity burada managed hale gelirse, değişiklikler transaction sonunda flush edilebilir.
EntityManager
JPA’nın entity yaşam döngüsünü yöneten ana bileşenidir. Spring Data JPA repository’leri çoğu zaman bu detayı bizim yerimize kullanır.
Transaction
Bir işin ya tamamen başarılı olması ya da geri alınması kuralıdır. Para transferi gibi işlerde transaction boundary business kararının parçasıdır.
Transaction Boundary
Transaction’ın nerede başlayıp nerede biteceğidir. Genelde controller’da değil, service katmanında belirlenir.
Lazy Loading
İlişkili verinin hemen değil, ihtiyaç olduğunda yüklenmesidir. Performans için faydalıdır ama yanlış yerde LazyInitializationException veya N+1 doğurabilir.
Eager Loading
İlişkili verinin ana entity ile birlikte yüklenmesidir. Basit görünür ama gereksiz veri çekmeye ve büyük query’lere sebep olabilir.
N+1 Problem
Bir liste için 1 ana query, listedeki her kayıt için ayrıca N query atılmasıdır. Production performans problemlerinin klasik JPA sebeplerindendir.
Propagation
Mevcut bir transaction varken çağrılan method’un bu transaction’a katılıp katılmayacağını belirler.
Rollback-only
Bir transaction’ın artık commit edilemeyecek şekilde işaretlenmesidir. Exception yakalansa bile transaction commit aşamasında geri dönebilir.
Fetch Join / EntityGraph
İlişkili veriyi kontrollü şekilde beraber çekmek için kullanılan yaklaşımlardır. N+1 problemini çözmede sık kullanılır.
Optimistic Lock (@Version)
Satırı kilitlemeden çakışmayı tespit eden yaklaşımdır. Entity’ye eklenen @Version alanı sayesinde commit anında versiyon değişmişse OptimisticLockException fırlatılır.
Pessimistic Lock
İlgili satırı en baştan kilitleyen yaklaşımdır. Arka planda SELECT ... FOR UPDATE çalışır; ikinci işlem kilit bırakılana kadar bekler. Lost update’i imkansız kılar ama bekleme/deadlock riski getirir.
Race Condition / Lost Update
Aynı veriyi eş zamanlı güncelleyen iki işlemden birinin değişikliğinin kaybolmasıdır. Sonuç, işlemlerin iç içe geçme sırasına bağlı hale gelir ve yanlış bakiye gibi tutarsızlıklar doğar.
JPA ne işe yarar?
JPA, veritabanındaki satırlarla Java objeleri arasında köprü kurmamızı sağlar. Ama asıl değer sadece “SQL yazmadan veri çekmek” değildir. Asıl değer; entity yaşam döngüsü, değişiklik takibi, repository modeli ve transaction ile birlikte çalışan tutarlı bir veri erişim katmanı kurmaktır.
JPA, veritabanındaki kayıtları Java objeleri gibi yönetmemizi sağlar. Spring Data JPA ise repository altyapısıyla bu modeli Spring Boot uygulamasında daha hızlı ve standart kullanmamızı sağlar.
JPA hangi problemi çözer?
JDBC ile çalışırken bağlantı açma, SQL yazma, ResultSet okuma, alanları Java objesine map etme, hata yönetimi ve transaction kontrolü çoğu zaman tekrar eden kod üretir. JPA bu tekrarların önemli kısmını soyutlar. Sen entity ve repository modelini kurarsın; framework kayıt bulma, saklama, değişiklik takip etme ve transaction sonunda veritabanına yansıtma süreçlerini yönetir.
JPA olmadan düşünürsek
SQL string’leri, connection yönetimi, result mapping, try/catch/finally blokları ve transaction sınırları daha fazla manuel kontrol gerektirir.
JPA ile düşünürsek
Entity modelini, repository methodlarını ve transaction boundary’yi doğru tasarlarsın. Framework detayların çoğunu yönetir ama performans etkilerini yine sen bilmek zorundasın.
MiniBank’ta nerede karşımıza çıkacak?
MiniBank’ta Account, Customer, TransferRecord ve AuditLog gibi entity’ler olacak. Repository’ler bu entity’leri okuyacak/yazacak. Service katmanı ise “para transferi bir bütün olarak başarılı mı başarısız mı?” gibi transaction kararlarını yönetecek.
@Entity
public class Account {
@Id
private Long id;
private String iban;
private BigDecimal balance;
// Entity davranışı: bakiye düş, bakiye artır vb.
}
public interface AccountRepository extends JpaRepository<Account, Long> {
Optional<Account> findByIban(String iban);
}Persistence Context ve managed entity mantığı
JPA’yı senior seviyede anlamanın kilit noktalarından biri şudur: Repository’den dönen entity sadece sıradan bir Java objesi gibi görünür ama transaction içinde persistence context tarafından takip ediliyor olabilir.
Managed entity ne demek?
Bir entity persistence context içinde managed hale geldiyse, JPA bu objenin değişikliklerini takip eder. Transaction içinde entity üzerinde alan değiştirdiğinde her zaman açıkça save() çağırman gerekmez; transaction sonunda flush sırasında değişiklik veritabanına yansıyabilir. Bu güçlüdür ama davranışın ne zaman gerçekleştiğini bilmezsen şaşırtıcı olabilir.
Entity’yi API response olarak dışarı vermek genellikle iyi fikir değildir. Entity persistence modelidir; API contract değildir. Lazy alanlar, gereksiz ilişki yüklemeleri ve serialization problemleri doğabilir. Bu yüzden REST modülünde öğrendiğimiz DTO ayrımı burada da kritik hale gelir.
Lazy loading vs eager loading
Entity ilişkilerinde en sık yapılan hata, “ilişkiyi ne zaman yüklemek istiyorum?” sorusunu bilinçli sormamaktır. Lazy ve eager sadece annotation seçimi değildir; query davranışı, performans ve API response tasarımını etkiler.
Lazy loading
İlişkili veri hemen çekilmez. İhtiyaç olduğunda yüklenir. Çoğu ilişkide daha güvenli başlangıçtır çünkü gereksiz veri çekimini azaltır.
Eager loading
İlişkili veri ana entity ile birlikte yüklenir. Bazı küçük ve her zaman gereken ilişkilerde mantıklı olabilir ama kontrolsüz kullanılırsa pahalı hale gelir.
MiniBank örneği
Bir hesabın son transferleri her endpoint’te gerekmeyebilir. GET /accounts/{iban} sadece hesap özetini dönebilir. Ama GET /accounts/{iban}/history transfer geçmişini de isteyebilir. Bu iki endpoint aynı veri ihtiyacına sahip değildir; bu yüzden loading stratejisi endpoint ihtiyacına göre düşünülmelidir.
@Entity
public class Account {
@Id
private Long id;
private String iban;
@OneToMany(mappedBy = "account", fetch = FetchType.LAZY)
private List<TransferRecord> transfers = new ArrayList<>();
}“Bu ilişki lazy mi eager mı olsun?” yerine önce şunu sor: “Bu use case bu ilişkiye gerçekten ihtiyaç duyuyor mu?” İhtiyaç varsa kontrollü query ile beraber çek; ihtiyaç yoksa hiç yükleme.
N+1 problemi
N+1 problemi, JPA kullanan ekiplerin production’da en sık karşılaştığı performans problemlerinden biridir. Kod küçük görünür, test datasında hızlıdır, ama gerçek veride yüzlerce SQL üretir.
Çok basit anlatım
Diyelim ki 100 hesabı listeledin. İlk query 100 hesabı getirir. Sonra her hesabın transferlerini ayrı ayrı okumaya başlarsan 100 query daha atılır. Toplamda 1 + 100 query oluşur. Buna N+1 diyoruz.
Nasıl fark ederiz?
SQL loglarını açtığında aynı pattern’de tekrar eden select sorguları görürsün. Bu yüzden JPA performansını konuşurken sadece Java koduna bakmak yetmez; üretilen SQL’i de okumak gerekir.
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.orm.jdbc.bind: TRACEÇözüm yaklaşımları
Fetch join
İhtiyaç duyulan ilişkiyi query içinde bilinçli şekilde beraber çekersin. Özellikle use-case bazlı okumalarda etkilidir.
@EntityGraph
Repository method’u için hangi ilişkilerin yüklenmesini istediğini daha deklaratif ifade edebilirsin.
DTO projection
Entity graph yerine doğrudan ihtiyacın olan alanları response modeline yakın şekilde çekebilirsin.
public interface JpaAccountRepository extends JpaRepository<JpaAccountEntity, String> {
@Query("select a from JpaAccountEntity a left join fetch a.histories where a.accountNo = :accountNo")
Optional<JpaAccountEntity> findByAccountNoWithHistory(String accountNo);
@EntityGraph(attributePaths = {"customer", "histories"})
Optional<JpaAccountEntity> findDetailedByAccountNo(String accountNo);
}Modül 05’te Pageable/Page REST contract’ını kurduk; burada aynı contract gerçek JpaRepository.findAll(Pageable) sorgusuna bağlanır. Bu uygulama ileri çalışmadır ve sonraki lab için prerequisite değildir.
@Transactional doğru kullanımı
@Transactional bir “veritabanı işlemi başlat” annotation’ı gibi görünür, ama aslında bir business boundary ifadesidir. “Bu method içindeki iş tek bir bütün olarak başarılı mı olmalı?” sorusunun cevabıdır.
Nereye koymalıyız?
Genel pratik: transaction boundary service katmanında olur. Controller HTTP detayını yönetir, repository veri erişimini yapar, service ise iş akışını ve transaction sınırını belirler. Para transferi gibi bir işte hem gönderen hesabın düşmesi hem alan hesabın artması aynı transaction içinde olmalıdır.
@Service
public class TransferService {
private final AccountRepository accountRepository;
private final AuditService auditService;
public TransferService(AccountRepository accountRepository,
AuditService auditService) {
this.accountRepository = accountRepository;
this.auditService = auditService;
}
@Transactional
public void transfer(String fromIban, String toIban, BigDecimal amount) {
Account from = accountRepository.findByIban(fromIban).orElseThrow();
Account to = accountRepository.findByIban(toIban).orElseThrow();
from.withdraw(amount);
to.deposit(amount);
auditService.recordTransferAttempt(fromIban, toIban, amount);
}
}Proxy mantığı burada da geçerli
AOP modülünde gördüğümüz proxy mantığı burada tekrar karşımıza çıkar. @Transactional davranışı Spring proxy’si üzerinden uygulanır. Bu yüzden aynı class içinde this.someTransactionalMethod() çağrısı yaptığında transaction beklendiği gibi çalışmayabilir.
Transaction içinde uzun süren external API çağrıları yapmak risklidir. DB connection ve lock süresi uzayabilir. External servis yavaşlarsa transaction da gereksiz yere açık kalır. Bu konuyu bir sonraki modüldeki External API bölümüne bağlayacağız.
Transaction propagation seviyeleri
Propagation, mevcut bir transaction varken çağrılan başka bir method’un o transaction’a katılıp katılmayacağını belirler. Bu bir enum ezberi değil; iş kuralı kararıdır.
TransferService.transfer() transaction içinde çalışıyor. İçeriden AuditService.saveAudit() çağrılırsa audit aynı transaction’a mı katılsın, yoksa bağımsız mı commit olsun?
REQUIRED
Varsa mevcut transaction’a katılır, yoksa yeni transaction açar. Default davranıştır ve çoğu service method’u için doğaldır.
REQUIRES_NEW
Mevcut transaction’ı askıya alır ve bağımsız yeni transaction açar. Audit/outbox gibi bazı durumlarda bilinçli kullanılır.
NESTED
Aynı physical transaction içinde savepoint mantığı sağlar. Kısmi rollback ihtiyacı varsa anlamlıdır; altyapı desteği önemlidir.
SUPPORTS
Transaction varsa katılır, yoksa transaction’sız çalışır. Genelde read/helper akışlarında görülür.
MANDATORY
Transaction olmak zorundadır. Yoksa hata verir. “Bu method transaction dışında anlamlı değil” demenin yoludur.
NOT_SUPPORTED / NEVER
Transaction istemeyen akışlar için kullanılır. NOT_SUPPORTED mevcut transaction’ı askıya alır; NEVER transaction varsa hata verir.
REQUIRED vs REQUIRES_NEW: MiniBank audit kararı
Transfer başarısız olursa audit kaydı da geri alınsın mı? Cevap “evet” ise REQUIRED doğal olabilir. Cevap “transfer rollback olsa bile deneme kaydı kalsın” ise REQUIRES_NEW düşünülebilir. Burada doğru cevap teknik değil, business kararıdır.
@Service
public class AuditService {
private final AuditLogRepository auditLogRepository;
public AuditService(AuditLogRepository auditLogRepository) {
this.auditLogRepository = auditLogRepository;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void recordTransferAttempt(String fromIban, String toIban, BigDecimal amount) {
auditLogRepository.save(AuditLog.transferAttempt(fromIban, toIban, amount));
}
}Rollback-only ve UnexpectedRollbackException
Bazı durumlarda iç method aynı transaction’a katılır ve hata oluştuğunda transaction rollback-only işaretlenir. Sen exception’ı yakalasan bile transaction artık commit edilemez hale gelmiş olabilir. Bu durumda dış method sonunda commit beklerken rollback gerçekleşebilir.
Exception’ı catch etmek her zaman transaction’ı kurtarmaz. Transaction rollback-only olduysa commit aşamasında yine rollback davranışı görebilirsin.
readOnly, timeout, isolation kısa farkları
readOnly
Bu transaction’ın okuma ağırlıklı olduğunu ifade eder. Query niyetini netleştirir ve bazı altyapılarda optimizasyon sağlayabilir.
timeout
Transaction’ın ne kadar süre açık kalabileceğini sınırlar. Uzun süren işlemlerde kaynak tüketimini kontrol etmeye yardımcı olur.
isolation
Aynı anda çalışan transaction’ların birbirinin verisini nasıl göreceğini belirler. Her yerde yükseltmek performans maliyeti doğurabilir.
Lock stratejileri: aynı hesaba iki paralel transfer
Transaction “bu iş ya tamamen olsun ya hiç olmasın” garantisini verir; ama tek başına “aynı veriyi aynı anda güncelleyen iki işlem birbirini bozmasın” garantisini vermez. Bu ikinci soru eşzamanlılık (concurrency) ve lock konusudur ve MiniBank gibi para hareketi olan sistemlerde göz ardı edilemez.
Senaryo: lost update / race condition
Bir hesabın bakiyesi 100 TL olsun. Aynı anda iki transfer bu hesaptan 50’şer TL çekmeye çalışsın. İkisi de neredeyse aynı anda bakiyeyi 100 olarak okur, kendi hesabını yapar (100 - 50 = 50) ve 50 yazar. Sonuçta hesapta 50 TL kalır; oysa toplam 100 TL çekildiği için 0 olmalıydı. 50 TL boşluktan ortaya çıkmıştır. Buna lost update (kaybolan güncelleme) ya da race condition denir: doğru sonuç, iki işlemin hangi sırayla ve ne kadar iç içe çalıştığına bağlı hale gelmiştir.
Bu problemi @Transactional tek başına çözmez, çünkü her iki işlem de geçerli bir transaction içinde, kendi açısından tutarlı çalışıyor. Eksik olan şey, “bu satırı ben güncellerken başkası araya girmesin” garantisidir. İki temel yaklaşım vardır: optimistic lock ve pessimistic lock.
Optimistic lock: @Version ile çakışmayı yakalama
Optimistic lock, “çakışma nadiren olur, o yüzden baştan kilitlemeyelim; ama olursa fark edelim” yaklaşımıdır. Entity’ye bir @Version alanı eklersin. JPA her güncellemede bu versiyonu kontrol eder: okuduğun versiyon ile yazmaya çalıştığın andaki versiyon farklıysa, demek ki araya başka bir işlem girmiş ve OptimisticLockException fırlatılır. Kimse satırı kilitlemez; çakışma sadece commit anında tespit edilir.
@Entity
@Table(name = "jpa_accounts")
public class JpaAccountEntity {
@Id
private String accountNo;
private BigDecimal balance;
// Optimistic lock için versiyon alanı.
// JPA her update'te versiyonu kontrol eder ve bir artırır.
@Version
private long version;
// ... mevcut alanlar ve metotlar
}Çakışma yakalandığında genelde işlemi baştan denemek (retry) mantıklıdır: hesabın güncel halini tekrar oku, işlemi tekrar uygula, tekrar commit etmeyi dene. Çünkü çakışma genelde geçicidir; ikinci denemede karşı işlem çoktan bitmiş olur.
public void transferWithRetry(String fromAccountNo, String toAccountNo, BigDecimal amount) {
int attempts = 0;
while (true) {
try {
transfer(fromAccountNo, toAccountNo, amount); // @Transactional
return;
} catch (OptimisticLockException ex) {
if (++attempts >= 3) {
throw ex; // çakışma ısrarcı: pes et ve yukarı bildir
}
// kısa bekle ve tekrar dene
}
}
}Pessimistic lock: satırı baştan kilitlemek
Pessimistic lock, “çakışma olacak, o yüzden satırı en baştan kilitleyelim” yaklaşımıdır. İlgili satırı okurken veritabanı düzeyinde kilitlersin; bu da arka planda SELECT ... FOR UPDATE sorgusuna dönüşür. İkinci işlem aynı satıra dokunmak istediğinde, ilk işlem commit edip kilidi bırakana kadar bekler. Böylece iki işlem gerçekten sıraya girer, lost update imkansız hale gelir.
public interface JpaAccountRepository extends JpaRepository<JpaAccountEntity, String> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select a from JpaAccountEntity a where a.accountNo = :accountNo")
Optional<JpaAccountEntity> findByAccountNoForUpdate(@Param("accountNo") String accountNo);
}Bu metot bir @Transactional service içinden çağrıldığında, kilit transaction commit olana kadar tutulur. Transfer akışında gönderen hesabı bu metotla okursan, aynı hesaptan eş zamanlı başka bir transfer denemesi senin işlemin bitmesini bekler.
Hangisi ne zaman?
Optimistic (düşük çakışma)
Aynı satırın eş zamanlı güncellenme olasılığı düşükse optimistic uygundur. Çakışma nadir olduğu için retry maliyeti ucuzdur ve hiçbir satır gereksiz yere kilitlenmez. Çoğu CRUD ekranı bu profile uyar.
Pessimistic (yüksek çakışma)
Aynı satıra sürekli ve eş zamanlı yazılıyorsa, kritik bölüm kısaysa pessimistic daha öngörülebilir olur. Ama satır kilitlendiği için deadlock ve uzun bekleme riski doğar; kilit süresini kısa tutmak şarttır.
Lock’un en sinsi maliyeti süredir. Pessimistic lock’ta satır, transaction commit olana kadar kilitli kalır; transaction ne kadar uzarsa kilit o kadar uzun tutulur ve bekleyen işlemler birikir. Bu yüzden “transaction içinde uzun süren işlem yapma” uyarısı burada iki katına çıkar ve bir sonraki Modül 10’daki external API çağrısı konusuyla doğrudan kesişir: bir kilidi tutarken yavaş bir dış servisi beklemek, sadece kendi işlemini değil, aynı hesabı bekleyen diğer transferleri de kilitlemek demektir. İki işlem birbirinin kilitlediği satırları ters sırada beklerse deadlock oluşur; bunu azaltmanın klasik yolu hesapları her zaman aynı sırada (örneğin accountNo sırasıyla) kilitlemektir. Pessimistic lock’ta ayrıca bir lock timeout belirlemek, sonsuza kadar beklemeyi engeller.
MiniBank Case 09 — JPA Relations, N+1 Optimization & Transfer Audit Propagation
Bu case’de MiniBank veri modelini gerçekçi hale getiriyoruz: customer-account-history ilişkilerini kuruyor, history endpointinde N+1 davranışını kontrollü şekilde gözlemliyor ve transfer audit kaydının transaction davranışını netleştiriyoruz. Görev listesi, doğrulama komutları, kod adımları ve kabul kriterleri Lab Dashboard'da.
Lab sırasında geride kalırsan lab-09-complete branch'ine geçip herkesle aynı checkpoint'ten devam edebilirsin.
Bu modülden akılda kalması gerekenler
JPA kolaylık sağlar ama SQL etkisini saklamaz.
Repository kodu temiz görünebilir ama arka planda üretilen SQL’i okumadan performans kararını doğrulayamazsın.
Lazy/eager seçimi use-case ile ilgilidir.
Her ilişkiyi eager yapmak çözüm değil; ihtiyaç duyulan veriyi kontrollü query ile çekmek daha sağlıklı yaklaşımdır.
N+1 production problemidir.
Test datasında görünmeyen küçük tekrarlar, gerçek veride yüzlerce query’ye dönüşebilir.
@Transactional business boundary ifade eder.
Transaction sınırı service katmanında, işin atomikliği düşünülerek belirlenmelidir.
Propagation teknik değil, iş kararıdır.
Audit kaydı transfer rollback olsa da kalsın mı? Bu sorunun cevabı REQUIRED mı REQUIRES_NEW mi seçeceğini belirler.
5 soru ile hızlı kontrol
1. Entity ile DTO arasındaki temel fark nedir?
Entity persistence modelidir ve veritabanı yapısına yakındır. DTO ise API contract veya use-case çıktısıdır. Entity’yi doğrudan dışarı vermek lazy loading, serialization ve contract problemleri doğurabilir.
2. N+1 problemi nedir?
Bir liste için önce 1 ana query, sonra listedeki her kayıt için ayrıca query atılmasıdır. Örneğin 100 account için 1 + 100 query oluşması N+1 problemidir.
3. @Transactional genelde hangi katmanda kullanılmalıdır?
Genellikle service katmanında kullanılır. Çünkü transaction boundary HTTP değil, business operation sınırıdır.
4. REQUIRED ve REQUIRES_NEW farkı nedir?
REQUIRED mevcut transaction varsa ona katılır, yoksa yeni açar. REQUIRES_NEW mevcut transaction’dan bağımsız yeni bir transaction açar.
5. Transaction içinde external API çağırmak neden risklidir?
DB connection ve lock’lar açık kalabilir. External servis yavaş veya hatalıysa transaction gereksiz uzar ve sistem kaynakları baskı altına girebilir.