REST API tasarımı, validasyon ve hata yönetimi
Kaynak odaklı URL'den status kodu seçimine, idempotency key'den ETag'e, Bean Validation'dan ProblemDetail'e kadar production kalitesinde bir HTTP API'nin nasıl tasarlandığını ve neden öyle tasarlandığını öğretir.
Bu bölümde
Bir HTTP API'nin sözleşmesini (URL, metot, status kodu, hata gövdesi) nasıl kuracağını, Spring MVC'de validasyonu ve hata yönetimini tek bir yerde nasıl toplayacağını, retry yapan istemcilere karşı API'yi nasıl güvenli hale getireceğini öğreneceksin. Mülakatta bu konu üç şekilde gelir: live coding'de "boş controller'ı doldur" ya da "sipariş API'sini yaz" görevi (kata k5), soru-cevapta "hangi status kodu?", "PUT ile PATCH farkı?", "idempotency'yi nasıl sağlarsın?" soruları, kod okumada ise her metodunda try/catch olan bir controller'ı düzeltme.
Konu anlatımı
Kaynak odaklı URL tasarımı
REST'te URL bir kaynağı (resource) adlandırır; o kaynak üzerinde ne yapılacağını HTTP metodu söyler. Bu ayrım, aynı URL'nin farklı metotlarla farklı işler yapmasını ve ara katmanların (cache, gateway, retry yapan istemci) isteğin anlamını URL'yi parse etmeden anlamasını sağlar.
Temel kurallar:
- Çoğul isim:
/api/v1/orders,/api/v1/orders/42. Koleksiyon ile tekil eleman aynı kökten gelir./order/42ile/orderskarışımı istemciyi tahmin yürütmeye zorlar. - URL'de fiil yok:
/getOrders,/createOrder,/orders/listyerineGET /orders,POST /orders. - Küçük harf ve kebab-case:
/shipping-fees,/return-requests. URL path'i büyük/küçük harfe duyarlıdır (RFC 3986), karışık kullanım 404 avına dönüşür. - Filtre, sıralama, sayfalama query parametresinde:
GET /api/v1/orders?status=SHIPPED&sort=createdAt,desc&size=20. - Hiyerarşi sadece gerçek sahiplik varsa: Yorum bir ürüne aittir,
GET /api/v1/products/5/reviewsanlamlıdır.
Nesting sınırı. Hiyerarşiyi en fazla bir seviye derinlikte tut: /customers/7/orders iyi, /customers/7/orders/42/lines/3/discounts kötü. Neden? Derin URL her seviyede ayrı bir sahiplik kontrolü gerektirir (42 numaralı sipariş gerçekten 7 numaralı müşterinin mi?), istemciyi tüm ebeveyn id'lerini taşımaya zorlar ve alt kaynağın kendi kimliği varsa gereksizdir. Sipariş satırının global bir id'si varsa /order-lines/913 yeterlidir.
CRUD'a sığmayan aksiyonlar. "Siparişi iptal et" bir alan güncellemesi değil, kuralları olan bir iş akışıdır. İki yaygın ve kabul gören yol var:
POST /api/v1/orders/42/cancellation # aksiyonu bir alt kaynak olarak modelle
POST /api/v1/orders/42:cancel # Google API Design Guide'daki "custom method" stili
Birincisi daha "saf" REST'tir: iptal bir kaynaktır (kim, ne zaman, neden iptal etti), sonradan GET /orders/42/cancellation ile okunabilir. PATCH /orders/42 {"status":"CANCELLED"} ise durum makinesini istemciye açar: istemci DELIVERED → CANCELLED gibi geçersiz geçişleri deneyebilir ve iade, stok iadesi gibi yan etkiler bir alan değişikliğinin arkasına saklanır.
Mülakatta URL tasarımını anlatırken kuralı ezbere söyleme, nedenini ekle: "Kaynak adı çoğul, aksiyonu metot anlatıyor; böylece gateway ve cache isteği URL'den değil metottan anlıyor, GET güvenle cache'lenip retry edilebiliyor."
HTTP metotlarının semantiği: safe ve idempotent
RFC 9110 iki özelliği tanımlar:
- Safe (güvenli): İstemci sunucu durumunda bir değişiklik istemiyor. GET, HEAD, OPTIONS, TRACE. Sunucu log yazabilir, sayaç artırabilir; önemli olan istemcinin bir değişiklik talep etmemesi ve bundan sorumlu tutulmamasıdır.
- Idempotent: Aynı isteği N kez göndermenin sunucu durumu üzerindeki amaçlanan etkisi bir kez göndermekle aynıdır. Safe metotların hepsi + PUT ve DELETE.
| Metot | Safe | Idempotent | Tipik kullanım |
|---|---|---|---|
| GET | evet | evet | Okuma |
| HEAD | evet | evet | Sadece header (var mı, boyutu, ETag) |
| OPTIONS | evet | evet | CORS preflight, desteklenen metotlar |
| PUT | hayır | evet | Kaynağı verilen temsille değiştir (ya da bilinen URL'de oluştur) |
| DELETE | hayır | evet | Sil |
| POST | hayır | hayır | Oluştur, süreç başlat, aksiyon |
| PATCH | hayır | hayır (garanti yok) | Kısmi güncelleme |
İdempotency cevap hakkında değil, durum hakkındadır. İlk DELETE /orders/42 204, ikincisi 404 dönebilir; sunucudaki sonuç (sipariş yok) aynı olduğu için DELETE yine idempotenttir.
Bu sınıflandırma neden önemli? Çünkü ağda bir isteğin cevabı kaybolduğunda istemci "istek sunucuya ulaştı mı?" sorusunun cevabını bilemez. Idempotent bir isteği tekrar göndermek güvenlidir; idempotent olmayanı tekrar göndermek çift sipariş, çift ödeme demektir. HTTP istemcileri, proxy'ler ve service mesh'ler bu yüzden otomatik retry'ı genelde sadece idempotent metotlarda yapar. Sözleşmeye uymayan bir API (örneğin GET /orders/42/cancel) bu altyapının tamamını tehlikeli hale getirir: bir crawler, prefetch yapan bir tarayıcı ya da retry yapan bir gateway siparişleri iptal edebilir.
PUT, PATCH ve kısmi güncelleme
PUT hedef kaynağın durumunu gönderilen temsille tamamen değiştirir. Gövdede olmayan alan "dokunma" değil, "bu alan artık yok/boş" demektir. Bu yüzden PUT idempotenttir: aynı tam temsili on kez göndermek aynı sonucu üretir.
PATCH (RFC 5789) kaynağa uygulanacak bir değişiklik talimatı gönderir. Talimat göreli olabileceği için idempotentlik garanti değildir: {"op":"increment","path":"/stock","value":-1} her çağrıda stoğu bir azaltır. İki standart format var:
- JSON Merge Patch (RFC 7396),
application/merge-patch+json: Gövde, kaynağın "değişen kısmı" gibi görünür.nullalanı silmek anlamına gelir. Basittir ama "alanı null yap" ile "alana dokunma" farkını sadece alanın gövdede bulunup bulunmamasıyla anlatır. Java'da record ile bu farkı yakalayamazsın: iki durumda da alannullolur.JsonNodeya daOptional/JsonNullablesarmalayıcıları gerekir. - JSON Patch (RFC 6902),
application/json-patch+json: İşlem listesi (add,remove,replace,test...). Güçlüdür,testişlemiyle koşullu güncelleme yapılabilir, ama istemci için daha karmaşıktır.
Pratikte birçok ekip, kısmi güncellemeyi amaca özel komut endpoint'leriyle yapar: PUT /orders/42/shipping-address (adres alt kaynağını tamamen değiştir, idempotent) ya da POST /orders/42/cancellation. Bu, domain kurallarını tek yerde tutar ve "hangi alan değişebilir" sorusunu sözleşmeye taşır.
PATCH endpoint'inde gelen JSON'ı doğrudan entity'ye kopyalamak (BeanUtils.copyProperties(request, entity) ya da objectMapper.updateValue(entity, body)) mass assignment açığıdır: istemci status, totalAmount, customerId gibi değişmemesi gereken alanları da gönderebilir. Değişebilir alanları açıkça listeleyen bir DTO kullan.
Status kodları: doğru seçim
Status kodu, istemcinin ne yapması gerektiğini söyler: tekrar dene, isteği düzelt, giriş yap, vazgeç. Gövdeyi okumadan karar verebilmeli.
| Kod | Ne zaman | Not |
|---|---|---|
| 200 OK | Başarılı okuma ya da güncelleme, gövde var | |
| 201 Created | Yeni kaynak oluştu | Location header'ı yeni kaynağı gösterir |
| 202 Accepted | İstek kabul edildi, işlem henüz bitmedi | Durum için bir izleme URL'si döndür |
| 204 No Content | Başarılı, gövde yok | DELETE, gövdesiz PUT |
| 400 Bad Request | İstek bozuk: parse edilemeyen JSON, tip hatası, validasyon | İstemci isteği düzeltmeden tekrar denememeli |
| 401 Unauthorized | Kimlik yok ya da geçersiz (token yok, süresi dolmuş) | Adı yanıltıcı: anlamı "unauthenticated". WWW-Authenticate header'ı ile döner |
| 403 Forbidden | Kimlik belli ama bu işe yetkisi yok | Tekrar giriş yapmak çözmez |
| 404 Not Found | Hedef URI'deki kaynak yok | Yetkisiz kaynağın varlığını gizlemek için de kullanılır |
| 409 Conflict | İstek kaynağın mevcut durumuyla çakışıyor | Duplicate (unique), geçersiz durum geçişi, eşzamanlı işlem |
| 412 Precondition Failed | If-Match gibi bir ön koşul tutmadı | Optimistic concurrency |
| 422 Unprocessable Content | Sözdizimi doğru, içerik iş kuralına aykırı | RFC 9110 ile çekirdek HTTP'ye girdi (eskiden WebDAV, RFC 4918) |
| 429 Too Many Requests | Rate limit aşıldı | Retry-After ile (RFC 6585) |
| 500 Internal Server Error | Beklenmeyen sunucu hatası | Detay istemciye değil log'a |
| 502 / 504 | Upstream geçersiz cevap / upstream timeout | Gateway ve proxy'lerin kodu |
| 503 Service Unavailable | Geçici olarak hizmet verilemiyor (bakım, aşırı yük) | Retry-After eklenebilir |
Karar verirken üç soru sor:
- İstek okunabiliyor mu? JSON bozuk, zorunlu alan eksik, tip yanlış: 400.
- Kim olduğunu biliyor muyum, yetkisi var mı? Bilmiyorum: 401. Biliyorum ama yetkisi yok: 403 (ya da varlığı gizlemek için 404).
- İstek anlamlı ama yerine getirilemiyor mu? Kaynak yok: 404. Mevcut durumla çakışıyor (e-posta zaten kayıtlı, sipariş zaten kargoda): 409. İçerik iş kuralına aykırı (sepette olmayan ürün, kampanya tarihi geçmiş): 422.
400 mü 422 mi? Bu konuda sektörde tek bir doğru yok. Bir kısım ekip her şeyi 400'e toplar ve ayrımı gövdedeki hata koduyla yapar; bir kısmı "sözdizimi/şema → 400, semantik/iş kuralı → 422" der. Önemli olan tutarlılık ve istemcinin programatik olarak ayırt edebileceği bir hata kodu ("code": "PRODUCT_NOT_FOUND") döndürmektir. Mülakatta "takımın standardına uyarım; yoksa şu ayrımı öneririm" demek, tek bir doğruyu savunmaktan daha olgun bir cevaptır.
404 ile 422 farkı. 404 hedef URI'deki kaynağın olmadığını söyler. POST /orders gövdesindeki productId: 999 yoksa hedef kaynak (/orders koleksiyonu) vardır; sorun gövdenin içeriğindedir. Bu yüzden k5'teki "var olmayan ürün" için 422 (ya da takım standardına göre 400) daha doğrudur. GET /orders/999 ise 404'tür.
201 + Location. Yeni kaynağın adresini Location header'ında dönmek, istemcinin id'yi gövdeden parse etmeden kaynağa gidebilmesini sağlar. RFC 9110'a göre Location göreli bir URI de olabilir. Spring'de mevcut istek URL'sinden üretmek, gateway arkasındaki path prefix'lerini doğru yansıtır:
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(created.id())
.toUri();
return ResponseEntity.created(location).body(created);
Proxy arkasında fromCurrentRequest dış host yerine iç host'u üretebilir; Boot'ta server.forward-headers-strategy=framework ile X-Forwarded-* header'larını hesaba kattır. Göreli URI (URI.create("/api/v1/orders/" + id)) bu problemi baştan önler.
202 Accepted dürüst bir asenkron sözleşmedir: "İsteği aldım, işleyeceğim, sonucu şuradan takip et." Rapor üretimi, toplu fiyat güncellemesi gibi saniyeler-dakikalar süren işler için:
POST /api/v1/price-imports -> 202 Accepted
Location: /api/v1/price-imports/7f3c
GET /api/v1/price-imports/7f3c -> 200 {"status":"RUNNING","processed":1200,"total":5000}
Idempotency key ile güvenli POST
POST idempotent değildir ama sipariş ve ödeme gibi kritik POST'ları retry'a dayanıklı yapmak zorundayız: mobil uygulama zayıf ağda timeout alır ve aynı isteği tekrar gönderir. Çözüm: istemci her mantıksal işlem için benzersiz bir anahtar üretir (UUID v4) ve tüm denemelerde aynı anahtarı Idempotency-Key header'ında gönderir. Sunucu anahtarı ilk işlemin sonucuyla birlikte saklar; aynı anahtarla gelen tekrar isteğe işlemi yeniden yapmadan ilk cevabı döner.
Bu header için IETF'te bir standart taslağı yazıldı (draft-ietf-httpapi-idempotency-key-header; RFC olmadı, son sürümün süresi dolmuş durumda) ve Stripe gibi ödeme API'lerinin yıllardır kullandığı kalıba dayanıyor. Taslağın önerdiği hata davranışı:
| Durum | Önerilen cevap |
|---|---|
| Anahtar zorunlu ama gönderilmemiş | 400 |
| Aynı anahtar farklı gövdeyle tekrar kullanılmış | 422 |
| Aynı anahtarla ilk istek hâlâ işleniyor | 409 |
Tasarım kararları:
- Anahtarın kapsamı: Global unique değil,
(customer_id, idempotency_key)çifti unique olmalı. Aksi halde iki farklı müşterinin rastgele çakışan anahtarı birbirinin siparişini döndürür; bu bir veri sızıntısıdır. - Parmak izi (fingerprint): İsteğin hash'ini anahtarla birlikte sakla. Aynı anahtar farklı gövdeyle gelirse eski cevabı dönmek yanlıştır (istemci hatası gizlenir): 422 dön.
- Saklama süresi: Anahtarlar sonsuza kadar tutulmaz. Stripe, anahtarları en az 24 saat sonra silinebilir olarak tanımlar. Kendi süreni retry penceresine göre seç ve dokümante et.
- Yarışı kapatan şey unique constraint'tir. "Önce bak, yoksa ekle" iki eşzamanlı istekte ikisinin de "yok" görmesine izin verir. PostgreSQL'de ikinci INSERT, birincinin transaction'ı bitene kadar unique index üzerinde bekler; birinci commit ederse ikinci
unique_violation(SQLSTATE 23505) alır, rollback ederse ikinci devam eder.
ALTER TABLE orders
ADD COLUMN idempotency_key VARCHAR(255),
ADD COLUMN request_hash CHAR(64);
CREATE UNIQUE INDEX ux_orders_customer_idem
ON orders (customer_id, idempotency_key)
WHERE idempotency_key IS NOT NULL;
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final OrderCreator orderCreator; // @Transactional create burada
private final RequestFingerprint fingerprint;
public OrderService(OrderRepository orderRepository, OrderCreator orderCreator, RequestFingerprint fingerprint) {
this.orderRepository = orderRepository;
this.orderCreator = orderCreator;
this.fingerprint = fingerprint;
}
// Bilerek @Transactional DEĞİL: içteki transaction tamamen bitsin (commit ya da rollback),
// yarışı kaybedersek kazananı yeni ve temiz bir okumayla bulalım.
public OrderResponse create(long customerId, String idempotencyKey, CreateOrderRequest request) {
String hash = fingerprint.of(request);
Optional<Order> existing = orderRepository.findByCustomerIdAndIdempotencyKey(customerId, idempotencyKey);
if (existing.isPresent()) {
return replay(existing.get(), hash);
}
try {
return orderCreator.create(customerId, idempotencyKey, hash, request);
} catch (DataIntegrityViolationException e) {
// Eşzamanlı aynı anahtar: diğer istek bizden önce commit etti.
// Kazanan yoksa ihlal başka bir constraint'tendir: hatayı olduğu gibi fırlat.
Order winner = orderRepository.findByCustomerIdAndIdempotencyKey(customerId, idempotencyKey)
.orElseThrow(() -> e);
return replay(winner, hash);
}
}
private OrderResponse replay(Order order, String hash) {
if (!order.getRequestHash().equals(hash)) {
throw new IdempotencyKeyReusedException(order.getIdempotencyKey()); // -> 422
}
return OrderResponse.from(order);
}
}
Neden create metodunun kendisi @Transactional değil? JPA'da flush sırasında constraint ihlali olunca transaction rollback-only olarak işaretlenir ve persistence context güvenilmez hale gelir. Kazananı aynı transaction içinde okumaya çalışmak ya ikinci bir exception'a ya da bayat veriye götürür. Transaction'ı ayrı bir bean'e (OrderCreator) koymak hem self-invocation problemini önler hem de yeniden okumayı yeni bir transaction'da yapar.
Bu "sonucu sakla, tekrar dön" yaklaşımı sadece senkron sonucu olan işlemler içindir. Ödeme sağlayıcısını çağıran bir işlemde ilk istek yarıda kalmış olabilir (sipariş yazıldı, ödeme cevabı gelmedi); o durumda anahtarın bir status alanı (IN_PROGRESS, COMPLETED, FAILED) olur ve tekrar gelen istek duruma göre 409 alır ya da işlem kaldığı yerden devam ettirilir.
ETag ve If-Match ile optimistic concurrency
Senaryo: Satıcı paneli ürün açıklamasını açar, iki operatör aynı anda düzenler. İkisi de PUT yaparsa sonra gelen, öncekinin değişikliğini sessizce ezer (lost update). HTTP'nin buna cevabı koşullu istektir:
GET /products/5cevabı birETagtaşır: kaynağın o anki sürümünün etiketi (ETag: "7").- İstemci güncellerken
If-Match: "7"gönderir: "Sadece kaynak hâlâ 7. sürümdeyse uygula." - Sunucu güncel sürümü 8 ise 412 Precondition Failed döner. İstemci yeniden okur, birleştirir, tekrar dener.
- Sunucu koşullu güncellemeyi zorunlu tutmak istiyorsa ve
If-Matchgelmemişse 428 Precondition Required (RFC 6585) dönebilir.
JPA'daki @Version alanı ETag için doğal kaynaktır. Kontrol iki katmanlıdır: önce gelen sürümü okunan sürümle karşılaştırırsın; okuma ile commit arasındaki yarışı ise @Version'ın ürettiği UPDATE ... WHERE id = ? AND version = ? yakalar.
@GetMapping("/{id}")
public ResponseEntity<ProductResponse> get(@PathVariable long id) {
ProductResponse product = productService.get(id);
return ResponseEntity.ok()
.eTag("\"" + product.version() + "\"")
.body(product);
}
@PutMapping("/{id}/description")
public ResponseEntity<ProductResponse> updateDescription(
@PathVariable long id,
@RequestHeader(name = HttpHeaders.IF_MATCH, required = false) String ifMatch,
@Valid @RequestBody UpdateDescriptionRequest request) {
if (ifMatch == null) {
throw new PreconditionRequiredException(); // -> 428
}
long expectedVersion = Long.parseLong(ifMatch.replace("\"", "")); // gerçek kodda format kontrolü
ProductResponse updated = productService.updateDescription(id, expectedVersion, request);
return ResponseEntity.ok().eTag("\"" + updated.version() + "\"").body(updated);
}
@Transactional
public ProductResponse updateDescription(long id, long expectedVersion, UpdateDescriptionRequest request) {
Product product = productRepository.findById(id).orElseThrow(() -> new ProductNotFoundException(id));
if (product.getVersion() != expectedVersion) {
throw new PreconditionFailedException(id, expectedVersion, product.getVersion()); // -> 412
}
product.changeDescription(request.description());
return ProductResponse.from(productRepository.saveAndFlush(product)); // yarış: OptimisticLockException
}
Commit sırasındaki çakışma Spring'de ObjectOptimisticLockingFailureException olarak gelir; bunu da merkezi handler'da 412'ye (ya da ETag kullanmıyorsan 409'a) çevir. ETag'in GET tarafında ikinci bir faydası var: istemci If-None-Match: "7" gönderirse ve sürüm değişmemişse sunucu gövdesiz 304 Not Modified döner. Spring'in ShallowEtagHeaderFilter'ı bunu gövdenin hash'iyle otomatik yapar ama gövdeyi yine de üretir; sadece bant genişliği kazandırır, sunucu işini azaltmaz.
Sayfalama, filtreleme ve sıralama
Liste endpoint'i asla sınırsız olmamalı. Bugün 50 kaydı olan müşteri iki yıl sonra 50.000 kayıtla tek istekte heap'i doldurur.
Offset sayfalama (?page=500&size=20): SQL'de LIMIT 20 OFFSET 10000.
- Artı: Basit, "47. sayfaya git" mümkün, toplam sayfa sayısı gösterilebilir.
- Eksi: Veritabanı atlanan 10.000 satırı yine de okuyup atar; derin sayfalar yavaşlar. Sayfalar arasında yeni kayıt eklenirse kayma olur: kullanıcı aynı kaydı iki kez görür ya da birini hiç görmez. Spring Data'nın
Pagedönüşü her istekte ek birCOUNT(*)sorgusu çalıştırır; büyük tabloda bu sorgu asıl sorgudan pahalı olabilir.
Cursor (keyset) sayfalama (?after=<opaque-token>&size=20): Son görülen kaydın sıralama anahtarından devam edilir.
-- index: (customer_id, created_at DESC, id DESC)
SELECT id, status, total_amount, created_at
FROM orders
WHERE customer_id = :customerId
AND (created_at, id) < (:lastCreatedAt, :lastId) -- row value karşılaştırması
ORDER BY created_at DESC, id DESC
LIMIT 20;
- Artı: Her sayfa index'te doğrudan konumlanır (index tasarımı için bkz. Bölüm 10); sayfa 1 ile sayfa 10.000 aynı hızdadır. Araya kayıt eklenmesi kaymaya yol açmaz. Sonsuz kaydırma (mobil feed) ve toplu dışa aktarma için idealdir.
- Eksi: Rastgele sayfaya atlanamaz, toplam sayı doğal olarak gelmez. Sıralama anahtarı benzersiz olmalıdır: sadece
created_atile sıralarsan aynı zaman damgalı kayıtlar sayfa sınırında kaybolur; bu yüzdenidikinci anahtar olarak eklenir.
Cursor'ı istemciye opak bir token olarak ver. İstemci içeriğine bağımlı olmasın; sen de ileride sıralama anahtarını değiştirebil:
record OrderCursor(Instant createdAt, long id) {
String encode() {
String raw = createdAt + "|" + id; // ISO-8601, tam hassasiyet
return Base64.getUrlEncoder().withoutPadding().encodeToString(raw.getBytes(StandardCharsets.UTF_8));
}
static OrderCursor decode(String token) {
try {
String raw = new String(Base64.getUrlDecoder().decode(token), StandardCharsets.UTF_8);
int sep = raw.indexOf('|');
return new OrderCursor(Instant.parse(raw.substring(0, sep)), Long.parseLong(raw.substring(sep + 1)));
} catch (RuntimeException e) { // bozuk base64, eksik ayraç, format hatası
throw new InvalidCursorException(token); // -> 400
}
}
}
Zaman damgasını cursor'a milisaniyeye yuvarlayarak koyma. PostgreSQL timestamptz mikrosaniye hassasiyetindedir; yuvarlanmış değerle (created_at, id) < karşılaştırması sayfa sınırındaki kayıtları atlayabilir ya da tekrarlayabilir. Instant.toString() tam hassasiyeti korur.
Spring Data tarafında seçenekler: Page<T> (count sorgulu offset), Slice<T> (count yok, sadece "sonraki var mı" için size + 1 kayıt çeker), Spring Data 3.1+ ile ScrollPosition ve Window<T> (keyset scrolling desteği). Page'i doğrudan JSON olarak dönme: Spring Data 3.1'den beri bu konuda uyarı log'lar, çünkü PageImpl'in JSON şekli bir sözleşme değildir. Spring Data 3.3+ ile PagedModel ya da @EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO) kullan, ya da kendi PageResponse<T> record'unu dön.
Filtreleme ve sıralama query parametresindedir: ?status=SHIPPED&createdFrom=2026-10-01&sort=createdAt,desc. İki güvenlik/performans kuralı:
sizeiçin üst sınır koy. Spring Data'nın varsayılan üst sınırı 2000'dir; çoğu API için fazla.spring.data.web.pageable.max-page-sizeile düşür.- Sıralama alanlarını beyaz listeyle sınırla. Spring Data dokümantasyonu, istekten çözülen
Sortdeğerlerinin domain modeline karşı doğrulanmadığını ve bunun uygulamanın sorumluluğu olduğunu açıkça söyler. Index'i olmayan bir kolona göre sıralama full scan'dir; hassas bir kolona (password_hash) göre sıralama ise bilgi sızdırabilir.
Versiyonlama
API geriye dönük uyumsuz değiştiğinde versiyonlanır. Uyumlu değişiklikler (yeni opsiyonel alan, yeni endpoint) versiyon gerektirmez; istemcilerin bilinmeyen alanları yok sayması (Jackson'da FAIL_ON_UNKNOWN_PROPERTIES=false, Spring Boot'un varsayılanı) bu yüzden önemlidir.
| Strateji | Örnek | Artı | Eksi |
|---|---|---|---|
| URI path | /api/v1/orders | Görünür, log'da ve gateway routing'de kolay, cache dostu | "Kaynak aynı, URI farklı" REST açısından tartışmalı |
| Header | X-API-Version: 2 | URI temiz kalır | Görünmez; cache için Vary gerekir; tarayıcıdan test zor |
| Media type | Accept: application/vnd.shop.order.v2+json | HTTP'nin content negotiation modeline en uygun | En karmaşık; araç desteği zayıf |
| Query param | ?version=2 | Basit | Opsiyonel parametre gibi görünür, unutulur |
Pratikte en yaygın ve mülakatta en rahat savunulan yol URI path'te major versiyondur. Spring Framework 7 (Boot 4) ile Spring MVC'ye yerleşik API versiyonlama geldi: aynı path farklı versiyonlar için ayrı metotlara map'lenebiliyor ve versiyonun header'dan, query parametresinden, media type'tan ya da path'ten okunacağı yapılandırılabiliyor:
spring:
mvc:
apiversion:
default: 1.0.0
use:
header: X-Version
Versiyonlamanın asıl zor kısmı teknik değil, yaşam döngüsüdür: v1'i ne kadar destekleyeceksin, istemcilere nasıl duyuracaksın, kimin hâlâ v1 çağırdığını nasıl ölçeceksin. Versiyon başına istek metriği tutmak ve Deprecation/Sunset header'larıyla bitiş tarihini önceden duyurmak bu yüzden önemlidir.
Bean Validation
Spring Boot'ta validasyon spring-boot-starter-validation ile gelir (Hibernate Validator, Jakarta Bean Validation implementasyonu). Boot 3 ile paketler javax.validation yerine jakarta.validation oldu; eski bir Boot 2.x projesinde import'ları buna göre yaz.
Request body: @Valid @RequestBody ile tetiklenir, hata olursa MethodArgumentNotValidException fırlar, Spring bunu 400'e çevirir.
public record CreateOrderRequest(
@NotNull Long customerId,
@NotEmpty @Size(max = 50) List<@Valid @NotNull OrderItemRequest> items,
@Size(max = 500) String note) {}
public record OrderItemRequest(
@NotNull Long productId,
@Positive @Max(100) int quantity) {}
Cascade kuralı: Bean Validation iç içe nesnelere kendiliğinden inmez. items listesinin elemanlarındaki @Positive kuralının çalışması için cascade'i açıkça istemen gerekir: List<@Valid OrderItemRequest> (container element constraint, Bean Validation 2.0+) ya da alanın üstüne @Valid. Bu, k5 ve z4'ün asıl konusudur.
Path ve query parametreleri: Spring Framework 6.1+ ile Spring MVC'de yerleşik method validation var. Constraint'i doğrudan parametreye koyarsan (@RequestParam @Min(1) int page) MVC bunu kendisi doğrular ve HandlerMethodValidationException fırlatır (o da 400'e çevrilir). Bu durumda method validation, aynı metottaki @Valid @RequestBody'yi de kapsar ve body hatası da artık MethodArgumentNotValidException değil HandlerMethodValidationException olarak gelir. Spring dokümantasyonu bu yüzden iki exception'ın da ele alınmasını önerir.
Eski yaklaşım controller sınıfına @Validated koymaktı. Bu, AOP proxy tabanlı method validation'ı açar ve hata ConstraintViolationException olarak gelir; bunu açıkça ele almazsan 500 döner. Spring 6.1+ dokümantasyonu, yerleşik desteği kullanmak için controller'daki sınıf seviyesindeki @Validated'ın kaldırılmasını söyler.
@Valid ve @Validated farkı:
@Valid(Jakarta): cascade'i işaretler ve Spring MVC'de argüman validasyonunu tetikler. Grup almaz.@Validated(Spring): validasyon grubu seçebilir; sınıf seviyesinde ise AOP ile method validation'ı açar (servis katmanında kullanılan yol budur).
Gruplar: Aynı DTO'nun oluşturma ve güncellemede farklı kurallara sahip olması için:
public interface OnCreate extends Default {} // Default'u genişletmezsen gruptan bağımsız kurallar ÇALIŞMAZ
public interface OnUpdate extends Default {}
public record ProductRequest(
@Null(groups = OnCreate.class) @NotNull(groups = OnUpdate.class) Long id,
@NotBlank String name,
@NotNull @DecimalMin("0.01") BigDecimal price) {}
@PostMapping
public ResponseEntity<ProductResponse> create(@Validated(OnCreate.class) @RequestBody ProductRequest request) { ... }
Tuzak: Bir grup belirttiğinde sadece o gruptaki constraint'ler çalışır. Grup vermediğin @NotBlank name Default grubundadır; OnCreate Default'u genişletmiyorsa bu kural sessizce atlanır. Gruplar çoğaldıkça okunabilirlik düşer; birçok ekip bunun yerine CreateProductRequest ve UpdateProductRequest diye iki ayrı DTO kullanmayı tercih eder.
Custom validator: Birden fazla alanı ilgilendiren kurallar (kampanya başlangıcı bitişten önce olmalı) için sınıf seviyesinde constraint:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = DateRangeValidator.class)
public @interface ValidDateRange {
String message() default "startDate must be on or before endDate";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class DateRangeValidator implements ConstraintValidator<ValidDateRange, CampaignRequest> {
@Override
public boolean isValid(CampaignRequest r, ConstraintValidatorContext ctx) {
if (r.startDate() == null || r.endDate() == null) {
return true; // null kontrolü @NotNull'ın işi; kuralları tek sorumlu tut
}
return !r.endDate().isBefore(r.startDate());
}
}
@ValidDateRange
public record CampaignRequest(@NotNull LocalDate startDate, @NotNull LocalDate endDate, @NotBlank String name) {}
Sınıf seviyesindeki ihlal bir field error değil global error'dur (BindingResult.getGlobalErrors()). Hata handler'ın sadece getFieldErrors()'ı dönüyorsa bu hata istemciye mesajsız bir 400 olarak gider.
Validasyon nerede? Controller'daki Bean Validation şekil kontrolüdür: alan var mı, pozitif mi, uzunluk uygun mu. "Bu ürün satışta mı", "müşterinin kredi limiti yeterli mi" gibi veriye bağlı kurallar domain/servis katmanında kontrol edilir ve domain exception'ı fırlatır. Veritabanı constraint'leri (NOT NULL, UNIQUE, CHECK, FOREIGN KEY) ise son savunma hattıdır; uygulama kodundaki her kontrol yarışa açıktır, constraint değildir.
Merkezi hata yönetimi ve ProblemDetail
Her controller metodunda try/catch yazmak üç problem yaratır: tekrar eden kod, tutarsız hata formatı ve yanlış status kodları (her şey 500 olur). Spring'in cevabı @RestControllerAdvice ile merkezi exception handling'dir: controller'dan (ve servisten) fırlayan exception'lar tek bir sınıfta HTTP cevabına çevrilir.
RFC 9457 Problem Details (RFC 7807'nin yerini aldı), hata gövdesi için standart bir JSON formatı tanımlar, media type application/problem+json:
{
"type": "https://api.example.com/problems/product-not-found",
"title": "Product not found",
"status": 422,
"detail": "Products [999] do not exist",
"instance": "/api/v1/orders",
"code": "PRODUCT_NOT_FOUND",
"errors": []
}
type hatanın türünü tanımlayan bir URI'dir (verilmezse about:blank); title insan için kısa özet; detail bu olaya özel açıklama; instance olayın kendisi. Standart alanların dışına code, errors, traceId gibi extension alanları eklenebilir.
Spring Framework 6 / Boot 3 ile gelen destek:
ProblemDetailsınıfı veErrorResponsearayüzü. Spring MVC'nin kendi exception'ları (MethodArgumentNotValidException,HttpMessageNotReadableException,NoResourceFoundException...)ErrorResponseuygular ve hazır birProblemDetailgövdesi taşır.ResponseEntityExceptionHandler: Spring MVC'nin standart exception'larınıProblemDetail'e çeviren temel sınıf. Kendi advice'ını bundan türetirsen bu dönüşümleri bedavaya alır, istediğini override edersin.- Boot'ta
spring.mvc.problemdetails.enabled=true: Kendi advice'ın yoksa Boot birResponseEntityExceptionHandleradvice'ı kaydeder ve MVC hatalarıapplication/problem+jsondöner. Varsayılanfalse'tur.
Domain exception hiyerarşisi. HTTP'yi domain'e sızdırmadan, kategoriye göre hata modellemek:
public abstract sealed class DomainException extends RuntimeException
permits NotFoundException, ConflictException, BusinessRuleException {
private final String code;
protected DomainException(String code, String message) {
super(message);
this.code = code;
}
public String code() { return code; }
}
public non-sealed class NotFoundException extends DomainException {
public NotFoundException(String code, String message) { super(code, message); }
}
public non-sealed class ConflictException extends DomainException {
public ConflictException(String code, String message) { super(code, message); }
}
public non-sealed class BusinessRuleException extends DomainException {
public BusinessRuleException(String code, String message) { super(code, message); }
}
public final class OrderNotFoundException extends NotFoundException {
public OrderNotFoundException(long id) { super("ORDER_NOT_FOUND", "Order " + id + " not found"); }
}
Domain katmanı HttpStatus bilmez; kategoriden status'a eşleme web katmanındaki advice'ın işidir. sealed hiyerarşi sayesinde Java 21'in pattern matching switch'i exhaustive olur: yeni bir kategori eklersen derleyici handler'ı güncellemeni zorlar.
@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class);
@ExceptionHandler(DomainException.class)
ProblemDetail handleDomain(DomainException ex) {
HttpStatus status = switch (ex) {
case NotFoundException e -> HttpStatus.NOT_FOUND;
case ConflictException e -> HttpStatus.CONFLICT;
case BusinessRuleException e -> HttpStatus.UNPROCESSABLE_ENTITY;
};
ProblemDetail pd = ProblemDetail.forStatusAndDetail(status, ex.getMessage());
pd.setType(URI.create("https://api.example.com/problems/" + ex.code().toLowerCase().replace('_', '-')));
pd.setProperty("code", ex.code());
return pd;
}
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
ProblemDetail pd = ex.getBody(); // Spring'in hazırladığı 400 gövdesi
pd.setDetail("Request validation failed");
List<Violation> violations = new ArrayList<>();
ex.getBindingResult().getFieldErrors()
.forEach(fe -> violations.add(new Violation(fe.getField(), fe.getDefaultMessage())));
ex.getBindingResult().getGlobalErrors() // sınıf seviyesindeki kurallar
.forEach(ge -> violations.add(new Violation(ge.getObjectName(), ge.getDefaultMessage())));
pd.setProperty("code", "VALIDATION_FAILED");
pd.setProperty("errors", violations);
return handleExceptionInternal(ex, pd, headers, status, request);
}
@ExceptionHandler(ObjectOptimisticLockingFailureException.class)
ProblemDetail handleOptimisticLock(ObjectOptimisticLockingFailureException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.PRECONDITION_FAILED,
"Resource was modified by another request; reload and retry");
}
@ExceptionHandler(AccessDeniedException.class)
void rethrowAccessDenied(AccessDeniedException ex) {
throw ex; // Spring Security 403'ü kendisi üretsin
}
@ExceptionHandler(Exception.class)
ProblemDetail handleUnexpected(Exception ex, HttpServletRequest request) {
String errorId = UUID.randomUUID().toString();
log.error("Unhandled exception errorId={} method={} path={}",
errorId, request.getMethod(), request.getRequestURI(), ex);
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
"Unexpected error. Reference: " + errorId);
pd.setProperty("errorId", errorId);
return pd;
}
record Violation(String field, String message) {}
}
Bu kodda dikkat edilecek noktalar:
- Handler seçimi en spesifik tipe göredir.
@ExceptionHandler(Exception.class)sadece daha özel bir handler'ı olmayan exception'ları yakalar.ResponseEntityExceptionHandler'ın standart MVC exception'ları için tanımladığı handler'lar daha spesifik olduğu için öncelik onlardadır. - Catch-all handler güvenliği bozabilir. Method security (
@PreAuthorize) controller çağrısındaAccessDeniedExceptionfırlatır ve bu exception MVC'nin exception resolver'larından geçer. Catch-all handler onu yakalarsa kullanıcı 403 yerine 500 alır. Ya ayrı ele al ya da yukarıdaki gibi yeniden fırlat. - 500'de iç detay yok. Stack trace, SQL, tablo/kolon adı, sınıf adı, iç servis URL'si istemciye gitmez: saldırgana keşif bilgisi verir, kişisel veri sızdırabilir. İstemciye bir
errorId(ya da tracing kullanıyorsantraceId) ver; destek ekibi log'da bu id ile arar. Boot'un varsayılan/errorcevabında da mesaj ve stack trace varsayılan olaraknever'dır (Boot 3.x'teserver.error.include-message,server.error.include-stacktrace; Boot 4'te bu özelliklerspring.web.error.*altına taşındı). Bunları production'da açma. - Log seviyesi. 4xx istemci hatasıdır:
WARNya daINFO, stack trace'siz. 5xx iseERRORve stack trace'li. Her 404'üERRORile log'lamak alarm yorgunluğu yaratır.
Eski bir Spring sürümünde (Boot 2.x) ProblemDetail yoktur. Live coding'de proje Boot 2.x ile gelirse aynı şekli kendi record'unla kur: record ApiError(String code, String message, List<Violation> errors). Mülakatçıya "Boot 3'te bunun yerine standart ProblemDetail kullanırdım" demen yeterli.
Katmanlar: DTO, entity ve transaction sınırı
Entity'yi API'den dönme. Nedenleri mekanizmaya dayanır:
- Lazy loading: JSON serileştirme lazy bir ilişkiye dokunduğunda ya
LazyInitializationExceptionalırsın (persistence context kapalı) ya da Open Session in View açıksa view render sırasında fark etmeden onlarca sorgu çalıştırırsın. - Sızıntı: Entity'deki her alan (iç notlar, maliyet fiyatı, ödeme referansı) sözleşmeye girer.
- Döngü: Çift yönlü ilişkiler (
Order.customer↔Customer.orders) sonsuz JSON döngüsüne girer. - Kilitlenme: API sözleşmesi tablo şemasına bağlanır; kolon adını değiştirmek istemcileri kırar.
- Mass assignment: Request olarak entity almak, istemcinin her alanı yazabilmesi demektir.
Request ve response için ayrı record DTO'lar kullan. Mapping'i elle (statik from metodu) ya da MapStruct ile yap; MapStruct derleme zamanında kod ürettiği için reflection maliyeti yoktur.
@Transactional servis katmanında. Transaction sınırı iş akışının atomiklik sınırıdır: "siparişi kaydet, stoğu düş, outbox'a event yaz" ya birlikte olur ya hiç olmaz. Bu sınırı bilen katman servistir.
- Controller'a koyarsan transaction (ve veritabanı connection'ı) JSON parse, validasyon, serileştirme ve bazen yavaş istemciye yazma boyunca açık kalır. Hikari havuzu 10 connection ise 10 yavaş istek tüm uygulamayı kilitler.
- Her repository metoduna ayrı ayrı koyarsan iki yazma ayrı transaction'larda olur, aradaki hata yarım veri bırakır.
- Transaction içinde harici HTTP çağrısı yapma: Ödeme servisi 5 saniye cevap vermezse connection 5 saniye boşta tutulur; ayrıca HTTP çağrısı rollback edilemez (para çekildi, DB rollback oldu). Harici çağrıyı transaction dışına al ya da outbox ile asenkron yap (bkz. Bölüm 13).
- Okuma metotlarında
@Transactional(readOnly = true): Hibernate dirty checking için snapshot tutmaz ve flush yapmaz; bazı kurulumlarda read replica'ya yönlendirme için de bu işaret kullanılır. @Transactionalproxy ile çalışır: aynı sınıf içindenthis.method()çağrısı proxy'den geçmez (self-invocation),privatemetotlarda da çalışmaz. Varsayılan olarak sadece unchecked exception'larda rollback yapar (ayrıntılar için bkz. Bölüm 8).
OpenAPI ve sözleşme
OpenAPI (eski adıyla Swagger) API sözleşmesinin makine tarafından okunabilir tanımıdır: endpoint'ler, şemalar, hata cevapları, güvenlik şemaları. Spring Boot 3 için yaygın araç springdoc-openapi'dir (springdoc-openapi-starter-webmvc-ui); controller'lardan ve Bean Validation annotation'larından spesifikasyonu üretir, Swagger UI sunar.
İki yaklaşım vardır:
- Code-first: Kodu yazarsın, spesifikasyon üretilir. Hızlıdır; ama sözleşme kodun yan ürünü olur ve kazara değişebilir.
- Contract-first (API-first): Önce
openapi.yamlyazılır, review edilir, ondan sunucu arayüzleri ve istemci kodu üretilir (openapi-generator). Birden fazla takımın aynı API'ye paralel çalıştığı durumda daha güvenlidir.
Hangisini seçersen seç, sözleşmenin kazara kırılmasını CI'da yakala: üretilen spesifikasyonu önceki sürümle karşılaştıran bir "breaking change" kontrolü (ör. openapi-diff) ya da tüketici odaklı contract testleri (Spring Cloud Contract, Pact).
Harici HTTP çağrıları: client seçimi ve timeout
Spring ekosistemindeki seçenekler ve güncel durumları:
| Client | Model | Durum |
|---|---|---|
RestTemplate | Senkron, template metotlar | Spring ekibi Eylül 2025'te deprecation planını açıkladı: 7.0'da niyet duyuruldu, 7.1'de (Kasım 2026 planlanan) @Deprecated olarak işaretlenecek, 8.0'da kaldırılacak. Yeni kodda kullanma. |
RestClient | Senkron, akıcı (fluent) API | Spring Framework 6.1 / Boot 3.2+. Spring ekibinin çoğu uygulama için önerdiği client. |
WebClient | Reactive, non-blocking | WebFlux bağımlılığı getirir. Reactive uygulamalar ve streaming için. Klasik MVC uygulamasında sadece HTTP client olarak eklemek çoğu zaman gereksiz karmaşıklıktır. |
HTTP interface (@HttpExchange) | Deklaratif arayüz | Spring 6+. RestClient ya da WebClient üzerinde çalışır. Spring 7 ile @ImportHttpServices ile gruplar halinde yapılandırma. |
| OpenFeign | Deklaratif arayüz | Spring Cloud OpenFeign projesi feature-complete kabul ediliyor: sadece bug fix alıyor; Spring dokümantasyonu HTTP Service Client'lara (HTTP interface) geçişi öneriyor. Spring Cloud sürümünün Boot sürümüyle uyumlu olması gerekir. |
Timeout olmadan dış çağrı yapma. new RestTemplate() arka planda HttpURLConnection kullanır ve connect/read timeout tanımlı değildir. Read timeout'suz bir çağrı, karşı taraf cevap vermezse thread'i sonsuza kadar bekletir. 200 thread'lik Tomcat havuzunda yavaşlayan tek bir sağlayıcı, ilgisiz endpoint'ler dahil tüm uygulamayı durdurur (thread starvation). Bu, kaskad arızaların en klasik sebebidir.
@Configuration
class PaymentClientConfig {
@Bean
RestClient paymentRestClient(RestClient.Builder builder, PaymentProperties props) {
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(1)) // TCP bağlantısı kurma süresi
.build();
JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(httpClient);
factory.setReadTimeout(Duration.ofSeconds(3)); // cevap bekleme süresi
return builder
.baseUrl(props.baseUrl())
.requestFactory(factory)
.build();
}
}
public PaymentResult authorize(PaymentRequest request, String idempotencyKey) {
return paymentRestClient.post()
.uri("/payments")
.header("Idempotency-Key", idempotencyKey)
.body(request)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, (req, res) -> {
throw new PaymentRejectedException(res.getStatusCode().value());
})
.body(PaymentResult.class);
}
Boot'un auto-configure ettiği RestClient.Builder'ı enjekte etmek, Jackson ayarları ve observability (Micrometer tracing) entegrasyonunu otomatik getirir. Timeout'u property ile de verebilirsin: Boot 3.4-3.5'te spring.http.client.connect-timeout / read-timeout, Boot 4'te spring.http.clients.connect-timeout / read-timeout. Client'ı her çağrıda new ile üretme: connection pool, TLS oturumu ve yapılandırma paylaşılmaz.
Deklaratif HTTP interface örneği:
@HttpExchange("/shipments")
public interface ShippingClient {
@PostExchange
ShipmentResponse create(@RequestBody CreateShipmentRequest request);
@GetExchange("/{id}")
ShipmentResponse get(@PathVariable String id);
}
@Bean
ShippingClient shippingClient(RestClient shippingRestClient) {
return HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(shippingRestClient))
.build()
.createClient(ShippingClient.class);
}
Dış çağrının dört kelimesi: timeout, retry, circuit breaker, idempotency. Retry sadece idempotent işlemlerde ya da idempotency key ile yapılır; exponential backoff ve jitter olmadan yapılan retry, zaten zorlanan servisi daha da boğar (retry storm). Circuit breaker (Resilience4j) sürekli hata veren bir bağımlılığa çağrıyı keserek thread'leri korur (bkz. Bölüm 14).
Güvenlik temelleri
- Authentication (kimlik doğrulama): "Sen kimsin?" Token geçerli mi, imzası doğru mu, süresi dolmuş mu. Başarısızsa 401 ve
WWW-Authenticateheader'ı. - Authorization (yetkilendirme): "Bunu yapmaya yetkin var mı?" Rol, kapsam (scope) ve kaynak sahipliği. Başarısızsa 403.
IDOR / BOLA. Insecure Direct Object Reference: GET /api/v1/orders/1001 çağrısında kimliği doğrulanmış kullanıcının 1001 numaralı siparişin sahibi olup olmadığı kontrol edilmezse, id'yi bir artırarak başkalarının siparişleri okunur. OWASP API Security Top 10 (2023) listesinin birinci maddesi bu yüzden Broken Object Level Authorization'dır. Kontrol her kaynak erişiminde yapılmalıdır, ideal olarak sorgunun kendisinde:
@Transactional(readOnly = true)
public OrderResponse getForCustomer(long orderId, long authenticatedCustomerId) {
return orderRepository.findByIdAndCustomerId(orderId, authenticatedCustomerId)
.map(OrderResponse::from)
.orElseThrow(() -> new OrderNotFoundException(orderId)); // 403 değil 404: varlığı da gizle
}
Başkasına ait kaynakta 403 yerine 404 dönmek, saldırganın "bu id'de bir sipariş var" bilgisini almasını engeller. customerId'yi asla istekten (path, body) alıp güvenme; kimlik token'ından (Spring Security Authentication) gelmeli. Ardışık sayısal id'ler tahmini kolaylaştırır; dışarıya UUID ya da ULID açmak savunmayı derinleştirir ama yetki kontrolünün yerini tutmaz.
Rate limiting. Kaba kuvvet (kupon denemesi, login), kazıma ve kaynak tüketimine karşı. Reddedilen isteğe 429 ve Retry-After. Limitin anahtarı kullanıcı, API key, IP ya da bunların bileşimi olabilir; algoritmalar ve dağıtık uygulaması için (bkz. Bölüm 19).
Diğer temel kurallar: Girdi ne kadar doğrulanmış olursa olsun SQL'i parametreli kur; log'a token, kart numarası, şifre ve gereksiz kişisel veri yazma (KVKK, PCI DSS); CORS'u * ile açma; hata mesajlarında kullanıcı varlığı gibi bilgileri sızdırma.
Quiz'deki sorular: çözüm ve nedenleri
k5: Sipariş API'sini sıfırdan yaz
Senaryo: ProductRepository, OrderRepository, Order/OrderLine entity'leri hazır. POST /api/v1/orders (gövde: customerId ve items) ile GET /api/v1/orders/{id} yazılacak. Beklenenler: DTO'lar, validasyon (400), var olmayan ürün ve sipariş için anlamlı hatalar, 201 + Location, toplamın sunucuda hesaplanması ve mobil retry'da çift sipariş oluşmaması.
Çözümün iskeleti ve her kararın nedeni:
- Record DTO'lar:
CreateOrderRequest(@NotNull Long customerId, @NotEmpty List<@Valid OrderItemRequest> items),OrderItemRequest(@NotNull Long productId, @Positive int quantity),OrderResponse. Entity dışarı açılmaz (lazy loading, sızıntı, sözleşmenin şemaya kilitlenmesi). - İç içe validasyon:
List<@Valid OrderItemRequest>olmadanquantity: -3geçer. - Fiyat sunucuda: İstek gövdesinde fiyat olsa bile kullanılmaz. Fiyat DB'deki üründen okunur, toplam
BigDecimalile hesaplanır (price.multiply(BigDecimal.valueOf(quantity))). İstemciye güvenmek, fiyatı 0,01 TL'ye çekip sipariş vermenin kapısını açar. - N+1 yok: Ürünler tek
findAllById(ids)ile çekilir; eksik id'ler (ids - bulunanlar) tek seferde raporlanır. - Status kodları: Oluşturma 201 +
Location; validasyon 400; olmayan sipariş 404; olmayan ürün 422 (hedef URI değil gövde içeriği hatalı); idempotency anahtarı farklı gövdeyle tekrar kullanılırsa 422; aynı anahtarla eşzamanlı işlem sürüyorsa 409. @Transactionalservis metodunda, okumareadOnly = true.- Idempotency:
Idempotency-Keyheader'ı,(customer_id, idempotency_key)üzerinde unique index, istek parmak izi, tekrar istekte ilk siparişin cevabı.
Kata çözümündeki eksikler ve daha iyisi:
- Çözüm
DataIntegrityViolationException'ın tamamını global olarak409 "Duplicate request"e çeviriyor. Bu exception her constraint ihlalinde fırlar (FK, NOT NULL, CHECK); örneğin var olmayan bircustomerIdFK hatası da "duplicate" olarak raporlanır. Daha iyisi: idempotency yarışını servis içinde, yukarıdakiOrderServiceörneğindeki gibi ele almak (kazananı yeniden oku ve ilk cevabı dön), diğer integrity ihlallerini ayrı ele almak. Aynı anahtarla gelen ikinci isteğe 409 dönmek de idempotency'nin amacına aykırıdır: istemci hata değil, ilk siparişin cevabını bekler. - Unique index sadece
idempotency_keyüzerinde. Anahtar müşteri kapsamında olmalı:UNIQUE (customer_id, idempotency_key). - Aynı anahtarın farklı gövdeyle kullanılması kontrol edilmiyor; istek hash'i saklanmalı.
- Idempotency kontrolü ile oluşturma aynı
@Transactionalmetotta. Yarışı kaybeden istek constraint hatası aldığında transaction rollback-only olur; kazananı aynı transaction'da okuyup dönemezsin. Transaction'ı ayrı bir bean'e almak gerekir. Locationiçin göreli URI kabul edilebilir (RFC 9110), ama gateway arkasında prefix değişiyorsaServletUriComponentsBuilderdaha doğru sonuç verir.
Production karşılığı: Takip soruları bu endpoint'i gerçek bir sisteme bağlar: stok aynı transaction'da atomik UPDATE products SET stock = stock - :q WHERE id = :id AND stock >= :q ile düşülür (etkilenen satır 0 ise yetersiz stok); kargo servisine haber Kafka'ya doğrudan değil, aynı transaction'da yazılan outbox kaydıyla verilir (dual-write problemi); GET /orders?customerId=7 keyset sayfalama ile yazılır; test için @WebMvcTest + MockMvc (validasyon, status kodları, JSON şekli) ve Testcontainers ile PostgreSQL üzerinde integration test (unique index yarışı, transaction davranışı).
z1: Yeni sipariş oluşturulunca hangi cevap?
Doğru: b) 201 Created + Location: /api/v1/orders/{id} + (opsiyonel) gövde. 201, isteğin sonucunda yeni bir kaynağın oluştuğunu söyler; Location onun adresidir. Gövdede oluşturulan kaynağı dönmek, istemcinin ikinci bir GET yapmasını gereksiz kılar (sunucunun hesapladığı toplam, id, durum).
- a) 200 OK: Çalışır ama "yeni kaynak oluştu" bilgisini ve adresini vermez; istemci ve araçlar (API gateway metrikleri, SDK üreticileri) farkı göremez.
- c) 202 Accepted: İşlem asenkron kuyruğa alınmış ve henüz tamamlanmamışsa doğrudur. Sipariş senkron oluştuysa 202 yanlış bir sinyal verir: istemci durum sorgulamaya başlar.
- d) 204 No Content: Gövdesiz başarı; DELETE ya da gövde dönmeyen PUT için. Oluşturmada kaynağın adresini de söylemez.
Ana kavram: Status kodu istemcinin bir sonraki adımını belirler. Flash sale gibi asenkron tasarımlarda aynı POST /orders bilinçli olarak 202 + takip URL'si dönebilir; seçim sözleşmenin dürüstlüğüyle ilgilidir.
z2: Hangi HTTP metotları idempotenttir?
Doğru: a) GET, b) PUT, c) DELETE.
- GET safe'tir; safe olan her metot idempotenttir.
- PUT kaynağı verilen temsille değiştirir; aynı temsili tekrar göndermek aynı durumu üretir.
- DELETE ilk çağrıda siler, sonrakiler 404 dönebilir; ama sunucu durumu aynıdır. İdempotency cevabın değil, etkinin özelliğidir.
Yanlışlar:
- d) POST: Her çağrı yeni bir kaynak ya da yeni bir yan etki üretebilir. Idempotency key ile uygulama seviyesinde idempotent yapılabilir ama metot semantiği gereği değildir.
- e) PATCH: RFC 5789 PATCH'i idempotent saymaz. "Stoğu 1 azalt" gibi göreli bir değişiklik her çağrıda yeni etki üretir. "Adı X yap" gibi mutlak bir patch pratikte idempotent olabilir ama garanti yoktur.
Listede olmayan HEAD, OPTIONS ve TRACE de safe oldukları için idempotenttir.
Production karşılığı: Retry politikaları bu sınıflandırmaya dayanır. Resilience4j'de retry'ı açarken veya gateway'de otomatik retry tanımlarken metot ayrımı yapılmazsa, timeout alan bir POST tekrar gönderilir ve çift kayıt oluşur. Kafka consumer'ları da "en az bir kez" teslim nedeniyle aynı problemi yaşar; çözüm yine idempotent işleme (unique key, işlenmiş mesaj tablosu).
z3: Ürün yorumlarını sayfalı getiren endpoint
Doğru: b) GET /api/v1/products/5/reviews?page=0&size=20&sort=createdAt,desc. Çoğul isimler, gerçek bir sahiplik hiyerarşisi (yorum ürüne ait), major versiyon path'te, sayfalama ve sıralama query parametresinde, okuma için GET (cache'lenebilir, paylaşılabilir, retry güvenli).
- a)
GET /getProductReviews?id=5: URL'de fiil var, kaynak hiyerarşisi ve versiyon yok; aksiyon metotla değil URL'yle anlatılıyor. - c)
POST /api/v1/reviews/search: Basit listeleme için POST, cache'lenebilirliği ve retry güvenliğini kaybettirir. Çok karmaşık arama kriterleri (URL uzunluk sınırına dayanan filtreler, hassas veri) için pragmatik bir istisna olarak kullanılır; bu boşluk için gövdeli, safe ve idempotentQUERYmetodu RFC 10008 (Haziran 2026) ile standartlaştı, ama framework, proxy ve CDN desteği henüz olgun değil. - d)
GET /api/v1/product/5/review/list: Tekil isimler velistfiili; koleksiyonun kendisi zaten liste demektir.
Production karşılığı: Yorum sayısı yüksek ürünlerde derin offset sayfalama yavaşlar; mobil "daha fazla yükle" akışı için cursor tabanlı sayfalama daha uygundur. Sıralama parametresi beyaz listeyle sınırlanmalı (createdAt, rating) ve bu alanlar için (product_id, created_at DESC, id DESC) gibi index olmalıdır.
z4: quantity: -3 neden validasyondan geçiyor?
Senaryo: @NotEmpty List<ItemRequest> items, ItemRequest içinde @Positive int quantity, controller'da @Valid @RequestBody.
Doğru: b) Liste elemanlarının doğrulanması için cascade gerekir: List<@Valid ItemRequest> ya da alanın üstüne @Valid. Bean Validation bir nesneyi doğrularken onun alanlarındaki constraint'lere bakar; alanın tipindeki nesnenin içine ancak @Valid ile işaretlenmişse iner. @NotEmpty listenin kendisine ait bir constraint olduğu için çalışır; elemanların içindeki @Positive ise hiç değerlendirilmez.
- a)
@Positiveint'te çalışmaz: Yanlış.@Positiveprimitive sayısal tiplerde ve sarmalayıcılarda çalışır. Fark sadecenulldurumundadır: primitiveintJSON'da yoksa0olur ve@Positivebunu yakalar;Integerisenullolur ve Bean Validation constraint'lerinin çoğunull'u geçerli sayar (@NotNullayrıca gerekir). - c) Starter yok: Starter olmasaydı
@NotEmptyde çalışmazdı; soru boş listenin reddedildiğini varsayıyor. - d)
@Validatedyazılmalı:@Validatedgrup seçimi ve AOP tabanlı method validation içindir; cascade problemini çözmez, iç nesneye yine@Validgerekir.
Quiz'in bu sorudaki notu "path ve query parametrelerinin validasyonu için controller'a @Validated eklenir" diyor. Bu Spring 6.0 ve öncesi için doğruydu. Spring Framework 6.1+ ile MVC'de yerleşik method validation var: constraint'i doğrudan parametreye koymak yeterli, hata HandlerMethodValidationException (400) olarak gelir. Controller'a sınıf seviyesinde @Validated koymak bu yerleşik desteği devre dışı bırakır, AOP tabanlı validation'a döner ve ConstraintViolationException fırlatır; bunu ayrıca ele almazsan istemci 500 alır. Spring dokümantasyonu yerleşik destek için sınıf seviyesindeki @Validated'ın kaldırılmasını önerir.
Production karşılığı: Validasyon hatası istemciye alan bazında dönmeli (items[0].quantity: must be greater than 0). Bunun için hata handler'ında FieldError.getField() kullanılır; Spring bu alana liste index'ini de yazar.
z9: @Transactional hangi katmanda?
Doğru: b) Servis katmanında, bir iş akışını (use case) temsil eden public metotta. Transaction sınırı, iş akışının "ya hep ya hiç" sınırıyla aynı olmalıdır ve bunu bilen katman servistir.
- a) Controller: Connection, istek gövdesinin parse edilmesinden cevabın yazılmasına kadar tutulur; transaction sınırı web katmanına sızar; yavaş istemciler connection havuzunu tüketir.
- c) Her repository metodunda ayrı ayrı: Birden fazla yazma ayrı transaction'larda olur; ikincisi patlarsa birincisi commit edilmiş kalır.
- d) Entity sınıfında: Entity'ler Spring bean'i değildir; proxy'lenmezler, annotation'ın etkisi olmaz.
Quiz'deki c şıkkının açıklaması "Spring Data zaten her metodu kendi transaction'ında çalıştırır" diyor. Bu tam doğru değil. Spring Data JPA dokümantasyonuna göre CrudRepository'den miras alınan metotlar SimpleJpaRepository'nin transaction ayarını kullanır (okumalar readOnly = true, diğerleri düz @Transactional), ama arayüzde tanımladığın sorgu metotlarına (findByStatus, @Query metotları, default metotlar) varsayılan olarak hiçbir transaction ayarı uygulanmaz. @Modifying bir @Query metodunu transaction'sız çağırırsan TransactionRequiredException alırsın.
Production karşılığı: Transaction'ı kısa tut: harici HTTP çağrısını, dosya üretimini, e-posta gönderimini transaction dışına al. Uzun transaction'lar PostgreSQL'de satır kilitlerini uzun tutar, vacuum'u engeller ve connection havuzunu boşaltır.
z10: Her controller'da try/catch → 500 "error"
Doğru: b) try/catch'leri kaldırıp domain exception'ları ve @RestControllerAdvice içindeki @ExceptionHandler'larla merkezi olarak doğru status'a (ProblemDetail) çevirmek. Tek yer, tutarlı format, doğru status kodları; beklenmeyen hatalar tek noktada log'lanır.
- a)
e.printStackTrace(): Her şey hâlâ 500; istemci validasyon hatasıyla sunucu hatasını ayırt edemez.printStackTracestderr'e yazar: log formatı, seviye, MDC (traceId) ve log toplama dışında kalır. - c) Her controller'da tipe göre ayrı catch: Aynı eşleme her yerde tekrar eder, biri unutulunca tutarsızlık başlar.
- d) Servis katmanında yakalayıp null dönmek: Hata bilgisi kaybolur, çağıran
nullkontrolünü unutursa hata başka bir yerde NPE olarak patlar; asıl sebebi bulmak zorlaşır.
Daha iyi çözüm: Yukarıdaki ApiExceptionHandler: sealed domain exception hiyerarşisi, kategori → status eşlemesi, validasyonda alan ve global hatalar, 500'de errorId ile log korelasyonu, AccessDeniedException'ın yutulmaması.
z12: Harici servis çağrıları için hangi ifadeler doğru?
Doğru: a, b, c, d.
- a)
RestClientFramework 6.1 / Boot 3.2 ile geldi; yeni kodda RestTemplate yerine önerilir. Doğru. Güncel durum quiz'den bir adım ileride: Spring ekibi Eylül 2025'te RestTemplate'in 7.1'de resmi olarak@Deprecatedolacağını ve 8.0'da kaldırılacağını açıkladı. "Bakım modunda" ifadesi artık "kaldırılma yolunda" olarak güncellenmeli. - b)
WebClientreactive ve non-blocking'dir; WebFlux bağımlılığı getirir. Doğru. MVC uygulamasında.block()ile kullanılan WebClient, reactive'in faydasını vermeden karmaşıklığını getirir. - c)
new RestTemplate()varsayılan olarak timeout tanımlamaz. Doğru. Read timeout gerçekten sınırsızdır; connect tarafında pratikte sadece işletim sisteminin TCP bağlantı denemesi süresi sınır olur. Timeout bilinçli olarak ayarlanmalıdır. - d) OpenFeign deklaratif client sunar; Spring Cloud sürümü Boot sürümüyle uyumlu olmalı. Doğru. Ek bilgi: Spring Cloud OpenFeign artık feature-complete kabul ediliyor; yeni projelerde Spring'in HTTP interface client'ları önerilir.
Yanlış: e) Client'ı her çağrıda new ile oluşturmak. Her seferinde yeni connection pool, yeni TLS el sıkışması ve dağınık konfigürasyon demektir; ayrıca timeout gibi ayarların bir yerde unutulmasını kolaylaştırır. Tek, yapılandırılmış bir bean kullan.
Production karşılığı: Timeout değerleri tahminle değil ölçümle seçilir: bağımlılığın p99 latency'sinin biraz üstü, ve toplam istek bütçesiyle uyumlu (gateway 5 saniyede kesiyorsa içerideki çağrı zinciri bunu aşmamalı). Timeout, retry ve circuit breaker metrikleri (Micrometer) dashboard'da olmalı.
z14: Kayıtta zaten var olan e-posta
Doğru: c) 409 Conflict. İstek sözdizimsel olarak geçerli; sorun, kaynakların mevcut durumuyla (unique e-posta) çakışması.
- a) 400: İstek bozuk değil; aynı istek, e-posta kayıtlı olmasaydı başarılı olurdu.
- b) 404: İlgisiz; aranan bir kaynak yok.
- d) 500: Veritabanının unique violation'ını yakalamayıp 500'e düşürmek klasik bir hatadır. İstemci bunu "sunucu bozuk, tekrar dene" diye yorumlar ve retry eder.
Mekanizma: "Önce existsByEmail, yoksa kaydet" yarışa açıktır: iki eşzamanlı kayıt isteği ikisi de "yok" görür. Gerçek garanti UNIQUE constraint'tir (e-postalar büyük/küçük harf duyarsız karşılaştırılacaksa lower(email) üzerinde unique index ya da PostgreSQL'de citext). Ön kontrol kullanıcı dostu bir mesaj için kalabilir; constraint ihlali ise DataIntegrityViolationException olarak yakalanıp 409'a çevrilir. Burada da hangi constraint'in ihlal edildiğine bakmak gerekir; her integrity hatası "e-posta zaten kayıtlı" değildir.
Senior notu: Kayıt endpoint'inde "bu e-posta zaten kayıtlı" demek hesap varlığını sızdırır (user enumeration). Güvenlik hassasiyeti yüksek sistemlerde kayıt isteği her zaman aynı genel cevabı döner ("Doğrulama e-postası gönderildi") ve e-posta sahibine "zaten hesabın var" bildirimi gider. Bu durumda 409 yerine 202 dönülür. Hangisinin seçileceği ürün ve güvenlik kararıdır; mülakatta bu trade-off'u söylemek fark yaratır.
Üç seviyede cevap
Soru: "Mobil uygulama sipariş isteğini ağ hatasında tekrar gönderiyor. Çift sipariş oluşmasını nasıl engellersin?"
seviye: Orta etiketler: [idempotency, rest] kaynak: Tahmini
POST yerine PUT kullanırım, PUT idempotent olduğu için tekrar gelse de sorun olmaz. Ya da kaydetmeden önce aynı müşterinin son bir dakikada aynı ürünlerle siparişi var mı diye bakarım.
İstemci her sipariş denemesi için bir UUID üretip Idempotency-Key header'ında gönderir, retry'larda aynı anahtarı kullanır. Sunucuda anahtarı siparişle birlikte saklarım ve unique constraint koyarım. Aynı anahtar gelirse yeni sipariş oluşturmadan ilk siparişi dönerim. Unique constraint önemli, çünkü "önce bak sonra ekle" kontrolü eşzamanlı iki istekte ikisine de izin verir.
İstemci mantıksal işlem başına bir anahtar üretir; sunucuda (customer_id, idempotency_key) üzerinde unique index ve isteğin hash'i saklanır. Kapsamı müşteriyle sınırlamak önemli, yoksa çakışan iki anahtar başka müşterinin siparişini döndürür. Akış şöyle: anahtar varsa hash'i karşılaştırır, aynıysa ilk cevabı dönerim, farklıysa 422. Yoksa siparişi ayrı bir transaction'da oluştururum; eşzamanlı ikinci istek PostgreSQL'de unique index üzerinde bekler, birincisi commit edince unique_violation alır, ben de yeni bir transaction'da kazananı okuyup aynı cevabı dönerim. Sipariş ödeme gibi dış bir çağrı içeriyorsa sadece sonucu saklamak yetmez: anahtarın IN_PROGRESS/COMPLETED/FAILED durumu olur, işlem sürerken gelen tekrar isteğe 409 dönerim ve ödeme sağlayıcısına da kendi idempotency key'imi geçiririm ki zincirin her halkası tekrar güvenli olsun. Anahtarları sonsuza kadar tutmam; retry penceresine göre (ör. 24-48 saat) temizlerim. Ölçüm olarak "replay edilen istek sayısı" ve "anahtar uyuşmazlığı (422)" metriklerini izlerim; 422 artıyorsa istemcide anahtar üretim hatası var demektir. PUT önerisine gelince: istemci sipariş id'sini kendisi üretip PUT /orders/{clientGeneratedId} yapabilir, bu da geçerli bir tasarım, ama id üretimini istemciye bırakmak ve id'nin tahmin edilebilirliği gibi başka sorular açar.
Junior'ın cevabı iki yaygın yanlış inanç içeriyor: metodu PUT yapmak, sunucu tarafındaki mantık değişmedikçe hiçbir şeyi idempotent yapmaz (idempotency metot adının değil, uygulamanın özelliğidir); "son bir dakikada benzer sipariş" sezgisi ise hem yarışa açık hem de meşru ikinci siparişi engeller. Mid doğru mekanizmayı ve unique constraint'in neden gerektiğini biliyor. Senior'ı ayıran şeyler: anahtarın kapsamı (güvenlik), farklı gövde durumu (422), eşzamanlı isteğin veritabanında nasıl davrandığı (bekleme ve 23505), transaction sınırının neden ayrılması gerektiği, dış çağrı içeren akışta durum makinesi, saklama süresi ve ölçüm.
Takip sorusu: Sipariş oluşturuldu ama ödeme sağlayıcısının cevabı timeout'a düştü. Kullanıcı aynı anahtarla tekrar denediğinde ne olur, ödemenin gerçekten alınıp alınmadığını nasıl öğrenirsin?
Soru: "Bir Spring Boot API'sinde hata yönetimini nasıl kurgularsın?"
seviye: Orta etiketler: [spring, error-handling, problem-details] kaynak: Tahmini
Global bir exception handler yazarım, @ControllerAdvice ile. Exception gelince 500 ve mesajı dönerim, böylece her controller'da try-catch yazmam gerekmez.
@RestControllerAdvice içinde exception tiplerine göre handler'lar yazarım: NotFoundException 404, MethodArgumentNotValidException 400 ve alan bazlı hata listesi, ConflictException 409, geri kalan her şey 500. Gövde formatı olarak Spring 6'daki ProblemDetail'i kullanırım. 500'de exception mesajını istemciye dönmem, log'larım.
Önce sözleşmeyi belirlerim: tüm hatalar RFC 9457 formatında, application/problem+json, ek olarak istemcinin programatik karar verebileceği stabil bir code alanı ve validasyonda errors listesi. Domain tarafında HTTP bilmeyen, kategoriye göre ayrılmış sealed bir exception hiyerarşisi kurarım (NotFound, Conflict, BusinessRule); status eşlemesi web katmanında tek bir advice'ta olur, Java 21 switch'i sayesinde yeni kategori eklenince derleyici beni uyarır. Advice'ı ResponseEntityExceptionHandler'dan türetirim ki Spring'in kendi hataları (bozuk JSON, desteklenmeyen media type, eksik parametre) da aynı formatta dönsün; Spring 6.1+'ta HandlerMethodValidationException'ı da unutmam. Bilinmeyen hatalarda istemciye sadece bir errorId dönerim, detay ERROR seviyesinde log'a ve traceId ile birlikte gider; 4xx'leri stack trace'siz WARN/INFO log'larım ki alarm yorgunluğu olmasın. İki tuzağa dikkat ederim: catch-all handler AccessDeniedException'ı yutup 403'ü 500'e çevirebilir; DataIntegrityViolationException'ı körü körüne 409 yapmak FK hatalarını da "duplicate" diye raporlar. Son olarak bunu @WebMvcTest ile test ederim: her hata türü için status, content type ve gövde şekli. Production'da status kodu dağılımını (4xx/5xx oranı, code bazında) dashboard'da izlerim; ani bir VALIDATION_FAILED artışı genelde yeni bir istemci sürümünün sözleşmeyi kırdığını gösterir.
Junior merkezi handler fikrini biliyor ama her şeyi 500'e çevirmek asıl problemi (yanlış status) çözmüyor; mesajı dönmek de iç detay sızdırıyor. Mid doğru eşlemeyi ve ProblemDetail'i biliyor, sızıntıya dikkat ediyor. Senior bunu bir sözleşme olarak ele alıyor: stabil hata kodu, domain'in HTTP'den bağımsızlığı, Spring'in kendi exception'larının aynı formata girmesi, log seviyesi stratejisi, güvenlikle etkileşim (403'ün yutulması), test ve gözlemlenebilirlik.
Takip sorusu: Mobil istemci hata mesajlarını kullanıcıya Türkçe ve İngilizce göstermek istiyor. Bunu sunucuda mı, istemcide mi çözersin; ProblemDetail'de nasıl taşırsın?
Soru: "400, 401, 403, 404, 409 ve 422 arasındaki farkı anlatır mısın? Hangisini ne zaman seçersin?"
seviye: Başlangıç etiketler: [http, status-codes] kaynak: Tahmini
400 hatalı istek, 401 yetkisiz, 403 yasak, 404 bulunamadı, 409 çakışma, 422 işlenemeyen içerik. Kullanıcı login olmamışsa ya da yetkisi yoksa 401 dönerim.
400 isteğin kendisi bozuksa: JSON parse edilemiyor ya da validasyon geçmiyor. 401 kimlik doğrulanmamışsa, 403 kimlik belli ama yetki yoksa. 404 hedef kaynak yoksa. 409 mevcut durumla çakışma, örneğin unique e-posta ya da zaten kargoya verilmiş siparişi iptal etmek. 422 sözdizimi doğru ama iş kuralına aykırı içerik için; bazı ekipler bunu da 400 ile döner.
Ben bunu istemcinin bir sonraki adımı üzerinden seçiyorum. 401: tekrar kimlik doğrula, WWW-Authenticate ile döner; adı "Unauthorized" ama anlamı "unauthenticated". 403: kimlik doğru, tekrar denemek işe yaramaz. Kaynak başka kullanıcıya aitse 403 yerine 404 dönerim, çünkü 403 o id'de bir kaynak olduğunu söyler; bu IDOR taramalarına bilgi verir. 400 ile 422 ayrımında takımın standardına uyarım; standart yoksa şema hatası 400, iş kuralı 422 öneririm ve asıl ayrımı gövdedeki stabil code alanıyla yaparım, çünkü istemci kodu status'tan çok code'a bakar. 404 hedef URI içindir: gövdedeki productId yoksa 404 değil 422/400'dür. 409'u durum çakışması için kullanırım; eşzamanlı güncellemede ETag/If-Match kullanıyorsam ön koşul hatası 412'dir, If-Match zorunlu ama gelmemişse 428. Retry edilebilir kodlarda da ayrım önemli: 503 ve 429'a Retry-After eklerim, çünkü istemcinin retry davranışını bu header yönlendirir.
Junior'ın cevabı ezber ve 401/403'ü karıştırıyor; bu yaygın yanlış inanç, istemcinin yetkisiz kullanıcıyı sonsuz login döngüsüne sokmasına yol açar. Mid doğru tanımları biliyor. Senior kodları istemci davranışıyla ilişkilendiriyor, 404'ü güvenlik aracı olarak kullanıyor, 404'ün hedef URI'yle ilgili olduğunu biliyor, 412/428/Retry-After gibi daha ince araçları doğru yere koyuyor ve 400/422 tartışmasında dogmatik değil.
Takip sorusu: Toplu bir işlemde (100 ürünün fiyatını güncelle) 97'si başarılı, 3'ü hatalı. Hangi status kodunu dönersin, gövdeyi nasıl tasarlarsın?
Soru: "Ödeme sağlayıcısını çağıran bir servis yazıyorsun. HTTP client tarafında nelere dikkat edersin?"
seviye: İleri etiketler: [http-client, resilience, payments] kaynak: Tahmini
RestTemplate ya da Feign kullanırım. Hata gelirse exception fırlatırım, bir de birkaç kez retry eklerim ki geçici hatalarda işlem başarısız olmasın.
Yeni kodda RestClient kullanırım, tek bir bean olarak ve connect/read timeout ayarlı. 4xx ve 5xx'i ayrı ele alırım: 4xx'te retry yapmam, 5xx ve timeout'ta exponential backoff ile sınırlı retry yaparım. Resilience4j ile circuit breaker eklerim ki sağlayıcı çökünce thread'lerim tükenmesin. Çağrıyı @Transactional metodun içinde yapmam.
Önce semantiği netleştiririm: bu çağrı para hareketi yapıyor, yani retry ancak sağlayıcı idempotency key destekliyorsa güvenli. Her ödeme denemesi için kendi tarafımda bir anahtar üretip saklarım ve sağlayıcıya geçiririm; timeout aldığımda "başarısız" değil "bilinmiyor" durumundayım, o yüzden aynı anahtarla tekrar sorarım ya da durum sorgulama endpoint'ini kullanırım, gerekirse uzlaştırma (reconciliation) job'ı kalan belirsizlikleri kapatır. Client RestClient, timeout'lar ölçülmüş p99'a göre: connect kısa (1 sn), read sağlayıcının SLA'sına göre, ve toplam süre gateway'in bütçesini aşmayacak şekilde. Retry sadece bağlantı hatası, 503 ve 429'da, backoff ve jitter ile, en fazla 2 deneme; 4xx'te asla. Circuit breaker ve bulkhead ile bu sağlayıcıya ayrılan eşzamanlılığı sınırlarım ki diğer endpoint'ler etkilenmesin. Çağrı veritabanı transaction'ı dışında; sipariş durumu PAYMENT_PENDING olarak yazılır, sonuç ayrı bir transaction'da işlenir. Log'a kart verisi yazmam, sadece kendi ödeme id'mi ve sağlayıcının referansını. Gözlemlenebilirlik için çağrı latency histogramı, hata oranı, circuit breaker durumu ve "bilinmeyen sonuçlu ödeme" sayısı dashboard'da olur. RestTemplate'i yeni kodda seçmem, Spring 7.1'de deprecate edilecek; Feign'i de yeni projede seçmem, Spring Cloud OpenFeign feature-complete durumda, HTTP interface client aynı deklaratif modeli veriyor.
Junior'ın retry önerisi tehlikeli: idempotency olmadan ödeme çağrısını tekrarlamak çift tahsilat demektir; timeout'tan hiç bahsetmemesi de en büyük eksik. Mid teknik olarak doğru araçları sayıyor. Senior'ı ayıran şey belirsiz sonuç kavramı (timeout = bilinmiyor), idempotency key'in dış sisteme taşınması, uzlaştırma, transaction sınırı, bulkhead ile izolasyon, PCI farkındalığı ve client seçiminin güncel durumunu bilmesi.
Takip sorusu: Sağlayıcı idempotency key desteklemiyor. Çift tahsilat riskini nasıl yönetirsin?
Sorulmadı ama sorulabilir
Richardson Maturity Model ve HATEOAS
Richardson modeli bir API'nin "REST'liliğini" dört seviyede tarif eder: Seviye 0 tek URL'ye her şeyi POST'lamak (RPC tarzı), Seviye 1 ayrı kaynaklar, Seviye 2 HTTP metotlarını ve status kodlarını doğru kullanmak, Seviye 3 hypermedia (HATEOAS): cevapta istemcinin yapabileceği sonraki aksiyonların linkleri ("_links": {"cancel": {"href": "/orders/42/cancellation"}}). Sektördeki API'lerin büyük çoğunluğu Seviye 2'dedir; Seviye 3 istemciyi URL yapısından bağımsızlaştırır ama istemcilerin linkleri gerçekten takip etmesini gerektirir ve pratikte nadiren tam uygulanır. Spring HATEOAS bunu destekler.
Tek cümle: "Çoğu production API'si Seviye 2'dir; HATEOAS'ı istemcinin iş akışını sunucunun yönlendirmesi gerektiğinde (ör. siparişte hangi aksiyonların mümkün olduğu) kullanırım."
REST, gRPC ve GraphQL ne zaman?
- REST/JSON: Dış ve mobil istemciler, tarayıcı, cache'lenebilir okumalar, geniş araç desteği.
- gRPC: Servisler arası iç iletişim, düşük latency, HTTP/2 üzerinde Protobuf ile şema ve kod üretimi, streaming. Tarayıcıdan doğrudan kullanımı sınırlıdır (gRPC-Web gerekir), payload insan tarafından okunmaz.
- GraphQL: Farklı ekranların aynı veriden farklı alt kümeler istediği durumlar (over-fetching/under-fetching). Bedeli: HTTP cache'ten faydalanmak zorlaşır, sorgu maliyetini sınırlamak (derinlik, karmaşıklık limiti) ve N+1'i DataLoader ile çözmek gerekir.
Tek cümle: "Dışa REST, içeride latency kritikse gRPC, çok sayıda farklı istemci aynı grafiğin farklı parçalarını istiyorsa GraphQL; seçim teknolojiden çok tüketiciye göre."
Toplu (bulk) işlemler ve kısmi başarı
POST /api/v1/products/price-updates ile 1000 fiyat güncellemesi. Seçenekler: ya hep ya hiç (tek transaction, bir hata hepsini geri alır, 422 + hatalı satırlar), ya da kısmi başarı (her öğe için ayrı sonuç). Kısmi başarıda 200 dönüp gövdede öğe bazında sonuç vermek en anlaşılır olanıdır:
{
"succeeded": 997,
"failed": 3,
"results": [
{ "index": 14, "productId": 501, "status": "FAILED", "code": "PRICE_BELOW_COST" }
]
}
Çok büyük toplu işlemler senkron yapılmaz: 202 + iş takibi kaynağı. Her öğeye bir idempotency anahtarı ya da isteğe bir toplu anahtar vermek, yarıda kesilen işin güvenle tekrar gönderilmesini sağlar.
Tek cümle: "Toplu işlemde önce 'ya hep ya hiç mi, kısmi başarı mı' sorusunu netleştiririm; kısmi başarıda öğe bazında sonuç dönerim ve büyük işleri 202 ile asenkron yaparım."
HTTP cache: Cache-Control ve koşullu GET
GET cevapları HTTP seviyesinde cache'lenebilir: Cache-Control: max-age=60 (60 saniye taze), public/private (paylaşılan cache'ler saklayabilir mi), no-store (hiç saklama: kişisel veri). Taze olmayan cevap ETag ya da Last-Modified ile koşullu olarak doğrulanır: If-None-Match → 304. Ürün kataloğu gibi herkese aynı olan veriler CDN'de cache'lenebilir; sepet ve sipariş gibi kişiye özel veriler private ya da no-store olmalıdır. Header'a göre değişen cevaplarda (Accept-Language, versiyon header'ı) Vary header'ı şarttır, yoksa CDN bir kullanıcının cevabını diğerine verir.
Tek cümle: "Herkese aynı olan okumaları CDN'de max-age ve ETag ile cache'lerim, kişiye özel cevaplarda private/no-store ve doğru Vary kullanırım."
Uzun süren işlemler: polling, webhook, SSE
202 + durum kaynağı (polling) en basit ve en dayanıklı yoldur. Webhook (işlem bitince sunucu istemcinin URL'sini çağırır) polling yükünü kaldırır ama imza doğrulama (HMAC), retry ve sıralama sorunlarını getirir; webhook alıcısı da idempotent olmalıdır, çünkü aynı event birden fazla gelebilir. Server-Sent Events (SSE) tarayıcıya tek yönlü canlı akış için uygundur (sipariş durum ekranı).
Tek cümle: "Varsayılanım 202 + polling; istemci bir sistemse imzalı ve idempotent webhook, tarayıcıda canlı güncelleme gerekiyorsa SSE."
Controller testleri: @WebMvcTest ve MockMvc
API sözleşmesinin testi: status kodu, header (Location), content type (application/problem+json), JSON şekli ve validasyon. @WebMvcTest(OrderController.class) sadece MVC katmanını ayağa kaldırır, servis mock'lanır (Boot 3.4+ @MockitoBean; eski @MockBean deprecate edildi).
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mvc;
@MockitoBean OrderService orderService;
@Test
void rejectsNonPositiveQuantity() throws Exception {
mvc.perform(post("/api/v1/orders")
.header("Idempotency-Key", "k-1")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"customerId": 7, "items": [{"productId": 3, "quantity": -3}]}
"""))
.andExpect(status().isBadRequest())
.andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
.andExpect(jsonPath("$.errors[0].field").value("items[0].quantity"));
verifyNoInteractions(orderService);
}
}
Idempotency yarışı, unique index ve transaction davranışı gibi şeyler ise ancak gerçek veritabanıyla test edilir: Testcontainers ile PostgreSQL.
Tek cümle: "Sözleşmeyi @WebMvcTest ile, veritabanı davranışını Testcontainers ile test ederim; mock'lu bir test unique index yarışını asla yakalamaz."
CORS
Tarayıcı, farklı origin'den (https://satici.example.com → https://api.example.com) gelen JavaScript isteklerini CORS kurallarına göre kısıtlar. Basit olmayan isteklerden (JSON gövdeli POST, özel header) önce OPTIONS preflight gönderilir. Sunucu Access-Control-Allow-Origin ile izin verir. allowedOrigins("*") ile birlikte allowCredentials(true) kullanılamaz (Spring buna izin vermez); izinli origin'leri açıkça listele. CORS bir sunucu güvenlik mekanizması değildir: sadece tarayıcıyı ilgilendirir; curl ile her istek atılabilir. Yetkilendirme yine sunucuda yapılır.
Tek cümle: "CORS tarayıcının kuralıdır, API'yi korumaz; izinli origin'leri açıkça listelerim ve yetkiyi her zaman sunucuda kontrol ederim."
Sık yapılan hatalar
- URL'de fiil (
/createOrder,/orders/delete/5) → Aksiyonu metot yerine URL'ye taşır, GET ile yan etki gibi tehlikeli kalıplara kapı açar → Kaynak + metot; CRUD'a sığmayan iş akışları için alt kaynak (/orders/42/cancellation). - Yan etkili GET → Crawler, prefetch ve otomatik retry'lar veriyi değiştirir → Durum değiştiren her şey POST/PUT/PATCH/DELETE.
- Oluşturmada 200 ve Location yok → İstemci yeni kaynağın adresini bilmez, semantik kaybolur → 201 +
Location. - Her şeyi 500'e çevirmek → İstemci kendi hatasını sunucu hatası sanıp retry eder, alarmlar yanlış çalar → Merkezi handler'da doğru 4xx/5xx eşlemesi.
- 500 gövdesinde exception mesajı/stack trace → SQL, tablo adı, iç servis adresi sızar → Genel mesaj +
errorId, detay log'da. List<ItemRequest>üzerinde@Validunutmak → Elemanlar doğrulanmaz, negatif adet geçer →List<@Valid ItemRequest>.- Controller'a sınıf seviyesinde
@Validated(Spring 6.1+) →ConstraintViolationExceptionele alınmazsa 500 → Constraint'i parametreye koy, yerleşik method validation'ı kullan veHandlerMethodValidationException'ı da ele al. - Validasyon grubu verip
Default'u genişletmemek → Grupsuz constraint'ler sessizce atlanır →interface OnCreate extends Default {}ya da ayrı DTO'lar. - Fiyatı/toplamı istemciden almak → Fiyat manipülasyonu → Fiyat DB'den, toplam sunucuda,
BigDecimalile. - Entity'yi request/response olarak kullanmak → Lazy loading hataları, sızıntı, mass assignment → Record DTO'lar.
@Transactionalcontroller'da ya da transaction içinde HTTP çağrısı → Connection havuzu tükenir, dış etki rollback edilemez → Servis seviyesinde kısa transaction, dış çağrı dışarıda ya da outbox ile.- "Önce var mı bak, sonra ekle" ile tekillik → Eşzamanlı isteklerde ikisi de geçer → Unique constraint + ihlali 409'a (ya da idempotent replay'e) çevirmek.
- Her
DataIntegrityViolationException'ı "duplicate" saymak → FK/NOT NULL hataları yanlış raporlanır → Constraint adına bak ya da yarışı servis içinde ele al. - Sınırsız liste endpoint'i, sınırsız
size, serbestsort→ Bellek patlaması, full scan, bilgi sızıntısı → Varsayılan ve maksimum sayfa boyutu, sıralama beyaz listesi, büyük veride keyset. - Timeout'suz HTTP client, her çağrıda
new RestTemplate()→ Thread starvation, kaskad arıza → Tek yapılandırılmışRestClientbean'i, connect/read timeout, circuit breaker. - POST'ta otomatik retry → Çift sipariş/ödeme → Retry sadece idempotent işlemde ya da idempotency key ile.
- Kaynak sahipliğini kontrol etmemek (IDOR) → Başkasının siparişi okunur → Sorguda sahiplik (
findByIdAndCustomerId), kimlik token'dan. - Catch-all
@ExceptionHandler(Exception.class)ileAccessDeniedException'ı yutmak → 403 yerine 500 → Security exception'larını ayrı ele al ya da yeniden fırlat.
Hızlı tekrar
S: Safe ve idempotent arasındaki fark nedir? Hangi metotlar idempotenttir?
C: Safe: istemci durum değişikliği istemez (GET, HEAD, OPTIONS, TRACE). Idempotent: N kez göndermenin amaçlanan etkisi bir kezle aynıdır; safe olanların hepsi + PUT ve DELETE. POST ve PATCH garanti değildir.
S: DELETE ikinci çağrıda 404 dönüyorsa idempotent sayılır mı?
C: Evet. Idempotency cevabın değil sunucu durumu üzerindeki etkinin özelliğidir; ikinci çağrıdan sonra da kaynak yoktur.
S: Oluşturma isteğine doğru cevap nedir?
C: 201 Created + yeni kaynağı gösteren Location header'ı, tercihen oluşturulan kaynağın gövdesiyle. İşlem henüz bitmediyse 202 + takip URL'si.
S: 401 ile 403 farkı?
C: 401: kimlik yok ya da geçersiz (unauthenticated), WWW-Authenticate ile döner. 403: kimlik belli ama yetki yok; tekrar giriş yapmak çözmez. Başkasına ait kaynakta varlığı gizlemek için 404 dönülebilir.
S: Gövdedeki productId veritabanında yoksa 404 mü dönülür?
C: Hayır. 404 hedef URI'deki kaynak içindir. Gövde içeriği iş kuralına aykırıysa 422 (ya da takım standardına göre 400) ve stabil bir hata kodu.
S: List<ItemRequest> elemanlarındaki @Positive neden çalışmaz?
C: Bean Validation iç içe nesnelere kendiliğinden inmez; cascade için List<@Valid ItemRequest> ya da alana @Valid gerekir.
S: Spring 6.1+'ta @RequestParam @Min(1) int page hatası hangi exception'la gelir?
C: HandlerMethodValidationException (400). Controller'a sınıf seviyesinde @Validated koyarsan AOP tabanlı validation devreye girer, ConstraintViolationException fırlar ve ele alınmazsa 500 olur.
S: Idempotency-Key ile çift siparişi engellemenin üç kritik detayı?
C: Anahtar müşteri kapsamında unique (customer_id, key); istek hash'i saklanır ve farklı gövdede 422; yarışı "önce bak" değil unique constraint kapatır, kaybeden kazananın cevabını alır.
S: ETag/If-Match akışında ön koşul tutmazsa ve If-Match hiç gelmezse hangi kodlar dönülür?
C: Sürüm uyuşmazlığında 412 Precondition Failed; If-Match zorunluysa ve gönderilmemişse 428 Precondition Required.
S: Offset sayfalama derin sayfalarda neden yavaşlar, keyset nasıl çözer?
C: OFFSET 10000 veritabanının 10.000 satırı okuyup atması demektir. Keyset, son görülen (created_at, id) değerinden index üzerinde doğrudan konumlanır; her sayfa aynı maliyettedir ve araya eklenen kayıtlar kaymaya yol açmaz.
S: RFC 9457 ProblemDetail'in standart alanları ve Boot'ta açma yolu?
C: type, title, status, detail, instance (+ extension alanları), media type application/problem+json. Boot 3+: spring.mvc.problemdetails.enabled=true ya da ResponseEntityExceptionHandler'dan türeyen kendi advice'ın.
S: @Transactional neden controller'da değil servis katmanında olmalı?
C: Transaction sınırı iş akışının atomiklik sınırıdır; controller'da connection istek boyunca tutulur ve havuz tükenir. Ayrıca transaction içinde harici HTTP çağrısı yapılmaz: connection boşa tutulur ve dış etki rollback edilemez.
S: RestTemplate, RestClient, WebClient ve OpenFeign'in güncel durumu?
C: RestTemplate 7.1'de deprecate, 8.0'da kaldırılacak; RestClient (6.1/Boot 3.2+) senkron uygulamalar için önerilen; WebClient reactive ve aktif; Spring Cloud OpenFeign feature-complete, yerine HTTP interface client öneriliyor.
S: IDOR nedir ve nasıl önlenir?
C: Kimliği doğrulanmış kullanıcının id değiştirerek başkasının kaynağına erişmesi (OWASP API1:2023 BOLA). Sahiplik her erişimde, tercihen sorguda (findByIdAndCustomerId) kontrol edilir; kullanıcı kimliği istekten değil token'dan alınır.
Kaynaklar
- RFC 9110: HTTP Semantics: Metotların safe/idempotent tanımları, status kodları, koşullu istekler ve
Location'ın birincil kaynağı. - RFC 9457: Problem Details for HTTP APIs: Hata gövdesi standardı;
type,title,detailalanlarının amacı. - RFC 5789: PATCH Method for HTTP: PATCH'in neden idempotent sayılmadığı.
- IETF Draft: The Idempotency-Key HTTP Header Field: Idempotency key'in önerilen davranışı ve hata kodları (400/409/422).
- Stripe API: Idempotent requests: Production'da idempotency key'in nasıl işletildiğine dair gerçek bir örnek (parametre karşılaştırması, 24 saat).
- Spring Framework: Error Responses:
ProblemDetail,ErrorResponseveResponseEntityExceptionHandler. - Spring Framework: Validation in Spring MVC: 6.1+ yerleşik method validation ve
@Validatedile etkileşimi. - The state of HTTP clients in Spring: RestTemplate deprecation takvimi ve RestClient/WebClient/HTTP interface önerileri.
- Spring Data JPA: Transactionality: Hangi repository metotlarının varsayılan olarak transactional olduğu.
- OWASP API Security Top 10 (2023): BOLA, kaynak tüketimi ve diğer API'ye özgü güvenlik riskleri.