← Kursa Dön
📄 Text · 20 min

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ı:

AvantajAçıklama
GüvenlikHer endpoint sadece gerekli veriyi açığa çıkarır
PerformansListe sorguları gereksiz veri taşımaz
BağımsızlıkEntity değiştiğinde API kırılmaz
ValidationHer senaryo kendi kurallarına sahip
DokümantasyonSwagger/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ı

  1. Immutable: Alanlar final — thread-safe, değiştirilemez

  2. Otomatik: equals(), hashCode(), toString() otomatik üretilir

  3. Kısa: Getter, constructor, boilerplate gereksiz

  4. 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?

DurumRecordClass
Response DTO (immutable)✅ Tercih edinGereksiz
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ı

AvantajDezavantaj
Tam kontrolBoilerplate kod çok
Bağımlılık yokHata yapma riski (alan atlamak)
Anlaşılması kolayYeni alan eklenince güncelleme unutulabilir
Compile-time güvenlikBüyük projelerde sürdürülemez

Mapping Stratejileri Karşılaştırma

YöntemAvantajDezavantajTercih
Manuel mappingTam kontrol, bağımlılık yokBoilerplate, hata riskiKüçük proje (< 5 entity)
Record factory methodKısa, okunabilir, standartKarmaşık mapping'de yetersizBasit dönüşümler
MapStructCompile-time, performanslı, tip güvenliÖğrenme eğrisi, ek dependencyBüyük projeler (önerilen)
ModelMapperRuntime, esnek, az kodYavaş, 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/JPA

Kurallar:

  1. Controller asla Entity ile çalışmaz — sadece DTO alır/döner

  2. Service, DTO'yu Entity'ye çevirir (veya tersi)

  3. Repository sadece Entity ile çalışır

  4. 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