DTO Pattern — Entity ve Transfer Nesneleri
Bir hastanenin hasta kayıt sistemini düşünün. Hasta veritabanında her şey var: ad-soyad, TC kimlik, kan grubu, tüm tıbbi geçmişi, sigorta bilgileri, iç notlar, doktor yorumları. Şimdi hasta randevu portalına giriş yaptığında tüm bu bilgileri mi göstermelisiniz? Tabii ki hayır — sadece adı, soyadı, yaklaşan randevuları ve iletişim bilgilerini gösterirsiniz.
İşte DTO (Data Transfer Object) Pattern tam olarak bunu yapar: katmanlar arasında sadece gerekli veriyi taşır. Entity'nin tamamını dışarıya açmak yerine, her kullanım senaryosu için özel "veri paketi" nesneleri oluşturursunuz.
Bu ders, entity'yi doğrudan kullanmanın tehlikelerini, DTO tasarım stratejilerini, Java Record ile modern DTO yaklaşımını ve katman mimarisi içindeki veri akışını kapsar.
Entity'yi Doğrudan Kullanmanın Tehlikeleri
Spring Boot'a yeni başlayanlar genellikle entity'yi doğrudan API yanıtı olarak döner. Bu yaklaşım başlangıçta çalışır ama ciddi sorunlara yol açar:
// ❌ YANLIŞ — Entity doğrudan döndürülüyor
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
return userRepository.findById(id).orElseThrow();
}Bu basit kodun arkasında beş önemli sorun gizlidir:
1. Güvenlik Açığı — Hassas Veri Sızıntısı
@Entity
public class User {
private Long id;
private String name;
private String email;
private String passwordHash; // 🔴 API'ye sızar!
private String resetToken; // 🔴 API'ye sızar!
private boolean isAdmin; // 🔴 Yetki bilgisi sızar!
private String internalNotes; // 🔴 Dahili notlar sızar!
private LocalDateTime lastLogin;
}JSON yanıtı:
{
"id": 1,
"name": "Ahmet",
"email": "ahmet@example.com",
"passwordHash": "$2a$10$xyz...",
"resetToken": "abc-123-secret",
"isAdmin": true,
"internalNotes": "VIP müşteri, %20 indirim ver"
}@JsonIgnore ile gizleyebilirsiniz ama bu entity'ye sunum mantığı (presentation logic) sokmak demektir — Clean Architecture ihlali.
2. API Contract Kırılması
Entity'de yapılan her değişiklik API yanıtını doğrudan etkiler:
// V1 — Entity
public class User {
private String name; // Frontend "name" bekliyor
}
// V2 — Entity değişti
public class User {
private String firstName; // 💥 API kırıldı! Frontend hâlâ "name" bekliyor
private String lastName;
}3. Circular Reference — Sonsuz Döngü
Bidirectional JPA ilişkilerinde JSON serialization sonsuz döngüye girer:
@Entity
public class User {
@OneToMany(mappedBy = "user")
private List<Order> orders; // User → Order → User → Order → ...
}
@Entity
public class Order {
@ManyToOne
private User user; // Order → User → Order → User → ...
}
// GET /api/users/1 → StackOverflowError veya sonsuz JSON!4. Over-fetching — Gereksiz Veri
// Kullanıcı listesi endpoint'i
@GetMapping("/users")
public List<User> getUsers() {
return userRepository.findAll();
// Her kullanıcının TÜM alanları + ilişkileri yükleniyor
// Sadece ad ve email yeterliydi!
}5. Tight Coupling — Veritabanı Şemasına Bağımlılık
API, veritabanı yapısına doğrudan bağımlı olur. Tablo yapısı değiştiğinde API da değişir — bu, iç mimari kararlarının dış arayüzü etkilemesi demektir (kötü).
DTO Nedir?
DTO (Data Transfer Object), katmanlar arasında veri taşımak için tasarlanmış basit nesnelerdir. İş mantığı içermezler — sadece veri taşırlar.
Gerçek Dünya Analojisi
Bir kargo şirketini düşünün:
Entity = Depo rafındaki ürün (tam bilgi: ağırlık, boyut, tedarikçi, maliyet, raf konumu...)
Request DTO = Sipariş formu (müşterinin doldurduğu: ürün ID, miktar, adres)
Response DTO = Kargo takip bilgisi (müşterinin gördüğü: kargo no, durum, tahmini teslim)
Müşteri (client) depodaki (veritabanı) tüm detayları bilmek zorunda değildir.
DTO Tipleri
Client Controller Service Database
│ │ │ │
│── CreateUserRequest ──→ │ │ │
│ (Request DTO) │── CreateUserRequest → │ │
│ │ │── User Entity ──→ │
│ │ │← User Entity ─── │
│← UserResponse ────────── │← UserResponse ─────── │ │
│ (Response DTO) │ │ │Request DTO ve Response DTO Ayrımı
Her API endpoint'i için ayrı request ve response DTO'ları oluşturmak best practice'tir:
// ─── Request DTO'ları ───
// Oluşturma
public class CreateUserRequest {
@NotBlank(message = "İsim zorunludur")
@Size(min = 2, max = 50)
private String name;
@NotBlank @Email
private String email;
@NotBlank @Size(min = 8)
private String password;
@NotNull
private Long departmentId;
// id YOK — otomatik oluşturulacak
// createdAt YOK — server tarafında set edilecek
}
// Güncelleme — farklı kurallar
public class UpdateUserRequest {
@NotBlank
@Size(min = 2, max = 50)
private String name;
@NotBlank @Email
private String email;
// password YOK — ayrı endpoint'te güncellenir
// departmentId YOK — department değiştirilemez
}
// Kısmi güncelleme (PATCH)
public class PatchUserRequest {
// Tüm alanlar opsiyonel
@Size(min = 2, max = 50)
private String name;
@Email
private String email;
}
// ─── Response DTO'ları ───
// Detaylı yanıt (tekil sorgular)
public class UserResponse {
private Long id;
private String name;
private String email;
private String departmentName; // Entity'den düzleştirilmiş
private List<String> roleNames; // Entity'den düzleştirilmiş
private LocalDateTime createdAt;
// passwordHash YOK — güvenlik!
// internalNotes YOK — güvenlik!
}
// Liste yanıtı (çoklu sorgular) — daha az alan
public class UserListItem {
private Long id;
private String name;
private String email;
private String departmentName;
// createdAt YOK — listede gereksiz
// roles YOK — listede gereksiz
}
// Özet yanıt (dropdown/autocomplete)
public class UserSummary {
private Long id;
private String name;
// Sadece ID ve isim — dropdown'da göstermek için
}Neden Bu Kadar DTO?
"Bu kadar DTO sınıfı yazmak aşırı değil mi?" diye düşünebilirsiniz. Ama avantajları:
| Avantaj | Açıklama |
|---|---|
| Güvenlik | Her endpoint sadece gerekli veriyi açığa çıkarır |
| Performans | Liste sorguları gereksiz veri taşımaz |
| Bağımsızlık | Entity değiştiğinde API kırılmaz |
| Validation | Her senaryo kendi kurallarına sahip |
| Dokümantasyon | Swagger/OpenAPI doğru şema üretir |
Java Record ile DTO — Modern Yaklaşım
Java 16+ ile record sınıfları, DTO'lar için mükemmel bir tercihtir:
// Geleneksel class — boilerplate cehennemi
public class UserResponse {
private final Long id;
private final String name;
private final String email;
private final LocalDateTime createdAt;
public UserResponse(Long id, String name, String email, LocalDateTime createdAt) {
this.id = id;
this.name = name;
this.email = email;
this.createdAt = createdAt;
}
// getter, equals, hashCode, toString — 50+ satır boilerplate
}
// Record ile — aynı şey, 5 satır
public record UserResponse(
Long id,
String name,
String email,
LocalDateTime createdAt
) {}Record'un DTO İçin Avantajları
Immutable: Alanlar
final— thread-safe, değiştirilemezOtomatik:
equals(),hashCode(),toString()otomatik üretilirKısa: Getter, constructor, boilerplate gereksiz
Semantik: "Bu sınıf sadece veri taşır" mesajını net verir
Record + Validation
public record CreateUserRequest(
@NotBlank(message = "İsim zorunludur")
@Size(min = 2, max = 50)
String name,
@NotBlank @Email
String email,
@NotBlank @Size(min = 8)
String password
) {}
// Jackson otomatik deserialize eder
// Validation annotation'ları çalışır ✅Record + Factory Method
public record UserResponse(
Long id,
String name,
String email,
String departmentName,
LocalDateTime createdAt
) {
// Factory method — Entity'den DTO oluşturma
public static UserResponse from(User user) {
return new UserResponse(
user.getId(),
user.getName(),
user.getEmail(),
user.getDepartment() != null
? user.getDepartment().getName()
: null,
user.getCreatedAt()
);
}
// Liste için factory method
public static List<UserResponse> from(List<User> users) {
return users.stream()
.map(UserResponse::from)
.toList();
}
}Record Ne Zaman Kullanılmaz?
| Durum | Record | Class |
|---|---|---|
| Response DTO (immutable) | ✅ Tercih edin | Gereksiz |
| Request DTO (Jackson) | ✅ Çalışır (Spring Boot 3+) | Alternatif |
| Builder pattern gerekli | ❌ Record'da builder yok | ✅ Lombok @Builder |
| Inheritance gerekli | ❌ Record extend edilemez | ✅ Class |
| Mutable nesne | ❌ Record immutable | ✅ Class |
Manuel DTO Mapping
Küçük projelerde entity-DTO dönüşümünü elle yapabilirsiniz:
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
// ─── Entity → Response DTO ───
public UserResponse findById(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User", "id", id));
return toResponse(user);
}
public List<UserListItem> findAll() {
return userRepository.findAll().stream()
.map(this::toListItem)
.toList();
}
// ─── Request DTO → Entity (Create) ───
@Transactional
public UserResponse create(CreateUserRequest request) {
if (userRepository.existsByEmail(request.getEmail())) {
throw new ResourceAlreadyExistsException("User", "email", request.getEmail());
}
User user = new User();
user.setName(request.getName());
user.setEmail(request.getEmail());
user.setPasswordHash(passwordEncoder.encode(request.getPassword()));
return toResponse(userRepository.save(user));
}
// ─── Request DTO → Entity (Update) ───
@Transactional
public UserResponse update(Long id, UpdateUserRequest request) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User", "id", id));
user.setName(request.getName());
user.setEmail(request.getEmail());
return toResponse(userRepository.save(user));
}
// ─── Mapping Helper Methods ───
private UserResponse toResponse(User user) {
return new UserResponse(
user.getId(),
user.getName(),
user.getEmail(),
user.getDepartment() != null
? user.getDepartment().getName() : null,
user.getCreatedAt()
);
}
private UserListItem toListItem(User user) {
return new UserListItem(
user.getId(),
user.getName(),
user.getEmail(),
user.getDepartment() != null
? user.getDepartment().getName() : null
);
}
}Manuel Mapping'in Trade-off'ları
| Avantaj | Dezavantaj |
|---|---|
| Tam kontrol | Boilerplate kod çok |
| Bağımlılık yok | Hata yapma riski (alan atlamak) |
| Anlaşılması kolay | Yeni alan eklenince güncelleme unutulabilir |
| Compile-time güvenlik | Büyük projelerde sürdürülemez |
Mapping Stratejileri Karşılaştırma
| Yöntem | Avantaj | Dezavantaj | Tercih |
|---|---|---|---|
| Manuel mapping | Tam kontrol, bağımlılık yok | Boilerplate, hata riski | Küçük proje (< 5 entity) |
| Record factory method | Kısa, okunabilir, standart | Karmaşık mapping'de yetersiz | Basit dönüşümler |
| MapStruct | Compile-time, performanslı, tip güvenli | Öğrenme eğrisi, ek dependency | Büyük projeler (önerilen) |
| ModelMapper | Runtime, esnek, az kod | Yavaş, tip güvenliği zayıf | Önerilmez |
DTO Akışı — Katman Mimarisi
┌──────────┐ ┌────────────┐ ┌─────────┐ ┌──────────┐
│ Client │ ←→ │ Controller │ ←→ │ Service │ ←→ │Repository│
└──────────┘ └────────────┘ └─────────┘ └──────────┘
↕ ↕ ↕ ↕
JSON body Request DTO Entity Database
JSON response Response DTO Entity SQL/JPAKurallar:
Controller asla Entity ile çalışmaz — sadece DTO alır/döner
Service, DTO'yu Entity'ye çevirir (veya tersi)
Repository sadece Entity ile çalışır
DTO → Entity dönüşümü Service katmanında yapılır
@RestController
@RequestMapping("/api/products")
@RequiredArgsConstructor
public class ProductController {
private final ProductService productService;
@PostMapping
public ResponseEntity<ProductResponse> create(
@Valid @RequestBody CreateProductRequest request) {
// Controller sadece DTO bilir
ProductResponse response = productService.create(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@GetMapping
public ResponseEntity<Page<ProductListItem>> list(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(productService.findAll(page, size));
}
}API Versioning ile DTO
DTO'lar, API versiyonlama için doğal bir çözüm sunar:
// V1 — Mevcut API
public record UserResponseV1(
Long id,
String name, // Tek name alanı
String email
) {
public static UserResponseV1 from(User user) {
return new UserResponseV1(user.getId(),
user.getFirstName() + " " + user.getLastName(),
user.getEmail());
}
}
// V2 — Yeni API (firstName + lastName ayrıldı)
public record UserResponseV2(
Long id,
String firstName,
String lastName,
String email,
String avatarUrl,
List<String> roles
) {
public static UserResponseV2 from(User user) {
return new UserResponseV2(user.getId(),
user.getFirstName(),
user.getLastName(),
user.getEmail(),
user.getAvatarUrl(),
user.getRoles().stream().map(Role::getName).toList());
}
}
// Controller — aynı Entity, farklı DTO'lar
@GetMapping("/v1/users/{id}")
public UserResponseV1 getUserV1(@PathVariable Long id) {
User user = userService.findEntityById(id);
return UserResponseV1.from(user);
}
@GetMapping("/v2/users/{id}")
public UserResponseV2 getUserV2(@PathVariable Long id) {
User user = userService.findEntityById(id);
return UserResponseV2.from(user);
}Entity değişmeden, farklı API sürümlerine farklı DTO'lar sunabilirsiniz.
Yaygın Hatalar
1. ❌ Entity'yi Doğrudan Response Olarak Dönmek
// ❌
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
return userRepository.findById(id).orElseThrow();
}
// ✅
@GetMapping("/users/{id}")
public UserResponse getUser(@PathVariable Long id) {
return userService.findById(id); // DTO döner
}2. ❌ Entity'de Validation Annotation Kullanmak
// ❌ Entity hem persistence hem validation sorumluluğu taşıyor
@Entity
public class User {
@NotBlank // Validation
@Column(nullable = false) // Persistence
private String name;
}
// ✅ Validation DTO'da, persistence Entity'de
public class CreateUserRequest {
@NotBlank // Validation
private String name;
}
@Entity
public class User {
@Column(nullable = false) // Persistence
private String name;
}3. ❌ Tek DTO ile Her Şeyi Yapmaya Çalışmak
// ❌ Tek DTO — bazı alanlar bazen null
public class UserDTO {
private Long id; // Response'ta var, request'te yok
private String name;
private String password; // Create'te var, response'ta yok
private String email;
}
// ✅ Her senaryo için ayrı DTO
public record CreateUserRequest(String name, String email, String password) {}
public record UpdateUserRequest(String name, String email) {}
public record UserResponse(Long id, String name, String email, LocalDateTime createdAt) {}4. ❌ DTO'da İş Mantığı Barındırmak
// ❌ DTO'da iş mantığı
public class UserResponse {
private String name;
private BigDecimal balance;
public boolean isVip() {
return balance.compareTo(new BigDecimal("10000")) > 0;
}
}
// ✅ DTO sadece veri taşır, iş mantığı Service'te
public record UserResponse(String name, BigDecimal balance, boolean vip) {}
// Service'te:
return new UserResponse(user.getName(), user.getBalance(),
user.getBalance().compareTo(VIP_THRESHOLD) > 0);Test Stratejisi
DTO mapping'lerini test etmek, veri bütünlüğü açısından kritiktir:
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock private UserRepository userRepository;
@InjectMocks private UserService userService;
@Test
void findById_shouldMapEntityToResponseCorrectly() {
// Given
User user = new User();
user.setId(1L);
user.setName("Ahmet");
user.setEmail("ahmet@example.com");
user.setPasswordHash("$2a$10$secret"); // Hassas veri
user.setCreatedAt(LocalDateTime.now());
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
// When
UserResponse response = userService.findById(1L);
// Then
assertThat(response.id()).isEqualTo(1L);
assertThat(response.name()).isEqualTo("Ahmet");
assertThat(response.email()).isEqualTo("ahmet@example.com");
// passwordHash RESPONSE'TA YOK — güvenlik testi
}
}Özet
DTO Pattern, katmanlar arasında sadece gerekli veriyi taşıyan nesneler kullanır
Entity'yi doğrudan API'ye açmak → güvenlik açığı, tight coupling, circular reference sorunlarına yol açar
Her endpoint için ayrı Request DTO ve Response DTO oluşturun
Java 16+ kullanıyorsanız record tercih edin — immutable, kısa, otomatik equals/hashCode
Validation annotation'ları DTO'da kullanın, entity'de değil
DTO → Entity dönüşümü Service katmanında yapılır
Küçük projelerde manuel mapping, büyük projelerde MapStruct kullanın
DTO'lar API versiyonlama için doğal bir çözüm sunar — Entity değişmeden farklı sürümler
AI Asistan
Sorularını yanıtlamaya hazır