Live coding · Refactoring · Exception handling · Config

Ödeme sağlayıcıları: tekrar eden kodu toparla, üçüncüsünü ekle

ZorHedef 45 dk

Spring tabanlı live coding görevlerine benzeyen kurgu

00:00/ 45:00 hedefÖnce kendin çöz; ipuçlarını sırayla aç.

Projede iki ödeme sağlayıcısı entegre: Iyzico ve PayTR. Her birinin kendi controller'ı ve servisi var; kod büyük ölçüde kopyala-yapıştır. Üçüncü sağlayıcı Papara eklenecek. Her sağlayıcının üç işlemi var: authorize, refund, redirect-url.

(Kod sadeleştirilmiştir. PayTR servis ve controller'ı Iyzico'nun aynısı; sadece URL ve key farklı.)

Görevler

  1. Papara'yı ekle. Bunu yaparken tekrar eden kodu artırma.
  2. Üç sağlayıcı için tek bir controller: /api/v1/payments/{provider}/authorize | refund | redirect-url.
  3. Her serviste tekrar eden HTTP çağrı kodunu ortak bir yapıya taşı.
  4. Exception handling'i düzelt: BusinessException ve GlobalExceptionHandler; anlamlı HTTP kodları.
  5. Hard-coded URL ve API key'leri konfigürasyona (application.yml) taşı.
  6. Kritik bir sınıf için unit test yaz.
Başlangıç kodu
// ===== IyzicoController.java =====
@RestController
@RequestMapping("/iyzico")
public class IyzicoController {

    @Autowired
    IyzicoService iyzicoService;

    @PostMapping("/authorize")
    public ResponseEntity<?> authorize(@RequestBody PaymentRequest req) {
        try {
            return ResponseEntity.ok(iyzicoService.authorize(req));
        } catch (Exception e) {
            return ResponseEntity.status(500).body("error");
        }
    }

    @PostMapping("/refund")
    public ResponseEntity<?> refund(@RequestBody RefundRequest req) {
        try {
            return ResponseEntity.ok(iyzicoService.refund(req));
        } catch (Exception e) {
            return ResponseEntity.status(500).body("error");
        }
    }
}

// ===== IyzicoService.java =====
@Service
public class IyzicoService {

    private RestTemplate restTemplate = new RestTemplate();

    public PaymentResponse authorize(PaymentRequest req) {
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        headers.set("Authorization", "Bearer sk_live_iyzico_123");
        HttpEntity<PaymentRequest> entity = new HttpEntity<>(req, headers);
        ResponseEntity<PaymentResponse> resp = restTemplate.postForEntity(
                "https://api.iyzico.example/v2/auth", entity, PaymentResponse.class);
        if (resp.getStatusCode() != HttpStatus.OK) {
            throw new RuntimeException("iyzico error");
        }
        return resp.getBody();
    }

    public PaymentResponse refund(RefundRequest req) {
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        headers.set("Authorization", "Bearer sk_live_iyzico_123");
        HttpEntity<RefundRequest> entity = new HttpEntity<>(req, headers);
        ResponseEntity<PaymentResponse> resp = restTemplate.postForEntity(
                "https://api.iyzico.example/v2/refund", entity, PaymentResponse.class);
        if (resp.getStatusCode() != HttpStatus.OK) {
            throw new RuntimeException("iyzico error");
        }
        return resp.getBody();
    }
}

// PaytrController + PaytrService: yukarıdakilerin aynısı ("/paytr", "https://api.paytr.example/...", "sk_live_paytr_456")
// TODO: Papara (authorize, refund, redirect-url) eklenecek
İpucu 1

Önce ortak bir sözleşme tanımla: PaymentGateway { String provider(); authorize; refund; redirectUrl }. Controller sağlayıcıyı path'ten alıp bir registry'den (Map) seçsin.

İpucu 2

Tekrar eden header ve POST kodunu, sağlayıcı konfigürasyonunu (baseUrl, apiKey, timeout) alan ortak bir client'a taşı. Boot 3.2+ ise RestClient; eski Boot'ta RestTemplateBuilder ile timeout'lu RestTemplate.

İpucu 3

try/catch(Exception) → 500 "error" blokları gitmeli. BusinessException(errorCode, status); bilinmeyen sağlayıcı → 404/400, sağlayıcı hatası → 502. @ConfigurationProperties(prefix = "payment") ile Map<String, ProviderProperties>. Secret'lar env/Vault'tan gelir, koda ve yml'a yazılmaz.

Referans çözümü göster
Çözüm
// ===== application.yml =====
// payment:
//   providers:
//     iyzico: { base-url: https://api.iyzico.example/v2, api-key: ${IYZICO_API_KEY}, timeout: 3s }
//     paytr:  { base-url: https://api.paytr.example,     api-key: ${PAYTR_API_KEY},  timeout: 3s }
//     papara: { base-url: https://api.papara.example,    api-key: ${PAPARA_API_KEY}, timeout: 3s }

// ===== PaymentProperties.java =====
@ConfigurationProperties(prefix = "payment")
public record PaymentProperties(Map<String, Provider> providers) {
    public record Provider(URI baseUrl, String apiKey, Duration timeout) {}

    public Provider get(String name) {
        Provider p = providers.get(name);
        if (p == null) throw new UnsupportedProviderException(name);
        return p;
    }
}

// ===== PaymentGateway.java =====
public interface PaymentGateway {
    String provider();                              // "iyzico", "paytr", "papara"
    PaymentResponse authorize(PaymentRequest request);
    PaymentResponse refund(RefundRequest request);
    RedirectUrlResponse redirectUrl(PaymentRequest request);
}

// ===== ProviderClient.java: tekrar eden HTTP kodu burada =====
public final class ProviderClient {
    private final String provider;
    private final RestClient restClient;

    public ProviderClient(String provider, PaymentProperties.Provider props, RestClient.Builder builder) {
        this.provider = provider;
        var factory = new SimpleClientHttpRequestFactory();
        factory.setConnectTimeout((int) props.timeout().toMillis());
        factory.setReadTimeout((int) props.timeout().toMillis());
        this.restClient = builder.clone()
                .baseUrl(props.baseUrl().toString())
                .requestFactory(factory)
                .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + props.apiKey())
                .build();
    }

    public <T> T post(String path, Object body, Class<T> responseType) {
        try {
            return restClient.post().uri(path)
                    .contentType(MediaType.APPLICATION_JSON)
                    .body(body)
                    .retrieve()
                    .body(responseType);
        } catch (RestClientResponseException e) {
            throw new ProviderException(provider, e.getStatusCode().value(), e.getResponseBodyAsString());
        } catch (ResourceAccessException e) {      // timeout, bağlantı hatası
            throw new ProviderUnavailableException(provider, e);
        }
    }
}

// ===== PaparaGateway.java (Iyzico ve PayTR de aynı kalıpta) =====
@Component
class PaparaGateway implements PaymentGateway {
    private final ProviderClient client;

    PaparaGateway(PaymentProperties properties, RestClient.Builder builder) {
        this.client = new ProviderClient(provider(), properties.get(provider()), builder);
    }

    @Override public String provider() { return "papara"; }

    @Override public PaymentResponse authorize(PaymentRequest r) { return client.post("/payments", PaparaMapper.toAuth(r), PaymentResponse.class); }
    @Override public PaymentResponse refund(RefundRequest r)     { return client.post("/refunds", PaparaMapper.toRefund(r), PaymentResponse.class); }
    @Override public RedirectUrlResponse redirectUrl(PaymentRequest r) { return client.post("/checkout-sessions", PaparaMapper.toSession(r), RedirectUrlResponse.class); }
}

// ===== PaymentGatewayRegistry.java =====
@Component
public class PaymentGatewayRegistry {
    private final Map<String, PaymentGateway> gateways;

    public PaymentGatewayRegistry(List<PaymentGateway> gateways) {
        this.gateways = gateways.stream()
                .collect(Collectors.toUnmodifiableMap(PaymentGateway::provider, Function.identity()));
    }

    public PaymentGateway get(String provider) {
        PaymentGateway gateway = gateways.get(provider.toLowerCase(Locale.ROOT));
        if (gateway == null) throw new UnsupportedProviderException(provider);
        return gateway;
    }
}

// ===== PaymentController.java =====
@RestController
@RequestMapping("/api/v1/payments/{provider}")
public class PaymentController {
    private final PaymentGatewayRegistry registry;

    public PaymentController(PaymentGatewayRegistry registry) { this.registry = registry; }

    @PostMapping("/authorize")
    public PaymentResponse authorize(@PathVariable String provider, @Valid @RequestBody PaymentRequest request) {
        return registry.get(provider).authorize(request);
    }

    @PostMapping("/refund")
    public PaymentResponse refund(@PathVariable String provider, @Valid @RequestBody RefundRequest request) {
        return registry.get(provider).refund(request);
    }

    @PostMapping("/redirect-url")
    public RedirectUrlResponse redirectUrl(@PathVariable String provider, @Valid @RequestBody PaymentRequest request) {
        return registry.get(provider).redirectUrl(request);
    }
}

// ===== Exceptions =====
public abstract class BusinessException extends RuntimeException {
    private final String errorCode;
    private final HttpStatus status;
    protected BusinessException(String errorCode, HttpStatus status, String message) {
        super(message); this.errorCode = errorCode; this.status = status;
    }
    public String errorCode() { return errorCode; }
    public HttpStatus status() { return status; }
}
public class UnsupportedProviderException extends BusinessException {
    public UnsupportedProviderException(String p) { super("PAYMENT_PROVIDER_NOT_SUPPORTED", HttpStatus.NOT_FOUND, "Unsupported provider: " + p); }
}
public class ProviderException extends BusinessException {
    public ProviderException(String p, int upstream, String body) { super("PAYMENT_PROVIDER_ERROR", HttpStatus.BAD_GATEWAY, p + " returned " + upstream); }
}

// ===== GlobalExceptionHandler.java =====
@RestControllerAdvice
class GlobalExceptionHandler {
    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(BusinessException.class)
    ResponseEntity<ProblemDetail> business(BusinessException e) {
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(e.status(), e.getMessage());
        pd.setProperty("errorCode", e.errorCode());
        return ResponseEntity.status(e.status()).body(pd);
    }

    @ExceptionHandler(Exception.class)
    ProblemDetail unexpected(Exception e) {
        log.error("Unexpected error", e);                       // detay log'da, kullanıcıya değil
        return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "Unexpected error");
    }
}

// ===== PaymentGatewayRegistryTest.java =====
class PaymentGatewayRegistryTest {
    @Test
    void resolvesProviderCaseInsensitively() {
        PaymentGateway papara = mock(PaymentGateway.class);
        when(papara.provider()).thenReturn("papara");
        var registry = new PaymentGatewayRegistry(List.of(papara));

        assertThat(registry.get("Papara")).isSameAs(papara);
    }

    @Test
    void rejectsUnknownProvider() {
        var registry = new PaymentGatewayRegistry(List.of());
        assertThatThrownBy(() -> registry.get("stripe")).isInstanceOf(UnsupportedProviderException.class);
    }
}
Açıklama: neden böyle?

Bu görevde mülakatçının aradığı beş şey:

  1. Abstraction + Strategy: Ortak sözleşme, sağlayıcı başına implementasyon, registry ile seçim. Papara eklemek bir sınıf ve bir yml satırı demek.
  2. DRY ama doğru yerde: Tekrar eden HTTP kodu (header, POST, hata çevirme) composition ile ortak bir client'ta toplanır. Bir AbstractGateway üst sınıfı da kabul edilir, ama composition daha esnektir. Sağlayıcıya özgü mapping (istek ve cevap formatları) her gateway'de kalır.
  3. Exception handling: catch (Exception e) → 500 "error" hem hatayı yutuyor hem istemciye anlamsız cevap veriyor. Domain exception hiyerarşisi + merkezi handler + ProblemDetail + errorCode. Sağlayıcı hatası 502, bilinmeyen sağlayıcı 404/400, validasyon 400.
  4. Konfigürasyon ve güvenlik: Koda gömülü canlı API key bir güvenlik açığıdır (git geçmişinde kalır). Değerler @ConfigurationProperties'a, secret'lar env/Vault'a taşınır. new RestTemplate()'te timeout yoktur; sağlayıcı yavaşlarsa thread'ler sonsuza kadar bekler.
  5. Test: Registry için saf unit test. Gateway'ler için MockRestServiceServer veya WireMock ile HTTP seviyesinde test.

Eski Spring sürümü verilirse (bir aday Spring 2.2 ile karşılaştığını anlatıyor): RestClient yoktur (Boot 3.2+). RestTemplateBuilder ile timeout'lu RestTemplate kullan. ProblemDetail Boot 3'te geldi; eski sürümde kendi ErrorResponse record'unu dön. Lombok veya Feign eklemeye çalışıp 15 dakika kaybetmek yerine "bunu eklemek isterdim ama build ile vakit kaybetmeyelim, elle yazıyorum" de.

  • Ortak PaymentGateway interface'i ve sağlayıcı başına implementasyon (Papara dahil)
  • Tek controller: /api/v1/payments/{provider}/... ve registry ile seçim (Map)
  • Tekrar eden HTTP kodu ortak client'a taşındı (RestClient/RestTemplate wrapper)
  • BusinessException + @RestControllerAdvice; anlamlı status kodları (400/404/502)
  • URL ve key'ler konfigürasyonda; secret'lar koddan çıkarıldı (env/secret)
  • Timeout ve dayanıklılık (connect/read timeout, gerekirse retry/circuit breaker)
  • Unit test (gateway seçimi veya hata dönüşümü)
  • Authorize çağrısı timeout aldı ama sağlayıcı ödemeyi aslında aldıysa ne olur? Çift çekimi nasıl önlersin? (idempotency key)
  • Sağlayıcılardan biri 10 dakikalığına çöktü. Kullanıcıyı başka sağlayıcıya yönlendirmek ister misin? Nasıl? (circuit breaker + fallback)
  • Refund'ı neden senkron yapmayabilirsin? (asenkron, outbox, retry)
  • Bu refactor'ı production'da tek PR'da mı yaparsın, adım adım mı?