Zanaat · Bölüm 18

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.

~45 dk okumaQuiz: k5, z1, z2, z3, z4, z9, z10, z12…
Cevaplar:

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/42 ile /orders karışımı istemciyi tahmin yürütmeye zorlar.
  • URL'de fiil yok: /getOrders, /createOrder, /orders/list yerine GET /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/reviews anlamlı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:

http
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.
MetotSafeIdempotentTipik kullanım
GETevetevetOkuma
HEADevetevetSadece header (var mı, boyutu, ETag)
OPTIONSevetevetCORS preflight, desteklenen metotlar
PUThayırevetKaynağı verilen temsille değiştir (ya da bilinen URL'de oluştur)
DELETEhayırevetSil
POSThayırhayırOluştur, süreç başlat, aksiyon
PATCHhayırhayı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. null alanı 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 alan null olur. JsonNode ya da Optional/JsonNullable sarmalayıcıları gerekir.
  • JSON Patch (RFC 6902), application/json-patch+json: İşlem listesi (add, remove, replace, test...). Güçlüdür, test iş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.

KodNe zamanNot
200 OKBaşarılı okuma ya da güncelleme, gövde var
201 CreatedYeni kaynak oluştuLocation header'ı yeni kaynağı gösterir
202 Acceptedİstek kabul edildi, işlem henüz bitmediDurum için bir izleme URL'si döndür
204 No ContentBaşarılı, gövde yokDELETE, gövdesiz PUT
400 Bad Requestİstek bozuk: parse edilemeyen JSON, tip hatası, validasyonİstemci isteği düzeltmeden tekrar denememeli
401 UnauthorizedKimlik yok ya da geçersiz (token yok, süresi dolmuş)Adı yanıltıcı: anlamı "unauthenticated". WWW-Authenticate header'ı ile döner
403 ForbiddenKimlik belli ama bu işe yetkisi yokTekrar giriş yapmak çözmez
404 Not FoundHedef URI'deki kaynak yokYetkisiz kaynağın varlığını gizlemek için de kullanılır
409 Conflictİstek kaynağın mevcut durumuyla çakışıyorDuplicate (unique), geçersiz durum geçişi, eşzamanlı işlem
412 Precondition FailedIf-Match gibi bir ön koşul tutmadıOptimistic concurrency
422 Unprocessable ContentSözdizimi doğru, içerik iş kuralına aykırıRFC 9110 ile çekirdek HTTP'ye girdi (eskiden WebDAV, RFC 4918)
429 Too Many RequestsRate limit aşıldıRetry-After ile (RFC 6585)
500 Internal Server ErrorBeklenmeyen sunucu hatasıDetay istemciye değil log'a
502 / 504Upstream geçersiz cevap / upstream timeoutGateway ve proxy'lerin kodu
503 Service UnavailableGeçici olarak hizmet verilemiyor (bakım, aşırı yük)Retry-After eklenebilir

Karar verirken üç soru sor:

  1. İstek okunabiliyor mu? JSON bozuk, zorunlu alan eksik, tip yanlış: 400.
  2. Kim olduğunu biliyor muyum, yetkisi var mı? Bilmiyorum: 401. Biliyorum ama yetkisi yok: 403 (ya da varlığı gizlemek için 404).
  3. İ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:

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

http
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şleniyor409

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.
sql
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;
java
@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:

  1. GET /products/5 cevabı bir ETag taşır: kaynağın o anki sürümünün etiketi (ETag: "7").
  2. İstemci güncellerken If-Match: "7" gönderir: "Sadece kaynak hâlâ 7. sürümdeyse uygula."
  3. Sunucu güncel sürümü 8 ise 412 Precondition Failed döner. İstemci yeniden okur, birleştirir, tekrar dener.
  4. Sunucu koşullu güncellemeyi zorunlu tutmak istiyorsa ve If-Match gelmemiş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.

java
@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);
}
java
@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 Page dönüşü her istekte ek bir COUNT(*) 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.

sql
-- 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_at ile sıralarsan aynı zaman damgalı kayıtlar sayfa sınırında kaybolur; bu yüzden id ikinci 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:

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

  • size iç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-size ile düşür.
  • Sıralama alanlarını beyaz listeyle sınırla. Spring Data dokümantasyonu, istekten çözülen Sort değ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ÖrnekArtıEksi
URI path/api/v1/ordersGörünür, log'da ve gateway routing'de kolay, cache dostu"Kaynak aynı, URI farklı" REST açısından tartışmalı
HeaderX-API-Version: 2URI temiz kalırGörünmez; cache için Vary gerekir; tarayıcıdan test zor
Media typeAccept: application/vnd.shop.order.v2+jsonHTTP'nin content negotiation modeline en uygunEn karmaşık; araç desteği zayıf
Query param?version=2BasitOpsiyonel 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:

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

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

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

java
@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:

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:

  • ProblemDetail sınıfı ve ErrorResponse arayüzü. Spring MVC'nin kendi exception'ları (MethodArgumentNotValidException, HttpMessageNotReadableException, NoResourceFoundException...) ErrorResponse uygular ve hazır bir ProblemDetail gö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 bir ResponseEntityExceptionHandler advice'ı kaydeder ve MVC hataları application/problem+json döner. Varsayılan false'tur.

Domain exception hiyerarşisi. HTTP'yi domain'e sızdırmadan, kategoriye göre hata modellemek:

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

java
@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ında AccessDeniedException fı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ıyorsan traceId) ver; destek ekibi log'da bu id ile arar. Boot'un varsayılan /error cevabında da mesaj ve stack trace varsayılan olarak never'dır (Boot 3.x'te server.error.include-message, server.error.include-stacktrace; Boot 4'te bu özellikler spring.web.error.* altına taşındı). Bunları production'da açma.
  • Log seviyesi. 4xx istemci hatasıdır: WARN ya da INFO, stack trace'siz. 5xx ise ERROR ve stack trace'li. Her 404'ü ERROR ile 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 LazyInitializationException alı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.
  • @Transactional proxy ile çalışır: aynı sınıf içinden this.method() çağrısı proxy'den geçmez (self-invocation), private metotlarda 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.yaml yazı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ı:

ClientModelDurum
RestTemplateSenkron, template metotlarSpring 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.
RestClientSenkron, akıcı (fluent) APISpring Framework 6.1 / Boot 3.2+. Spring ekibinin çoğu uygulama için önerdiği client.
WebClientReactive, non-blockingWebFlux 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üzSpring 6+. RestClient ya da WebClient üzerinde çalışır. Spring 7 ile @ImportHttpServices ile gruplar halinde yapılandırma.
OpenFeignDeklaratif arayüzSpring 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.

java
@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();
    }
}
java
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:

java
@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-Authenticate header'ı.
  • 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:

java
@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:

  1. 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).
  2. İç içe validasyon: List<@Valid OrderItemRequest> olmadan quantity: -3 geçer.
  3. Fiyat sunucuda: İstek gövdesinde fiyat olsa bile kullanılmaz. Fiyat DB'deki üründen okunur, toplam BigDecimal ile hesaplanır (price.multiply(BigDecimal.valueOf(quantity))). İstemciye güvenmek, fiyatı 0,01 TL'ye çekip sipariş vermenin kapısını açar.
  4. N+1 yok: Ürünler tek findAllById(ids) ile çekilir; eksik id'ler (ids - bulunanlar) tek seferde raporlanır.
  5. 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.
  6. @Transactional servis metodunda, okuma readOnly = true.
  7. Idempotency: Idempotency-Key header'ı, (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 olarak 409 "Duplicate request"e çeviriyor. Bu exception her constraint ihlalinde fırlar (FK, NOT NULL, CHECK); örneğin var olmayan bir customerId FK hatası da "duplicate" olarak raporlanır. Daha iyisi: idempotency yarışını servis içinde, yukarıdaki OrderService ö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ı @Transactional metotta. 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.
  • Location için göreli URI kabul edilebilir (RFC 9110), ama gateway arkasında prefix değişiyorsa ServletUriComponentsBuilder daha 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 idempotent QUERY metodu 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 ve list fiili; 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) @Positive int'te çalışmaz: Yanlış. @Positive primitive sayısal tiplerde ve sarmalayıcılarda çalışır. Fark sadece null durumundadır: primitive int JSON'da yoksa 0 olur ve @Positive bunu yakalar; Integer ise null olur ve Bean Validation constraint'lerinin çoğu null'u geçerli sayar (@NotNull ayrıca gerekir).
  • c) Starter yok: Starter olmasaydı @NotEmpty de çalışmazdı; soru boş listenin reddedildiğini varsayıyor.
  • d) @Validated yazılmalı: @Validated grup seçimi ve AOP tabanlı method validation içindir; cascade problemini çözmez, iç nesneye yine @Valid gerekir.

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. printStackTrace stderr'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 null kontrolü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) RestClient Framework 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 @Deprecated olacağı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) WebClient reactive 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?"

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?"

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?"

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?"

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:

json
{
  "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).

java
@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 @Valid unutmak → Elemanlar doğrulanmaz, negatif adet geçer → List<@Valid ItemRequest>.
  • Controller'a sınıf seviyesinde @Validated (Spring 6.1+) → ConstraintViolationException ele alınmazsa 500 → Constraint'i parametreye koy, yerleşik method validation'ı kullan ve HandlerMethodValidationException'ı 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, BigDecimal ile.
  • Entity'yi request/response olarak kullanmak → Lazy loading hataları, sızıntı, mass assignment → Record DTO'lar.
  • @Transactional controller'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, serbest sort → 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ış RestClient bean'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) ile AccessDeniedException'ı 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