← Kursa Dön
📄 Text · 20 min

Spring Data REST

Giriş — Repository'den Otomatik API

Diyelim ki yeni bir projeye başlıyorsun. Bir admin paneli lazım, hızlıca CRUD endpoint'leri oluşturman gerekiyor. Entity, repository, service, controller, DTO, mapper... Hepsi standart, hepsi tekrarlayan boilerplate kod. Sadece 5 entity için bile 20+ dosya.

Peki ya sana şunu söylesem: tek bir dependency ekle, sıfır controller yaz, tüm CRUD endpoint'lerin hazır olsun?

Spring Data REST tam olarak bunu yapıyor. JPA repository'lerini otomatik olarak RESTful API'ye çeviriyor. HAL formatında, HATEOAS uyumlu, paging ve sorting dahil.

// Bu tek interface ile...
public interface ProductRepository extends JpaRepository<Product, Long> {
}

// Bu endpoint'lerin HEPSI otomatik oluşur:
// GET    /products        → Tüm ürünleri listele (paged)
// GET    /products/1      → Tek ürün getir
// POST   /products        → Yeni ürün oluştur
// PUT    /products/1      → Ürünü tamamen güncelle
// PATCH  /products/1      → Ürünü kısmen güncelle
// DELETE /products/1      → Ürünü sil

Sıfır controller. Sıfır service. Sıfır DTO. Sadece entity ve repository.

⚠️ Dikkat: Bu güç tehlikeli de olabilir. Tüm entity field'ları expose olur, business logic atlabilir, API contract kontrolden çıkabilir. Bu dersin sonunda ne zaman kullanılmalı, ne zaman kullanılmamalı detaylıca tartışacağız.


Kurulum

Dependency

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-rest</artifactId>
</dependency>

<!-- JPA zaten varsa eklemeye gerek yok, yoksa: -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

<!-- Veritabanı (H2 ile test) -->
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>

Temel Konfigürasyon

# application.properties

# Base path (varsayılan: /)
spring.data.rest.base-path=/api

# Sayfa boyutu (varsayılan: 20)
spring.data.rest.default-page-size=10

# Maksimum sayfa boyutu
spring.data.rest.max-page-size=100

# Sayfa parametresi adı (varsayılan: page)
spring.data.rest.page-param-name=page

# Boyut parametresi adı (varsayılan: size)
spring.data.rest.size-param-name=size

# Sıralama parametresi adı (varsayılan: sort)
spring.data.rest.sort-param-name=sort

# HAL explorer (browser'da API keşfi)
# spring-boot-starter-data-rest ile otomatik gelir

Minimal Örnek

@Entity
@Table(name = "products")
@Getter @Setter @NoArgsConstructor
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    private String description;

    @Column(nullable = false)
    private BigDecimal price;

    private Integer stockQuantity;

    @Column(nullable = false)
    private String category;

    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;

    @PrePersist
    protected void onCreate() {
        createdAt = LocalDateTime.now();
    }

    @PreUpdate
    protected void onUpdate() {
        updatedAt = LocalDateTime.now();
    }
}

// Bu kadar! Başka hiçbir şey yazmana gerek yok.
public interface ProductRepository extends JpaRepository<Product, Long> {
}

Uygulamayı çalıştır ve http://localhost:8080/api adresine git:

{
  "_links": {
    "products": {
      "href": "http://localhost:8080/api/products{?page,size,sort}",
      "templated": true
    },
    "profile": {
      "href": "http://localhost:8080/api/profile"
    }
  }
}

Tebrikler, API'n hazır. Tek satır controller yazmadan.


@RepositoryRestResource — Endpoint Özelleştirme

Varsayılan davranışı özelleştirmek için @RepositoryRestResource kullan:

// Path ve collection name özelleştirme
@RepositoryRestResource(
    path = "urunler",                    // /api/urunler (varsayılan: /api/products)
    collectionResourceRel = "urunler",   // JSON'daki _embedded key'i
    itemResourceRel = "urun"             // Tekil resource key'i
)
public interface ProductRepository extends JpaRepository<Product, Long> {
}

// Endpoint'i tamamen gizleme
@RepositoryRestResource(exported = false)  // Bu repository API'de görünmez
public interface AuditLogRepository extends JpaRepository<AuditLog, Long> {
}

Belirli metotları gizleme:

public interface UserRepository extends JpaRepository<User, Long> {

    // DELETE endpoint'ini devre dışı bırak
    @Override
    @RestResource(exported = false)
    void deleteById(Long id);

    // Tüm silme işlemlerini devre dışı bırak
    @Override
    @RestResource(exported = false)
    void delete(User entity);

    // Save'i de gizleyebilirsin (sadece read-only API)
    @Override
    @RestResource(exported = false)
    <S extends User> S save(S entity);
}

💡 İpucu: exported = false ile hassas operasyonları gizleyebilirsin. Örneğin kullanıcı entity'sinde DELETE'i kapatıp, silme işlemini custom controller ile kontrollü yapabilirsin.


Otomatik CRUD — HAL Response Formatı

Spring Data REST, HAL (Hypertext Application Language) formatında yanıt döner. HAL, REST API'lerde HATEOAS prensibini uygular — her yanıtta ilgili kaynağa ulaşmak için linkler vardır.

POST — Yeni Kayıt Oluşturma

curl -X POST http://localhost:8080/api/products \
     -H "Content-Type: application/json" \
     -d '{
       "name": "MacBook Pro 14",
       "description": "M3 Pro chip, 18GB RAM",
       "price": 74999.00,
       "stockQuantity": 50,
       "category": "Elektronik"
     }'

Response (201 Created):

{
  "name": "MacBook Pro 14",
  "description": "M3 Pro chip, 18GB RAM",
  "price": 74999.00,
  "stockQuantity": 50,
  "category": "Elektronik",
  "createdAt": "2025-01-15T14:30:00",
  "updatedAt": null,
  "_links": {
    "self": {
      "href": "http://localhost:8080/api/products/1"
    },
    "product": {
      "href": "http://localhost:8080/api/products/1"
    }
  }
}

GET — Tek Kayıt

curl http://localhost:8080/api/products/1

GET — Liste (Paged)

curl "http://localhost:8080/api/products?page=0&size=3&sort=price,desc"

Response:

{
  "_embedded": {
    "products": [
      {
        "name": "MacBook Pro 14",
        "price": 74999.00,
        "_links": {
          "self": { "href": "http://localhost:8080/api/products/1" }
        }
      },
      {
        "name": "iPhone 15 Pro",
        "price": 64999.00,
        "_links": {
          "self": { "href": "http://localhost:8080/api/products/2" }
        }
      }
    ]
  },
  "_links": {
    "self": { "href": "http://localhost:8080/api/products?page=0&size=3&sort=price,desc" },
    "next": { "href": "http://localhost:8080/api/products?page=1&size=3&sort=price,desc" },
    "last": { "href": "http://localhost:8080/api/products?page=3&size=3&sort=price,desc" }
  },
  "page": {
    "size": 3,
    "totalElements": 10,
    "totalPages": 4,
    "number": 0
  }
}

PUT vs PATCH

# PUT — Tüm entity'yi güncelle (verilmeyen field'lar null olur!)
curl -X PUT http://localhost:8080/api/products/1 \
     -H "Content-Type: application/json" \
     -d '{
       "name": "MacBook Pro 14 M3",
       "price": 79999.00,
       "stockQuantity": 45,
       "category": "Elektronik"
     }'
# description verilmedi → null olur!

# PATCH — Sadece verilen field'ları güncelle (diğerleri korunur)
curl -X PATCH http://localhost:8080/api/products/1 \
     -H "Content-Type: application/json" \
     -d '{
       "price": 69999.00,
       "stockQuantity": 30
     }'
# Sadece price ve stockQuantity değişir, diğerleri aynı kalır

⚠️ Dikkat: PUT tüm entity'yi replace eder. Verilmeyen field'lar null olur. Kısmi güncelleme için her zaman `PATCH` kullan.

DELETE

curl -X DELETE http://localhost:8080/api/products/1
# 204 No Content

HAL Formatı Detayları

HAL response'unda 3 ana bölüm var:

{
  "name": "MacBook Pro",           // 1. Resource data (entity field'ları)
  "price": 74999.00,

  "_links": {                       // 2. Links (HATEOAS navigasyon)
    "self": {
      "href": "http://localhost:8080/api/products/1"
    },
    "product": {
      "href": "http://localhost:8080/api/products/1"
    },
    "category": {                   // İlişkili kaynak linki
      "href": "http://localhost:8080/api/products/1/category"
    }
  },

  "_embedded": {                    // 3. Embedded resources (iç içe kaynaklar)
    "category": {
      "name": "Elektronik",
      "_links": {
        "self": { "href": "http://localhost:8080/api/categories/1" }
      }
    }
  }
}

HATEOAS'ın faydası: Client, URL'leri hardcode etmez. Her yanıttaki _links'i takip eder. API URL yapısı değişirse client'ın kodu bozulmaz — çünkü URL'leri dinamik olarak alıyor.

İlişkili Entity'ler

@Entity
public class Order {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne
    private Customer customer;      // İlişki

    @OneToMany(mappedBy = "order")
    private List<OrderItem> items;  // İlişki

    private BigDecimal total;
    private String status;
}

public interface OrderRepository extends JpaRepository<Order, Long> {}
public interface CustomerRepository extends JpaRepository<Customer, Long> {}
# Order getirdiğinde customer linkini görürsün
curl http://localhost:8080/api/orders/1
{
  "total": 149.90,
  "status": "COMPLETED",
  "_links": {
    "self": { "href": "http://localhost:8080/api/orders/1" },
    "customer": { "href": "http://localhost:8080/api/orders/1/customer" },
    "items": { "href": "http://localhost:8080/api/orders/1/items" }
  }
}
# İlişkili customer'ı getir
curl http://localhost:8080/api/orders/1/customer

# İlişkili item'ları getir
curl http://localhost:8080/api/orders/1/items

İlişki kurma (association):

# Order'a customer ata (URI ile)
curl -X PUT http://localhost:8080/api/orders/1/customer \
     -H "Content-Type: text/uri-list" \
     -d "http://localhost:8080/api/customers/5"

💡 İpucu: İlişki kurma text/uri-list content type'ı kullanır — JSON değil. Bu Spring Data REST'e özgü bir yöntemdir ve alışılmadık gelebilir.


Projections — Custom Response Shape

Entity'nin tüm field'larını expose etmek istemeyebilirsin. Veya farklı endpoint'lerde farklı görünüm isteyebilirsin. Projection tam olarak bunu sağlar.

// Sadece temel bilgileri gösteren projection
@Projection(name = "summary", types = {Product.class})
public interface ProductSummary {
    String getName();
    BigDecimal getPrice();
    String getCategory();
}

// Detaylı bilgileri gösteren projection
@Projection(name = "detail", types = {Product.class})
public interface ProductDetail {
    String getName();
    String getDescription();
    BigDecimal getPrice();
    Integer getStockQuantity();
    String getCategory();
    LocalDateTime getCreatedAt();

    // Computed field — entity'de olmayan alan
    @Value("#{target.price.multiply(new java.math.BigDecimal('1.20'))}")
    BigDecimal getPriceWithTax();
}

// İlişkili entity'yi inline gösteren projection
@Projection(name = "withCategory", types = {Product.class})
public interface ProductWithCategory {
    String getName();
    BigDecimal getPrice();
    CategoryInfo getCategory();  // nested projection

    interface CategoryInfo {
        String getName();
        String getDescription();
    }
}

Kullanım:

# Varsayılan (tüm field'lar)
curl http://localhost:8080/api/products/1

# Summary projection
curl "http://localhost:8080/api/products/1?projection=summary"

# Detail projection (KDV dahil fiyat ile)
curl "http://localhost:8080/api/products/1?projection=detail"

# Liste endpoint'inde projection
curl "http://localhost:8080/api/products?projection=summary"

Summary projection yanıtı:

{
  "name": "MacBook Pro 14",
  "price": 74999.00,
  "category": "Elektronik",
  "_links": {
    "self": { "href": "http://localhost:8080/api/products/1" }
  }
}

⚠️ Dikkat: Projection, entity'nin field'larını gizlemez — sadece farklı bir görünüm sunar. Varsayılan endpoint (?projection parametresi olmadan) hâlâ tüm field'ları döner. Güvenlik için projection'a güvenme, field'ları entity seviyesinde koru.


Excerpts — Liste Endpoint'lerinde Projection

Normal liste endpoint'inde (/api/products) entity'lerin tüm field'ları döner. Excerpt ile liste endpoint'inde otomatik olarak bir projection uygulanmasını sağlayabilirsin:

@RepositoryRestResource(excerptProjection = ProductSummary.class)
public interface ProductRepository extends JpaRepository<Product, Long> {
}

Artık /api/products listesinde her ürün ProductSummary projection'ı ile döner. Ama /api/products/1 (tekil) hâlâ tüm field'ları döner.

# Liste — otomatik excerpt (summary) uygulanır
curl http://localhost:8080/api/products

# Tekil — tüm field'lar (excerpt uygulanmaz)
curl http://localhost:8080/api/products/1

# Tekilde de projection istersen parametreyle
curl "http://localhost:8080/api/products/1?projection=summary"

💡 İpucu: Excerpt, özellikle liste endpoint'lerinde performans için faydalı. 50 ürün listelediğinde description gibi büyük text field'ları taşımak gereksiz — excerpt ile sadece name, price, category taşırsın.


Event Hooks — Business Logic Ekleme

Spring Data REST, CRUD işlemlerinden önce ve sonra event'ler fırlatır. Bu event'leri yakalayarak business logic ekleyebilirsin:

@Component
@RepositoryEventHandler
public class ProductEventHandler {

    private static final Logger log = LoggerFactory.getLogger(ProductEventHandler.class);

    // CREATE öncesi
    @HandleBeforeCreate
    public void handleBeforeCreate(Product product) {
        log.info("Creating product: {}", product.getName());

        // Varsayılan değerler ata
        if (product.getStockQuantity() == null) {
            product.setStockQuantity(0);
        }

        // İsim normalizasyonu
        product.setName(product.getName().trim());

        // Fiyat kontrolü
        if (product.getPrice().compareTo(BigDecimal.ZERO) <= 0) {
            throw new IllegalArgumentException("Fiyat sıfırdan büyük olmalı");
        }
    }

    // CREATE sonrası
    @HandleAfterCreate
    public void handleAfterCreate(Product product) {
        log.info("Product created: id={}, name={}", product.getId(), product.getName());
        // Bildirim gönder, cache temizle, audit log yaz...
    }

    // UPDATE öncesi
    @HandleBeforeSave
    public void handleBeforeSave(Product product) {
        log.info("Updating product: id={}", product.getId());
        product.setUpdatedAt(LocalDateTime.now());

        // Stok negatife düşmesin
        if (product.getStockQuantity() != null && product.getStockQuantity() < 0) {
            throw new IllegalArgumentException("Stok negatif olamaz");
        }
    }

    // UPDATE sonrası
    @HandleAfterSave
    public void handleAfterSave(Product product) {
        log.info("Product updated: id={}", product.getId());
    }

    // DELETE öncesi
    @HandleBeforeDelete
    public void handleBeforeDelete(Product product) {
        log.warn("Deleting product: id={}, name={}", product.getId(), product.getName());

        // Aktif siparişi olan ürün silinemesin
        // (Bu tür kontroller için genelde service katmanı daha uygun)
    }

    // DELETE sonrası
    @HandleAfterDelete
    public void handleAfterDelete(Product product) {
        log.info("Product deleted: id={}", product.getId());
    }
}

Event lifecycle:

POST /api/products
  │
  ├─ @HandleBeforeCreate  ← Validation, default değerler
  ├─ repository.save()     ← JPA persist
  └─ @HandleAfterCreate   ← Bildirim, audit log

PUT/PATCH /api/products/1
  │
  ├─ @HandleBeforeSave    ← Validation, güncelleme kontrolleri
  ├─ repository.save()     ← JPA merge
  └─ @HandleAfterSave    ← Cache temizleme

DELETE /api/products/1
  │
  ├─ @HandleBeforeDelete  ← Silme izni kontrolü
  ├─ repository.delete()   ← JPA remove
  └─ @HandleAfterDelete  ← Temizlik

⚠️ Dikkat: Event handler'larda fırlatılan exception'lar 500 Internal Server Error döner. Daha anlamlı hata mesajları için ResponseEntityExceptionHandler veya @ControllerAdvice kullan.

@ControllerAdvice
public class RestExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<Map<String, String>> handleIllegalArgument(IllegalArgumentException e) {
        return ResponseEntity.badRequest()
            .body(Map.of("error", e.getMessage()));
    }
}

Search Endpoints — Otomatik Query Expose

Repository'deki derived query method'ları otomatik olarak search endpoint'i haline gelir:

public interface ProductRepository extends JpaRepository<Product, Long> {

    // GET /api/products/search/findByCategory?category=Elektronik
    List<Product> findByCategory(String category);

    // GET /api/products/search/findByPriceBetween?min=100&max=500
    List<Product> findByPriceBetween(
        @Param("min") BigDecimal min,
        @Param("max") BigDecimal max
    );

    // GET /api/products/search/findByNameContainingIgnoreCase?name=mac
    List<Product> findByNameContainingIgnoreCase(@Param("name") String name);

    // GET /api/products/search/findByCategoryAndPriceLessThan?category=Elektronik&price=50000
    Page<Product> findByCategoryAndPriceLessThan(
        @Param("category") String category,
        @Param("price") BigDecimal price,
        Pageable pageable
    );

    // Belirli search endpoint'ini gizle
    @Override
    @RestResource(exported = false)
    List<Product> findAll();

    // Endpoint adını özelleştir
    @RestResource(path = "ucuz-urunler", rel = "ucuz-urunler")
    List<Product> findByPriceLessThan(@Param("maxPrice") BigDecimal maxPrice);
}

Search endpoint'lerini keşfetme:

# Tüm arama endpoint'lerini listele
curl http://localhost:8080/api/products/search
{
  "_links": {
    "findByCategory": {
      "href": "http://localhost:8080/api/products/search/findByCategory{?category}",
      "templated": true
    },
    "findByPriceBetween": {
      "href": "http://localhost:8080/api/products/search/findByPriceBetween{?min,max}",
      "templated": true
    },
    "findByNameContainingIgnoreCase": {
      "href": "http://localhost:8080/api/products/search/findByNameContainingIgnoreCase{?name}",
      "templated": true
    },
    "ucuz-urunler": {
      "href": "http://localhost:8080/api/products/search/ucuz-urunler{?maxPrice}",
      "templated": true
    }
  }
}

Kullanım:

# Kategoriye göre ara
curl "http://localhost:8080/api/products/search/findByCategory?category=Elektronik"

# Fiyat aralığı
curl "http://localhost:8080/api/products/search/findByPriceBetween?min=1000&max=5000"

# İsim araması (case-insensitive)
curl "http://localhost:8080/api/products/search/findByNameContainingIgnoreCase?name=mac"

# Paging ile arama
curl "http://localhost:8080/api/products/search/findByCategoryAndPriceLessThan?category=Elektronik&price=50000&page=0&size=5&sort=price,asc"

💡 İpucu: @Param annotation'ı URL query parametresi adını belirler. Kullanmazsan parametreler arg0, arg1 gibi çirkin isimlerle expose olur.


Paging ve Sorting

Spring Data REST, tüm liste endpoint'lerinde otomatik paging ve sorting desteği sunar:

# Sayfalama
curl "http://localhost:8080/api/products?page=0&size=5"

# Sıralama (artan)
curl "http://localhost:8080/api/products?sort=price,asc"

# Sıralama (azalan)
curl "http://localhost:8080/api/products?sort=price,desc"

# Çoklu sıralama
curl "http://localhost:8080/api/products?sort=category,asc&sort=price,desc"

# Hepsi birlikte
curl "http://localhost:8080/api/products?page=0&size=10&sort=category,asc&sort=price,desc"

Response'daki page bilgisi:

{
  "_embedded": { ... },
  "_links": {
    "first": { "href": "...?page=0&size=5" },
    "self":  { "href": "...?page=2&size=5" },
    "next":  { "href": "...?page=3&size=5" },
    "prev":  { "href": "...?page=1&size=5" },
    "last":  { "href": "...?page=9&size=5" }
  },
  "page": {
    "size": 5,
    "totalElements": 47,
    "totalPages": 10,
    "number": 2
  }
}

Client tarafında pagination yapmak için _links.next, _links.prev linklerini takip etmen yeterli. URL yapısını bilmeye gerek yok — HATEOAS'ın gücü bu.


Validators — Repository Event Öncesi Doğrulama

Spring Data REST, JSR-303 (Bean Validation) ile entegre çalışır:

@Entity
public class Product {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotBlank(message = "Ürün adı boş olamaz")
    @Size(min = 2, max = 200, message = "Ürün adı 2-200 karakter olmalı")
    private String name;

    @NotNull(message = "Fiyat zorunlu")
    @DecimalMin(value = "0.01", message = "Fiyat 0.01'den büyük olmalı")
    private BigDecimal price;

    @Min(value = 0, message = "Stok negatif olamaz")
    private Integer stockQuantity;

    @NotBlank(message = "Kategori zorunlu")
    private String category;
}

Ama bu yetmezse, custom validator da ekleyebilirsin:

@Component("beforeCreateProductValidator")
public class ProductValidator implements Validator {

    @Override
    public boolean supports(Class<?> clazz) {
        return Product.class.isAssignableFrom(clazz);
    }

    @Override
    public void validate(Object target, Errors errors) {
        Product product = (Product) target;

        // Kategori kontrolü
        List<String> validCategories = List.of("Elektronik", "Giyim", "Gıda", "Kitap");
        if (!validCategories.contains(product.getCategory())) {
            errors.rejectValue("category", "invalid.category",
                "Geçersiz kategori. Kabul edilen: " + validCategories);
        }

        // İş kuralı: Gıda kategorisinde max fiyat 10.000 TL
        if ("Gıda".equals(product.getCategory()) &&
            product.getPrice().compareTo(new BigDecimal("10000")) > 0) {
            errors.rejectValue("price", "price.too.high",
                "Gıda kategorisinde fiyat 10.000 TL'yi geçemez");
        }
    }
}
// Validator'ı Spring Data REST'e kaydet
@Configuration
public class RestValidationConfig implements RepositoryRestConfigurer {

    private final Validator productValidator;

    public RestValidationConfig(
            @Qualifier("beforeCreateProductValidator") Validator productValidator) {
        this.productValidator = productValidator;
    }

    @Override
    public void configureValidatingRepositoryEventListener(
            ValidatingRepositoryEventListener listener) {
        listener.addValidator("beforeCreate", productValidator);
        listener.addValidator("beforeSave", productValidator);
    }
}

Validation hatası response'u:

{
  "errors": [
    {
      "entity": "Product",
      "property": "category",
      "invalidValue": "Mobilya",
      "message": "Geçersiz kategori. Kabul edilen: [Elektronik, Giyim, Gıda, Kitap]"
    }
  ]
}

Custom Controller Override

Belirli endpoint'leri elle yazmak isteyebilirsin — özellikle business logic gerektiren işlemler için:

@RepositoryRestController  // @RestController DEĞİL!
@RequestMapping("/api/products")
public class ProductCustomController {

    private final ProductRepository productRepository;
    private final InventoryService inventoryService;

    public ProductCustomController(ProductRepository productRepository,
                                    InventoryService inventoryService) {
        this.productRepository = productRepository;
        this.inventoryService = inventoryService;
    }

    // Bu endpoint Spring Data REST'in otomatik endpoint'ini OVERRIDE eder
    @GetMapping("/{id}")
    public ResponseEntity<EntityModel<Product>> getProduct(@PathVariable Long id) {
        Product product = productRepository.findById(id)
            .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));

        // Ekstra business logic: görüntülenme sayısını artır
        product.incrementViewCount();
        productRepository.save(product);

        EntityModel<Product> model = EntityModel.of(product);
        model.add(linkTo(methodOn(ProductCustomController.class).getProduct(id)).withSelfRel());

        return ResponseEntity.ok(model);
    }

    // Custom endpoint — stok güncelleme
    @PatchMapping("/{id}/stock")
    public ResponseEntity<EntityModel<Product>> updateStock(
            @PathVariable Long id,
            @RequestBody StockUpdateRequest request) {

        Product product = productRepository.findById(id)
            .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));

        inventoryService.updateStock(product, request.getQuantity(), request.getReason());

        return ResponseEntity.ok(EntityModel.of(product));
    }
}

⚠️ Dikkat: Override controller'da @RepositoryRestController kullan, @RestController değil. @RestController kullanırsan Spring Data REST'in endpoint'i ile çakışır ve beklenmedik davranışlar olur. @RepositoryRestController aynı base-path altında çalışır ve Spring Data REST endpoint'ini override eder.

// ❌ YANLIŞ — Spring Data REST ile çakışır
@RestController
@RequestMapping("/api/products")
public class ProductController { }

// ✅ DOĞRU — Spring Data REST endpoint'ini override eder
@RepositoryRestController
@RequestMapping("/api/products")
public class ProductController { }

CORS Konfigürasyonu

Frontend uygulamalar farklı domain'den API'ye erişiyorsa CORS ayarı gerekir:

@Configuration
public class RestConfig implements RepositoryRestConfigurer {

    @Override
    public void configureRepositoryRestConfiguration(RepositoryRestConfiguration config,
                                                       CorsRegistry cors) {
        // Base path ayarla
        config.setBasePath("/api");

        // ID'leri response'a dahil et (varsayılan: dahil değil!)
        config.exposeIdsFor(Product.class, Customer.class, Order.class);

        // CORS
        cors.addMapping("/api/**")
            .allowedOrigins("http://localhost:3000", "https://myapp.com")
            .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE")
            .allowedHeaders("*")
            .allowCredentials(true);
    }
}

⚠️ Dikkat: Spring Data REST varsayılan olarak entity ID'lerini response'a dahil etmez. Bu kafa karıştırıcı olabilir. config.exposeIdsFor() ile görmek istediğin entity'leri belirt. Aksi halde client ID bilgisi alamaz ve güncelleme/silme yapamaz.


Tam Bir Örnek: Kitap Mağazası Admin API

Tüm kavramları bir araya getiren örnek:

Entity'ler

@Entity
@Table(name = "books")
@Getter @Setter @NoArgsConstructor
public class Book {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotBlank
    private String title;

    @NotBlank
    private String isbn;

    @NotNull
    @DecimalMin("0.01")
    private BigDecimal price;

    @Min(0)
    private int stock;

    private String description;
    private int pageCount;
    private LocalDate publishDate;

    @ManyToOne
    @JoinColumn(name = "publisher_id")
    private Publisher publisher;

    @ManyToMany
    @JoinTable(
        name = "book_authors",
        joinColumns = @JoinColumn(name = "book_id"),
        inverseJoinColumns = @JoinColumn(name = "author_id")
    )
    private Set<Author> authors = new HashSet<>();

    private LocalDateTime createdAt;

    @PrePersist
    protected void onCreate() {
        createdAt = LocalDateTime.now();
    }
}

@Entity
@Table(name = "authors")
@Getter @Setter @NoArgsConstructor
public class Author {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private String biography;
}

@Entity
@Table(name = "publishers")
@Getter @Setter @NoArgsConstructor
public class Publisher {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private String country;
}

Repository'ler

@RepositoryRestResource(excerptProjection = BookSummary.class)
public interface BookRepository extends JpaRepository<Book, Long> {

    List<Book> findByTitleContainingIgnoreCase(@Param("title") String title);

    Page<Book> findByPriceLessThan(@Param("maxPrice") BigDecimal maxPrice, Pageable pageable);

    @RestResource(path = "by-isbn", rel = "by-isbn")
    Book findByIsbn(@Param("isbn") String isbn);

    List<Book> findByAuthorsNameContaining(@Param("authorName") String authorName);

    @Query("SELECT b FROM Book b WHERE b.stock < :threshold")
    @RestResource(path = "low-stock", rel = "low-stock")
    List<Book> findLowStockBooks(@Param("threshold") int threshold);
}

@RepositoryRestResource
public interface AuthorRepository extends JpaRepository<Author, Long> {
    List<Author> findByNameContainingIgnoreCase(@Param("name") String name);
}

@RepositoryRestResource
public interface PublisherRepository extends JpaRepository<Publisher, Long> {
}

Projections

@Projection(name = "summary", types = {Book.class})
public interface BookSummary {
    Long getId();
    String getTitle();
    String getIsbn();
    BigDecimal getPrice();
    int getStock();
}

@Projection(name = "detail", types = {Book.class})
public interface BookDetail {
    String getTitle();
    String getIsbn();
    String getDescription();
    BigDecimal getPrice();
    int getStock();
    int getPageCount();
    LocalDate getPublishDate();
    Set<AuthorSummary> getAuthors();
    PublisherSummary getPublisher();

    interface AuthorSummary {
        String getName();
    }

    interface PublisherSummary {
        String getName();
        String getCountry();
    }
}

Event Handler

@Component
@RepositoryEventHandler
@Slf4j
public class BookEventHandler {

    @HandleBeforeCreate
    public void handleBeforeCreate(Book book) {
        log.info("Creating book: {}", book.getTitle());

        // ISBN format kontrolü
        String isbn = book.getIsbn().replaceAll("-", "");
        if (isbn.length() != 13 && isbn.length() != 10) {
            throw new IllegalArgumentException("Geçersiz ISBN formatı");
        }
    }

    @HandleAfterCreate
    public void handleAfterCreate(Book book) {
        log.info("Book created: id={}, title={}, isbn={}", 
                book.getId(), book.getTitle(), book.getIsbn());
    }

    @HandleBeforeDelete
    public void handleBeforeDelete(Book book) {
        if (book.getStock() > 0) {
            throw new IllegalArgumentException(
                "Stokta bulunan kitap silinemez. Mevcut stok: " + book.getStock());
        }
    }
}

Konfigürasyon

@Configuration
public class DataRestConfig implements RepositoryRestConfigurer {

    @Override
    public void configureRepositoryRestConfiguration(RepositoryRestConfiguration config,
                                                       CorsRegistry cors) {
        config.setBasePath("/api");
        config.exposeIdsFor(Book.class, Author.class, Publisher.class);
        config.setDefaultPageSize(20);
        config.setMaxPageSize(100);

        cors.addMapping("/api/**")
            .allowedOrigins("http://localhost:3000")
            .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE");
    }
}

API Kullanım Senaryoları

# 1. Tüm kitapları listele (excerpt projection ile)
curl http://localhost:8080/api/books

# 2. Detaylı kitap bilgisi
curl "http://localhost:8080/api/books/1?projection=detail"

# 3. ISBN ile ara
curl "http://localhost:8080/api/books/search/by-isbn?isbn=978-0-13-468599-1"

# 4. Yazar adına göre ara
curl "http://localhost:8080/api/books/search/findByAuthorsNameContaining?authorName=Martin"

# 5. Düşük stoklu kitaplar
curl "http://localhost:8080/api/books/search/low-stock?threshold=5"

# 6. Yeni kitap ekle
curl -X POST http://localhost:8080/api/books \
     -H "Content-Type: application/json" \
     -d '{
       "title": "Clean Code",
       "isbn": "978-0-13-235088-4",
       "price": 249.90,
       "stock": 100,
       "description": "A Handbook of Agile Software Craftsmanship",
       "pageCount": 464,
       "publishDate": "2008-08-01"
     }'

# 7. Kitaba yazar ata
curl -X POST http://localhost:8080/api/books/1/authors \
     -H "Content-Type: text/uri-list" \
     -d "http://localhost:8080/api/authors/1"

# 8. Kitaba publisher ata
curl -X PUT http://localhost:8080/api/books/1/publisher \
     -H "Content-Type: text/uri-list" \
     -d "http://localhost:8080/api/publishers/1"

# 9. Fiyat güncelle (PATCH)
curl -X PATCH http://localhost:8080/api/books/1 \
     -H "Content-Type: application/json" \
     -d '{"price": 199.90}'

# 10. Ucuzdan pahalıya sırala, sayfa 2
curl "http://localhost:8080/api/books?sort=price,asc&page=1&size=10"

Neden Production'da Dikkatli Olunmalı?

Spring Data REST çok güçlü bir araç. Ama büyük güç, büyük sorumluluk getirir. Production'da kullanırken farkında olman gereken riskler:

1. Tüm Entity Field'ları Expose Olur (Güvenlik Riski)

@Entity
public class User {
    private Long id;
    private String username;
    private String email;
    private String passwordHash;    // 🚨 API'den erişilebilir!
    private String tcKimlikNo;      // 🚨 KVKK ihlali!
    private BigDecimal salary;      // 🚨 Hassas veri!
    private String internalNotes;   // 🚨 İç notlar!
}

Repository'yi expose ettiğin anda, GET /api/users/1 ile tüm field'lar görünür. @JsonIgnore veya projection kullansan bile, birileri farklı yollarla erişebilir.

2. Complex Business Logic İçin Yetersiz

// Sipariş oluşturma business logic:
// 1. Stok kontrolü
// 2. Fiyat hesaplama (indirim, kampanya, KDV)
// 3. Ödeme işlemi
// 4. Stok düşürme
// 5. Email gönderme
// 6. Kargo oluşturma
// 7. Audit log

// Spring Data REST ile sadece "POST /api/orders" yapabilirsin
// Ama yukarıdaki 7 adımı nereye koyacaksın?
// Event handler'a mı? 7 adımlık logic event handler'a sığmaz.

3. API Contract Değişikliğine Açık

// Entity'ye yeni field eklediğinde...
@Entity
public class Product {
    private String name;
    private BigDecimal price;
    private String internalSku;    // Yeni field → otomatik API'de görünür!
}
// API response'u değişti → Client'lar bozulabilir
// REST endpoint'te ise DTO kullandığın için bu kontrol altında

4. Debugging Zorluğu

Bir hata olduğunda, Controller → Service → Repository akışını takip edebilirsin. Ama Spring Data REST'te controller yok — Spring'in internal mekanizmaları çalışıyor. Debug etmek zor, stack trace'ler karmaşık.

Ne Zaman Kullanılır?

SenaryoSpring Data REST?
Admin panel (internal tool)✅ Harika
Prototip / MVP✅ Hızlı başlangıç
Internal microservice (team içi)✅ Uygun
CRUD-heavy uygulama (basit domain)✅ İdeal
Hackathon / demo✅ Süper hızlı

Ne Zaman Kullanılmaz?

SenaryoSpring Data REST?
Public API❌ Güvenlik, versiyonlama, rate limit eksik
Complex domain (e-ticaret, bankacılık)❌ Business logic katmanı lazım
Microservice boundary❌ API contract kontrolü önemli
Mobile BFF (Backend for Frontend)❌ Client'a özel response shape lazım
Güvenlik-kritik uygulama❌ Field-level kontrol yetersiz

⚠️ Dikkat: Spring Data REST'i production'da public API olarak kullanma. Internal tool, admin panel veya prototip için mükemmel. Ama external client'ların erişeceği API'lerde controller + service + DTO kalıbını kullan. Güvenlik, validasyon ve business logic kontrolünü kaybetme.

Hibrit Yaklaşım (Önerilen)

// Internal/admin entity'ler → Spring Data REST
@RepositoryRestResource
public interface AuditLogRepository extends JpaRepository<AuditLog, Long> {
    // Otomatik CRUD — admin panel için yeterli
}

// Public/complex entity'ler → Klasik Controller + Service
@RestController
@RequestMapping("/api/orders")
public class OrderController {
    // Manuel kontrol — business logic, güvenlik, DTO mapping
}

// Hassas entity'ler → Export etme
@RepositoryRestResource(exported = false)
public interface UserCredentialRepository extends JpaRepository<UserCredential, Long> {
    // API'de görünmez — sadece internal kullanım
}

Bu hibrit yaklaşımda basit CRUD entity'leri (audit log, kategori, etiket gibi) Spring Data REST ile hızlıca expose edersin. Karmaşık iş mantığı gerektiren entity'leri (sipariş, ödeme, kullanıcı) ise klasik controller ile yazarsın.


Özet

  • Spring Data REST, JPA repository'lerini otomatik olarak RESTful endpoint'lere çevirir — sıfır controller, sıfır service, sıfır DTO ile tam CRUD API

  • HAL formatı ile HATEOAS uyumlu yanıtlar döner — _links ile navigasyon, _embedded ile iç içe kaynaklar, otomatik paging ve sorting

  • Projection ve excerpt ile response shape'ini özelleştir — farklı client'lar için farklı görünümler sun, gereksiz field'ları gizle

  • Event hooks (@HandleBeforeCreate, @HandleAfterSave) ile business logic ekle — validation, audit log, bildirim. Ama karmaşık iş mantığı için yetersiz kalır

  • Production'da dikkatli ol — tüm entity field'ları expose olabilir, API contract kontrolden çıkabilir, debugging zorlaşır. Güvenlik-kritik ve karmaşık domain'lerde kullanma

  • İdeal kullanım alanları: admin panel, internal tool, prototip, basit CRUD. Public API, e-ticaret, bankacılık gibi karmaşık domain'lerde klasik controller + service + DTO kalıbını tercih et