@OneToOne İlişkiler
Giriş — Neden Bu Konu Önemli?
Bire-bir ilişki, bir entity'nin tam olarak bir başka entity ile eşleştiği durumlarda kullanılır. Bir kullanıcının bir profili, bir çalışanın bir kimlik kartı, bir ülkenin bir başkenti — bunlar bire-bir ilişki örnekleridir.
İlk bakışta en basit ilişki türü gibi görünse de, @OneToOne aslında JPA'nın en tuzaklı ilişki türüdür. Lazy loading'in çalışmaması, gereksiz sorgular ve performans sorunları bu ilişki türünün sık karşılaşılan problemleridir. Bu derste @OneToOne ilişkisinin farklı kurulum yöntemlerini, lazy loading problemini ve çözüm yollarını detaylıca inceleyeceğiz.
Gerçek Hayat Analojisi: Bir kişi ve pasaportu arasındaki ilişkiyi düşünün. Her kişinin en fazla bir pasaportu vardır ve her pasaport tam olarak bir kişiye aittir. Pasaportta kişinin TC kimlik numarası yazar (foreign key). Peki, kişinin tablosunda "pasaport numarası" da yazmak gerekir mi? Bidirectional ilişki tam da bu soruyu sorar — ve cevap çoğunlukla "hayır, gerek yok" olmalıdır.
Foreign Key ile @OneToOne
En yaygın yöntem, bir tabloya foreign key eklemektir:
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String email;
@OneToOne(
mappedBy = "user",
cascade = CascadeType.ALL,
fetch = FetchType.LAZY,
orphanRemoval = true
)
private UserProfile profile;
// Helper methods
public void setProfile(UserProfile profile) {
if (profile == null) {
if (this.profile != null) {
this.profile.setUser(null);
}
} else {
profile.setUser(this);
}
this.profile = profile;
}
}
@Entity
public class UserProfile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", unique = true)
private User user;
private String bio;
private String avatarUrl;
private LocalDate birthDate;
private String phoneNumber;
}Bu yapıda user_profile tablosu user_id foreign key sütunu taşır. unique = true constraint'i bire-bir ilişkiyi garanti eder — aynı kullanıcıya ikinci bir profil oluşturmayı engeller.
Veritabanı yapısı:
┌─────────────────┐ ┌──────────────────────┐
│ user │ │ user_profile │
├─────────────────┤ ├──────────────────────┤
│ id (PK) │◄──────│ user_id (FK, UNIQUE) │
│ username │ │ id (PK) │
│ email │ │ bio │
└─────────────────┘ │ avatar_url │
│ birth_date │
│ phone_number │
└──────────────────────┘Shared Primary Key — @MapsId
Daha verimli bir yaklaşım, child entity'nin primary key'inin aynı zamanda foreign key olmasıdır. Bu yaklaşımda ek bir sütun gerekmez:
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String email;
@OneToOne(
mappedBy = "user",
cascade = CascadeType.ALL,
fetch = FetchType.LAZY,
orphanRemoval = true
)
private UserProfile profile;
}
@Entity
public class UserProfile {
@Id
private Long id; // Auto-generate YOK — User'ın id'sini paylaşır
@OneToOne(fetch = FetchType.LAZY)
@MapsId // PK = FK
@JoinColumn(name = "id")
private User user;
private String bio;
private String avatarUrl;
private LocalDate birthDate;
}Veritabanı yapısı (daha temiz):
┌─────────────────┐ ┌──────────────────────┐
│ user │ │ user_profile │
├─────────────────┤ ├──────────────────────┤
│ id (PK) │◄══════│ id (PK = FK) │
│ username │ │ bio │
│ email │ │ avatar_url │
└─────────────────┘ │ birth_date │
└──────────────────────┘@MapsId sayesinde UserProfile'ın id'si her zaman ilişkili User'ın id'si ile aynı olur. Bu yaklaşımın avantajları:
Bir sütun daha az — ayrı FK sütununa gerek yok
JOIN performansı daha iyi — PK üzerinden JOIN, index kullanır
İlişki doğal olarak bire-bir garanti — PK unique olduğu için ek constraint gerekmez
Doğrudan erişim —
entityManager.find(UserProfile.class, userId)ile profil direkt bulunur
💡 İpucu: @MapsId ile profile ID'si her zaman user ID'si ile aynı olduğundan, profile'ı bulmak için sadece user ID'si yeterlidir — ayrı bir sorguya gerek kalmaz.
@MapsId Kullanımında Persist Sırası
// ✅ Doğru persist sırası
User user = new User();
user.setUsername("tolgahan");
UserProfile profile = new UserProfile();
profile.setBio("Java geliştirici");
profile.setUser(user); // @MapsId sayesinde profile.id = user.id olur
user.setProfile(profile);
userRepository.save(user); // Cascade ile her ikisi de kaydedilir
// user.id = 1, profile.id = 1 (aynı!)Lazy Loading ve @OneToOne Problemi
@OneToOne ilişkilerinin en bilinen problemi: inverse side'da lazy loading çalışmaz. Bu, Hibernate'in proxy mekanizmasıyla ilgili bir sınırlamadır.
// Owner side — lazy loading ÇALIŞIR ✅
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user; // Proxy oluşturulabilir
// Inverse side — lazy loading ÇALIŞMAZ ❌
@OneToOne(mappedBy = "user", fetch = FetchType.LAZY)
private UserProfile profile; // Her zaman eager fetch yapılır!Neden Çalışmaz?
Hibernate, proxy oluşturmak için referansın null mı yoksa bir nesne mi olduğunu bilmelidir:
Owner side'da (
UserProfile.user): Foreign key sütununa (user_id) bakarak referansın var olduğunu bilebilir.user_id = 5ise bir proxy oluşturur,user_id = NULLise null atar. Sorgu atmaya gerek yok.Inverse side'da (
User.profile): Foreign key yoktur! Hibernate, profilin var olup olmadığını anlamak için veritabanına sorgu atmalıdır. Sorguyu atınca da entity'yi zaten yüklemiş olur — proxy oluşturmanın anlamı kalmaz.
Analoji: Bir adres defterinde Ali'nin telefon numarası yazıyorsa (FK), kitabı açmadan "Ali'nin numarası var" diyebilirsiniz. Ama "Ali beni aradı mı?" sorusunu yanıtlamak için arama geçmişine bakmanız (sorgu atmanız) gerekir.
Bunu Kanıtlayalım
// SQL loglarını açın
// spring.jpa.show-sql=true
User user = userRepository.findById(1L).orElseThrow();
// Log: SELECT * FROM user WHERE id = 1
// Log: SELECT * FROM user_profile WHERE user_id = 1 ← İSTEMEDİK AMA ÇALIŞTI!
System.out.println(user.getProfile());
// Profile zaten yüklendi — lazy loading çalışmadı!Çözüm Yolları
1. @MapsId Kullanın (En İyi Çözüm)
Shared PK ile entityManager.find() doğrudan PK üzerinden çalışır:
@Entity
public class UserProfile {
@Id
private Long id;
@OneToOne(fetch = FetchType.LAZY)
@MapsId
@JoinColumn(name = "id")
private User user;
}
// Kullanım — profile'a doğrudan user ID ile erişilebilir
UserProfile profile = entityManager.find(UserProfile.class, userId);
// Eğer profile yoksa null döner — ayrı sorguya gerek yok2. optional = false Belirtin
Hibernate, ilişkinin kesinlikle var olduğunu bilirse bazı optimizasyonlar yapabilir:
@OneToOne(
mappedBy = "user",
fetch = FetchType.LAZY,
optional = false // Profile her zaman vardır
)
private UserProfile profile;⚠️ Dikkat:
optional = falsetek başına lazy loading'i garanti etmez — bu Hibernate versiyonuna ve bytecode enhancement durumuna bağlıdır. Ama optimizasyona yardımcı olur.
3. Bytecode Enhancement
Hibernate'in build-time bytecode enhancement özelliği ile lazy loading aktif edilebilir:
<!-- pom.xml — Hibernate bytecode enhancement plugin -->
<plugin>
<groupId>org.hibernate.orm.tooling</groupId>
<artifactId>hibernate-enhance-maven-plugin</artifactId>
<version>${hibernate.version}</version>
<executions>
<execution>
<configuration>
<enableLazyInitialization>true</enableLazyInitialization>
</configuration>
<goals>
<goal>enhance</goal>
</goals>
</execution>
</executions>
</plugin>@Entity
public class User {
@OneToOne(
mappedBy = "user",
fetch = FetchType.LAZY
)
@LazyToOne(LazyToOneOption.NO_PROXY) // Bytecode enhancement gerektirir
private UserProfile profile;
}Bu yöntem çalışır ama build pipeline'a karmaşıklık ekler.
4. Unidirectional Tercih Edin (En Temiz Çözüm)
Mümkünse sadece owner side'dan erişim yeterlidir. Inverse side'ı kaldırmak en temiz çözümdür:
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
// profile alanı YOK — bidirectional gerek yok
}
@Entity
public class UserProfile {
@Id
private Long id;
@OneToOne(fetch = FetchType.LAZY)
@MapsId
@JoinColumn(name = "id")
private User user;
private String bio;
}
// Profile'a ihtiyaç duyduğunuzda:
public interface UserProfileRepository extends JpaRepository<UserProfile, Long> {
// user ID = profile ID (@MapsId sayesinde)
// findById(userId) ile doğrudan erişim
}5. DTO Projection ile Birleştirme
// İki tablodan birleşik veri çekmek için DTO projection
public record UserWithProfileDto(
Long id, String username, String email,
String bio, String avatarUrl
) {}
@Query("""
SELECT new com.example.dto.UserWithProfileDto(
u.id, u.username, u.email, p.bio, p.avatarUrl
)
FROM User u LEFT JOIN UserProfile p ON u.id = p.id
WHERE u.id = :userId
""")
Optional<UserWithProfileDto> findUserWithProfile(@Param("userId") Long userId);@Embedded Alternatifi
@OneToOne kullanmadan önce kendinize sorun: gerçekten ayrı tablo gerekiyor mu?
Eğer child entity bağımsız olarak sorgulanmıyor ve sadece parent ile birlikte kullanılıyorsa, @Embedded çok daha basit ve performanslı bir çözümdür:
@Embeddable
public class Profile {
private String bio;
private String avatarUrl;
private LocalDate birthDate;
private String phoneNumber;
}
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String email;
@Embedded
private Profile profile; // Aynı tabloda, ayrı sütunlar olarak
}Bu yapıda user tablosu bio, avatar_url, birth_date, phone_number sütunlarını doğrudan içerir — JOIN gerekmez, lazy loading problemi yoktur, tek sorgu ile tüm veri gelir.
Ne Zaman @OneToOne, Ne Zaman @Embedded?
| Kriter | @Embedded | @OneToOne |
|---|---|---|
| Ayrı tablo gerekli mi? | Hayır | Evet |
| Bağımsız sorgulama | ❌ | ✅ |
| NULL olabilir mi? | Kısmen | ✅ |
| Performans | ⭐⭐⭐ (JOIN yok) | ⭐⭐ (JOIN gerekli) |
| Güvenlik (erişim kontrolü) | Aynı tablo | Farklı tablo (ayrı yetki) |
| Veri büyüklüğü | Küçük/orta | Büyük (BLOB, TEXT) |
| Birden fazla entity paylaşır | ❌ | ✅ |
Gerçek Dünya Örneği: Çalışan ve Kimlik Kartı
@Entity
@Table(name = "employees")
public class Employee {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String fullName;
@Column(nullable = false, unique = true)
private String email;
@OneToOne(
mappedBy = "employee",
cascade = CascadeType.ALL,
orphanRemoval = true,
fetch = FetchType.LAZY
)
private IdCard idCard;
public void assignIdCard(IdCard card) {
card.setEmployee(this);
this.idCard = card;
}
public void revokeIdCard() {
if (this.idCard != null) {
this.idCard.setEmployee(null);
this.idCard = null; // orphanRemoval ile DB'den silinir
}
}
}
@Entity
@Table(name = "id_cards")
public class IdCard {
@Id
private Long id;
@OneToOne(fetch = FetchType.LAZY)
@MapsId // id = employee.id
@JoinColumn(name = "id")
private Employee employee;
@Column(nullable = false, unique = true)
private String cardNumber;
@Column(name = "issue_date")
private LocalDate issueDate;
@Column(name = "expiry_date")
private LocalDate expiryDate;
@Enumerated(EnumType.STRING)
private CardStatus status = CardStatus.ACTIVE;
public boolean isExpired() {
return expiryDate != null && expiryDate.isBefore(LocalDate.now());
}
}
public enum CardStatus {
ACTIVE, SUSPENDED, EXPIRED, REVOKED
}Service Katmanı
@Service
@Transactional
public class EmployeeService {
private final EmployeeRepository employeeRepo;
public IdCard issueIdCard(Long employeeId) {
Employee employee = employeeRepo.findById(employeeId)
.orElseThrow(() -> new ResourceNotFoundException("Employee not found"));
if (employee.getIdCard() != null) {
throw new BusinessException("Employee already has an ID card");
}
IdCard card = new IdCard();
card.setCardNumber(generateCardNumber());
card.setIssueDate(LocalDate.now());
card.setExpiryDate(LocalDate.now().plusYears(5));
employee.assignIdCard(card); // Helper method
// CascadeType.ALL ile card otomatik persist edilir
return card;
}
public void revokeIdCard(Long employeeId) {
Employee employee = employeeRepo.findById(employeeId)
.orElseThrow(() -> new ResourceNotFoundException("Employee not found"));
employee.revokeIdCard(); // orphanRemoval ile card DB'den silinir
}
private String generateCardNumber() {
return "ID-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
}
}Yaygın Hatalar ve Anti-Pattern'ler
1. İki Yönlü @OneToOne ile N+1
Bidirectional @OneToOne kullanıyorsanız ve tüm User'ları çekiyorsanız, her bir user için profile sorgusu atılır:
// ❌ N+1 problemi — 100 user = 101 sorgu!
List<User> users = userRepository.findAll();
// SELECT * FROM user (1 sorgu)
// SELECT * FROM user_profile WHERE id=1 (user1'in profili)
// SELECT * FROM user_profile WHERE id=2 (user2'nin profili)
// ... 100 user = 100 ek sorgu
// ✅ JOIN FETCH ile çözüm
@Query("SELECT u FROM User u LEFT JOIN FETCH u.profile")
List<User> findAllWithProfile();
// SELECT u.*, p.* FROM user u LEFT JOIN user_profile p ON u.id = p.id
// Tek sorgu!2. CascadeType.REMOVE ve Veri Kaybı
// ⚠️ User silindiğinde profile da silinir
@OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private UserProfile profile;
// Bu tehlikeli olabilir — profile silinmesini gerçekten istiyor musunuz?
// Bazı sistemlerde profil arşivlenmeli, silinmemeli.
// Bu durumda cascade'den REMOVE'u çıkarın:
@OneToOne(
mappedBy = "user",
cascade = {CascadeType.PERSIST, CascadeType.MERGE},
orphanRemoval = false // Profil korunur
)
private UserProfile profile;3. JSON Serialization Sonsuz Döngü
// ❌ Sonsuz döngü: User → Profile → User → Profile → ...
// Jackson bu döngüde StackOverflowError fırlatır
// ✅ Çözüm 1: @JsonIgnore
@Entity
public class UserProfile {
@JsonIgnore
@OneToOne(fetch = FetchType.LAZY)
@MapsId
private User user;
}
// ✅ Çözüm 2: DTO Pattern (en iyi)
public record UserResponse(Long id, String username, String bio, String avatarUrl) {
public static UserResponse from(User user) {
UserProfile profile = user.getProfile();
return new UserResponse(
user.getId(),
user.getUsername(),
profile != null ? profile.getBio() : null,
profile != null ? profile.getAvatarUrl() : null
);
}
}4. Birden Fazla @OneToOne Inverse Side
Bir entity'de birden fazla inverse @OneToOne ilişkisi varsa, lazy loading problemi katlanır:
@Entity
public class User {
@OneToOne(mappedBy = "user", fetch = FetchType.LAZY)
private UserProfile profile; // eager fetch (çalışmaz)
@OneToOne(mappedBy = "user", fetch = FetchType.LAZY)
private UserSettings settings; // eager fetch (çalışmaz)
@OneToOne(mappedBy = "user", fetch = FetchType.LAZY)
private UserPreferences prefs; // eager fetch (çalışmaz)
// Her User çekildiğinde 3 ek sorgu atılır!
}
// ✅ Çözüm: @Embedded ile birleştirin veya unidirectional yapın
@Entity
public class User {
@Embedded
private UserSettings settings; // Aynı tabloda
@Embedded
private UserPreferences prefs; // Aynı tabloda
// Profile gerçekten ayrı tablo gerekiyorsa unidirectional bırakın
// profile alanı yok — gerektiğinde repository ile çekin
}@OneToOne ve Test Yazma
@DataJpaTest
class UserProfileTest {
@Autowired
private TestEntityManager em;
@Test
void shouldCreateUserWithProfile() {
User user = new User();
user.setUsername("testuser");
user.setEmail("test@example.com");
UserProfile profile = new UserProfile();
profile.setBio("Test bio");
profile.setAvatarUrl("https://example.com/avatar.jpg");
profile.setUser(user); // @MapsId ile id senkronize olur
user.setProfile(profile);
em.persistAndFlush(user);
em.clear(); // First-level cache'i temizle
User found = em.find(User.class, user.getId());
assertNotNull(found);
// @MapsId sayesinde profile aynı ID ile bulunur
UserProfile foundProfile = em.find(UserProfile.class, user.getId());
assertNotNull(foundProfile);
assertEquals("Test bio", foundProfile.getBio());
assertEquals(user.getId(), foundProfile.getId());
}
@Test
void shouldCascadeDeleteProfile() {
User user = createUserWithProfile();
em.persistAndFlush(user);
Long userId = user.getId();
em.remove(user);
em.flush();
assertNull(em.find(User.class, userId));
assertNull(em.find(UserProfile.class, userId)); // Cascade ile silinmeli
}
private User createUserWithProfile() {
User user = new User();
user.setUsername("cascade-test");
user.setEmail("cascade@example.com");
UserProfile profile = new UserProfile();
profile.setBio("Will be deleted");
profile.setUser(user);
user.setProfile(profile);
return user;
}
}Özet
@OneToOne bire-bir ilişki için kullanılır — bir entity tam olarak bir başka entity ile eşleşir
@MapsId (shared primary key) en verimli yaklaşımdır — ek FK sütunu gerekmez, JOIN performansı yüksektir
Inverse side'da lazy loading çalışmaz — Hibernate FK olmadan ilişkinin varlığını kontrol etmek için sorgu atar
Çözümler: @MapsId kullanın, unidirectional tercih edin, bytecode enhancement ekleyin, veya DTO projection kullanın
@Embedded alternatifini değerlendirin — gerçekten ayrı tablo gerekli mi? Aynı tabloya gömmek çoğu durumda daha basit ve performanslıdır
optional = false ilişkinin kesinlikle var olduğunu belirtir ve optimizasyona yardımcı olur
AI Asistan
Sorularını yanıtlamaya hazır