RESTful Best Practices
Giriş — Neden Bu Konu Önemli?
REST (Representational State Transfer) sadece bir mimari stil olmasına rağmen, iyi bir REST API tasarlamak ciddi mühendislik bilgisi gerektirir. Bir API'nin URL yapısı, HTTP metot kullanımı, status code seçimi ve hata formatı, o API'nin profesyonellik seviyesini belirler.
Kötü tasarlanmış bir API, frontend geliştiricileri çıldırtır, dokümantasyonu sürdürülemez hale getirir ve entegrasyonları zorlaştırır. İyi tasarlanmış bir API ise "self-documenting" — URL'i okuyan herkes ne yaptığını anlar.
Gerçek Hayat Analojisi: Bir kütüphane düşünün. Kitaplar rastgele raflara atılmışsa (kötü API) aradığınızı bulmak imkansızdır. Ama Dewey Decimal sistemine göre düzenlenmişse (iyi API), raf numarasına bakarak istediğiniz kitabı saniyeler içinde bulursunuz. REST best practices, API'nize "kütüphane düzeni" kazandırır.
Bu derste Richardson Maturity Model'i, kaynak isimlendirme kurallarını, HTTP metot semantiklerini, durum kodlarını, idempotency kavramını ve tutarlı hata formatını detaylı olarak inceleyeceğiz.
Richardson Maturity Model
Leonard Richardson, REST API'ların "olgunluk seviyesini" ölçen dört aşamalı bir model tanımlamıştır. Bu model, bir API'nin ne kadar RESTful olduğunu anlamak için mükemmel bir çerçevedir:
Level 0 — The Swamp of POX (Plain Old XML)
En düşük olgunluk seviyesidir. Tek bir endpoint vardır ve tüm operasyonlar POST ile yapılır:
POST /api
{
"action": "getUser",
"userId": 42
}
POST /api
{
"action": "createOrder",
"product": "Laptop",
"quantity": 1
}Bu yaklaşımda HTTP'nin sunduğu hiçbir özellikten faydalanılmaz. URL'ler anlamsızdır, HTTP metotları göz ardı edilir ve status code'lar kullanılmaz. SOAP veya XML-RPC tarzı bir yaklaşımdır.
Level 1 — Resources
Her kaynak (resource) kendi URL'ine sahiptir:
POST /users/42 → kullanıcı bilgisi getir
POST /orders → yeni sipariş oluştur
POST /products/5 → ürün güncelleArtık /api gibi tek bir endpoint yerine /users, /orders, /products gibi anlamlı URI'lar kullanılır. Ancak hâlâ tüm işlemler POST ile yapılabilir.
Level 2 — HTTP Verbs
HTTP metotları (GET, POST, PUT, DELETE, PATCH) doğru semantikleriyle kullanılır. Status code'lar anlamlı şekilde döndürülür. Çoğu modern REST API bu seviyededir:
GET /users/42 → 200 OK + user JSON
POST /orders → 201 Created + Location header
PUT /products/5 → 200 OK + updated product
DELETE /users/42 → 204 No ContentLevel 3 — Hypermedia Controls (HATEOAS)
En yüksek olgunluk seviyesidir. API yanıtları, istemcinin sonraki adımlarda yapabileceği işlemlerin bağlantılarını (links) içerir:
{
"id": 42,
"name": "Ali",
"email": "ali@example.com",
"_links": {
"self": { "href": "/users/42" },
"orders": { "href": "/users/42/orders" },
"update": { "href": "/users/42", "method": "PUT" },
"delete": { "href": "/users/42", "method": "DELETE" }
}
}İstemci URL'leri hardcode etmek yerine API'nin döndürdüğü linkleri takip eder. HATEOAS'ı bir sonraki derste detaylı inceleyeceğiz.
Resource Naming (Kaynak İsimlendirme)
İyi bir REST API'nin temeli anlamlı ve tutarlı URL yapısıdır. Aşağıdaki kurallar endüstri standardıdır:
1. İsimler çoğul olmalı
Koleksiyonları temsil eden endpoint'ler çoğul isim kullanır:
✅ /users → tüm kullanıcılar
✅ /users/42 → id=42 olan kullanıcı
✅ /products → tüm ürünler
❌ /user → tekil isim — kaçının
❌ /getUsers → fiil kullanmayın
❌ /user-list → gereksiz ek2. Hiyerarşik ilişkileri nested URL ile ifade edin
GET /users/42/orders → 42 nolu kullanıcının siparişleri
GET /users/42/orders/7 → 42 nolu kullanıcının 7 nolu siparişi
GET /departments/5/employees → 5 nolu departmanın çalışanlarıAncak ikiden fazla seviye derinliğe inmekten kaçının. Üç seviye ve ötesi URL'leri karmaşıklaştırır:
❌ /users/42/orders/7/items/3/reviews → çok derin
✅ /order-items/3/reviews → daha düz yapı3. Küçük harf ve kebab-case kullanın
✅ /order-items
✅ /user-profiles
❌ /orderItems → camelCase — URL'lerde uygun değil
❌ /Order_Items → snake_case ve büyük harf — kaçının4. Fiil kullanmayın, işlemi HTTP metodu belirlesin
❌ GET /getUsers
❌ POST /createUser
❌ POST /deleteUser/42
✅ GET /users
✅ POST /users
✅ DELETE /users/42Tek istisna: Eyleme dayalı (action-based) operasyonlar için fiil kabul edilebilir:
POST /users/42/activate → kullanıcıyı aktifleştir
POST /orders/7/cancel → siparişi iptal et
POST /reports/generate → rapor oluştur
POST /emails/42/send → e-posta gönder5. Filtreleme, sıralama ve sayfalama query parameter ile yapılır
GET /products?category=elektronik&min-price=100&max-price=500
GET /users?sort=name,asc&page=0&size=20
GET /orders?status=DELIVERED&from=2024-01-01&to=2024-12-31HTTP Method Semantics
Her HTTP metodu belirli bir anlam taşır. Bu semantiklere uyum, API'nizin öngörülebilir ve tutarlı olmasını sağlar:
| Metot | Amaç | Request Body | Idempotent | Safe |
|---|---|---|---|---|
| GET | Kaynak getir | Yok | ✅ | ✅ |
| POST | Yeni kaynak oluştur | Var | ❌ | ❌ |
| PUT | Kaynağı tamamen güncelle | Var | ✅ | ❌ |
| PATCH | Kaynağı kısmen güncelle | Var | ❌* | ❌ |
| DELETE | Kaynağı sil | Yok | ✅ | ❌ |
| HEAD | Sadece header getir | Yok | ✅ | ✅ |
| OPTIONS | İzin verilen metotları getir | Yok | ✅ | ✅ |
Safe (Güvenli): İstek sunucu durumunu değiştirmez. GET ve HEAD güvenlidir — sadece veri okurlar.
Idempotent (Etkisiz): Aynı isteği birden fazla kez göndermek, tek bir kez göndermekle aynı sonucu verir.
PUT vs PATCH Farkı
// PUT — TÜM alanları göndermelisiniz (yoksa null/default yapılır)
PUT /users/42
{
"firstName": "Ali",
"lastName": "Yılmaz",
"email": "ali@new-email.com",
"phone": null // telefon alanı silindi!
}
// PATCH — Sadece değişen alanları gönderirsiniz
PATCH /users/42
{
"email": "ali@new-email.com"
// Sadece email güncellenir, diğer alanlar korunur
}Spring Boot'ta PATCH implementasyonu:
@PatchMapping("/users/{id}")
public ResponseEntity<UserDto> patchUser(
@PathVariable Long id,
@RequestBody Map<String, Object> updates) {
User user = userService.findById(id);
updates.forEach((key, value) -> {
switch (key) {
case "firstName" -> user.setFirstName((String) value);
case "lastName" -> user.setLastName((String) value);
case "email" -> user.setEmail((String) value);
case "phone" -> user.setPhone((String) value);
}
});
User updated = userService.save(user);
return ResponseEntity.ok(UserDto.from(updated));
}Status Codes (Durum Kodları)
Doğru HTTP status code döndürmek, REST API kalitesinin en önemli göstergelerinden biridir:
// ═══════════════════════════════════════
// Başarılı yanıtlar (2xx)
// ═══════════════════════════════════════
200 OK → GET, PUT, PATCH başarılı
201 Created → POST ile yeni kaynak oluşturuldu
204 No Content → DELETE başarılı, body yok
202 Accepted → Asenkron işlem kabul edildi (henüz tamamlanmadı)
// ═══════════════════════════════════════
// İstemci hataları (4xx)
// ═══════════════════════════════════════
400 Bad Request → Geçersiz request body / parametre
401 Unauthorized → Authentication gerekli (kim olduğunu bilmiyoruz)
403 Forbidden → Yetki yok (kim olduğunu biliyoruz ama izin yok)
404 Not Found → Kaynak bulunamadı
405 Method Not Allowed → Bu URL'de bu metot desteklenmiyor
409 Conflict → Kaynak çakışması (duplicate email, version conflict)
422 Unprocessable → Validation hatası (syntax doğru ama semantik yanlış)
429 Too Many Requests → Rate limit aşıldı
// ═══════════════════════════════════════
// Sunucu hataları (5xx)
// ═══════════════════════════════════════
500 Internal Server Error → Beklenmeyen hata
502 Bad Gateway → Upstream sunucu yanıt vermedi
503 Service Unavailable → Geçici olarak hizmet dışı
504 Gateway Timeout → Upstream sunucu zaman aşımıSpring Boot'ta Status Code Döndürme
@PostMapping("/users")
public ResponseEntity<UserDto> createUser(@Valid @RequestBody CreateUserDto dto) {
UserDto created = userService.create(dto);
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(created.id())
.toUri();
return ResponseEntity
.created(location) // 201 Created
.body(created); // + Location: /users/42 header
}
@DeleteMapping("/users/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userService.delete(id);
return ResponseEntity.noContent().build(); // 204 No Content
}
@PostMapping("/reports/generate")
public ResponseEntity<Void> generateReport(@RequestBody ReportRequest request) {
reportService.enqueue(request);
return ResponseEntity.accepted().build(); // 202 Accepted
}Idempotency (Etkisizlik)
Idempotency, dağıtık sistemlerde kritik bir kavramdır. Ağ hataları, timeout'lar veya retry'larda aynı isteğin birden fazla kez işlenmesini güvenli kılmak için idempotency key kullanılır:
@PostMapping("/payments")
public ResponseEntity<?> processPayment(
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestBody PaymentRequest request) {
// Aynı key ile daha önce işlem yapıldı mı?
Optional<Payment> existing = paymentRepo.findByIdempotencyKey(idempotencyKey);
if (existing.isPresent()) {
return ResponseEntity.ok(existing.get()); // Aynı sonucu döndür
}
Payment payment = paymentService.process(request, idempotencyKey);
return ResponseEntity.status(HttpStatus.CREATED).body(payment);
}Bu pattern, ödeme gibi kritik işlemlerde çift işlemi önler. İstemci her istek için benzersiz bir key üretir (UUID); sunucu bu key'i kaydeder ve aynı key ile gelen tekrar isteklerde orijinal yanıtı döner.
Tutarlı Hata Yanıtı Formatı — RFC 7807
Tüm hata yanıtlarınız aynı yapıda olmalıdır. RFC 7807 (Problem Details) standardını takip etmek en iyi pratiktir. Spring Boot 3+ bu formatı native olarak destekler:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ProblemDetail> handleNotFound(ResourceNotFoundException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, ex.getMessage());
pd.setTitle("Resource Not Found");
pd.setType(URI.create("https://api.example.com/errors/not-found"));
pd.setProperty("timestamp", Instant.now());
pd.setProperty("resourceId", ex.getResourceId());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(pd);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ProblemDetail> handleValidation(
MethodArgumentNotValidException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(
HttpStatus.UNPROCESSABLE_ENTITY, "Validation failed");
pd.setTitle("Validation Error");
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error ->
errors.put(error.getField(), error.getDefaultMessage()));
pd.setProperty("errors", errors);
return ResponseEntity.unprocessableEntity().body(pd);
}
@ExceptionHandler(DuplicateResourceException.class)
public ResponseEntity<ProblemDetail> handleDuplicate(DuplicateResourceException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(
HttpStatus.CONFLICT, ex.getMessage());
pd.setTitle("Resource Conflict");
pd.setProperty("conflictingField", ex.getField());
return ResponseEntity.status(HttpStatus.CONFLICT).body(pd);
}
}Yanıt örneği:
{
"type": "https://api.example.com/errors/not-found",
"title": "Resource Not Found",
"status": 404,
"detail": "User with id 42 not found",
"instance": "/api/users/42",
"timestamp": "2024-03-01T12:00:00Z",
"resourceId": 42
}Content Negotiation
Aynı endpoint'ten farklı formatlar sunmak:
@GetMapping(value = "/users/{id}",
produces = {MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE})
public UserDto getUser(@PathVariable Long id) {
return userService.findById(id);
}
// İstemci Accept header ile format seçer:
// Accept: application/json → JSON yanıt
// Accept: application/xml → XML yanıtPagination Best Practices
@GetMapping("/products")
public ResponseEntity<Page<ProductDto>> listProducts(
@PageableDefault(size = 20, sort = "createdAt",
direction = Sort.Direction.DESC) Pageable pageable) {
Page<ProductDto> page = productService.findAll(pageable);
HttpHeaders headers = new HttpHeaders();
headers.add("X-Total-Count", String.valueOf(page.getTotalElements()));
headers.add("X-Total-Pages", String.valueOf(page.getTotalPages()));
return ResponseEntity.ok()
.headers(headers)
.body(page);
}Yanıt:
{
"content": [...],
"pageable": {
"pageNumber": 0,
"pageSize": 20,
"sort": { "sorted": true, "direction": "DESC" }
},
"totalElements": 150,
"totalPages": 8,
"first": true,
"last": false
}API Yanıt Wrapper Pattern
Bazı takımlar tüm API yanıtlarını tutarlı bir wrapper ile sarar:
// Wrapper DTO
public record ApiResponse<T>(
boolean success,
T data,
String message,
Instant timestamp
) {
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>(true, data, null, Instant.now());
}
public static <T> ApiResponse<T> ok(T data, String message) {
return new ApiResponse<>(true, data, message, Instant.now());
}
public static <T> ApiResponse<T> error(String message) {
return new ApiResponse<>(false, null, message, Instant.now());
}
}
// Controller'da kullanım
@GetMapping("/users/{id}")
public ResponseEntity<ApiResponse<UserDto>> getUser(@PathVariable Long id) {
UserDto user = userService.findById(id);
return ResponseEntity.ok(ApiResponse.ok(user));
}
// Yanıt:
// {
// "success": true,
// "data": { "id": 42, "name": "Ali", ... },
// "message": null,
// "timestamp": "2024-03-01T12:00:00Z"
// }⚠️ Tartışmalı pratik: HTTP status code zaten başarı/hata bilgisini taşır. Wrapper'daki
successalanı gereksiz tekrar olabilir. Büyük API'ler (Twitter, GitHub) wrapper kullanmaz, doğrudan veri döner. Küçük projelerde wrapper kolaylık sağlayabilir.
ETag ve Conditional Requests
Gereksiz veri transferini önlemek ve cache kontrolü sağlamak için ETag kullanılır:
@GetMapping("/products/{id}")
public ResponseEntity<ProductDto> getProduct(
@PathVariable Long id,
WebRequest request) {
ProductDto product = productService.findById(id);
// ETag oluştur (version veya hash tabanlı)
String etag = "\"" + product.version() + "\"";
// İstemcinin gönderdiği If-None-Match header'ı ile karşılaştır
if (request.checkNotModified(etag)) {
return null; // 304 Not Modified — body gönderilmez
}
return ResponseEntity.ok()
.eTag(etag)
.cacheControl(CacheControl.maxAge(Duration.ofMinutes(5)))
.body(product);
}
// Conditional Update — optimistic locking
@PutMapping("/products/{id}")
public ResponseEntity<ProductDto> updateProduct(
@PathVariable Long id,
@RequestHeader("If-Match") String ifMatch,
@RequestBody UpdateProductDto dto) {
ProductDto current = productService.findById(id);
String currentEtag = "\"" + current.version() + "\"";
if (!currentEtag.equals(ifMatch)) {
throw new ConflictException("Product was modified by another user");
// 409 Conflict
}
ProductDto updated = productService.update(id, dto);
return ResponseEntity.ok()
.eTag("\"" + updated.version() + "\"")
.body(updated);
}CORS Konfigürasyonu
Frontend uygulamalarının API'nize erişimi için CORS ayarları gereklidir:
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://myapp.com", "http://localhost:3000")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders("*")
.exposedHeaders("X-Total-Count", "X-Total-Pages", "Location")
.allowCredentials(true)
.maxAge(3600); // Preflight cache süresi
}
}Özet
Richardson Maturity Model ile API olgunluğunuzu ölçün — çoğu API Level 2'de olmalı
Çoğul isim kullanın (
/users,/products) — fiillerden kaçınınHTTP metotlarını semantiklerine uygun kullanın — GET okuma, POST oluşturma, PUT güncelleme, DELETE silme
Doğru status code döndürün — 201 Created, 204 No Content, 409 Conflict, 422 Unprocessable
PUT tüm kaynağı günceller, PATCH kısmi güncelleme yapar — farkı bilin
Idempotency key ile ödeme gibi kritik işlemlerde çift işlemi önleyin
RFC 7807 ProblemDetail ile tutarlı hata formatı sağlayın — Spring Boot 3+ native destekler
Nested URL'lerde ikiden fazla seviye derinliğe inmeyin
AI Asistan
Sorularını yanıtlamaya hazır