Zanaat · Bölüm 17

SOLID, design pattern'ler ve refactoring

SOLID ilkelerini gerçek ihlal ve düzeltmeleriyle, sık sorulan pattern'leri Spring'deki karşılıklarıyla ve güvenli refactoring adımlarını anlatır; refactoring odaklı live coding formatına en yakın iki kata'yı (kargo ücreti ve ödeme sağlayıcıları) adım adım çözer.

~45 dk okumaQuiz: q19, k1, k2, z7, z8
Cevaplar:

Bu bölümde

SOLID'in her harfini e-ticaretten gerçek bir ihlal ve düzeltmesiyle, mülakatta en çok sorulan pattern'leri Spring'deki karşılıklarıyla ve kötü kodu davranışı bozmadan iyileştirmenin adımlarını öğreneceksin. Bölümün ağırlık merkezi iki live coding kata'sı: if/else ile yazılmış kargo ücreti servisini Strategy'ye çevirmek (k1) ve kopyala-yapıştır ödeme entegrasyonlarını tek bir yapıya toplayıp üçüncü sağlayıcıyı eklemek (k2).

Refactoring odaklı live coding mülakatları sıklıkla bu formattadır: "Bu kodu production'a çıkacak hale getir." Mülakatçı pattern adını söylemenden çok, sorunları fark etmeni, önceliklendirmeni, adım adım ve test ederek ilerlemeni ve kararlarını gerekçelendirmeni ister.

Konu anlatımı

SOLID neden var

SOLID beş ilkenin baş harfleridir (Robert C. Martin'in derlemesi): tek amaçları değişimin maliyetini düşürmektir. Kod bir kez yazılır, yüzlerce kez değiştirilir. Kötü tasarımın belirtileri hep değişim anında görünür: küçük bir istek için on dosyaya dokunmak, bir yeri düzeltirken alakasız bir yeri bozmak, her PR'da aynı dosyada çakışma.

İlkeler kural değil, gerekçedir. Bir ilkeyi uygulamanın da maliyeti vardır (daha çok sınıf, daha çok dolaylılık); bu yüzden mülakatta her ilkeyi "ne zaman uygularım, ne zaman uygulamam" ile birlikte anlatmak gerekir.

S: Single Responsibility

Yaygın tanım "bir sınıf tek bir iş yapmalı" eksiktir. Martin'in kendi ifadesi: bir modülün değişmek için tek bir nedeni olmalı, ve "neden" aslında insanlardır: aynı nedenle değişen şeyleri bir araya topla, farklı nedenlerle değişenleri ayır.

İhlal:

java
@Service
public class OrderService {

    public Order place(PlaceOrderCommand command) {
        BigDecimal total = applyCampaigns(command);          // fiyatlama/pazarlama ekibinin kuralları
        Order order = orderRepository.save(Order.create(command, total));
        byte[] invoicePdf = renderInvoice(order);              // finans: fatura formatı, vergi satırları
        mailSender.send(buildHtmlEmail(order, invoicePdf));    // CRM: şablon, dil, kampanya bannerı
        return order;
    }
}

Bu sınıf üç farklı ekibin talebiyle değişir. Pazarlama yeni bir kampanya tipi istediğinde fatura kodunun bulunduğu dosyaya dokunulur; CRM e-posta şablonunu değiştirirken bir merge conflict sipariş akışını riske atar. Ayrıca e-posta gönderimi yavaşladığında sipariş oluşturma da yavaşlar.

Düzeltme, değişim nedenlerine göre ayırmaktır: CampaignPricer fiyatı hesaplar, OrderService siparişi oluşturur ve bir OrderPlaced event'i yayınlar, InvoiceService ve OrderNotificationService bu event'e tepki verir (bkz. Observer). Yan fayda: e-posta artık sipariş transaction'ının dışında.

Aşırısı da kötüdür: her metodu ayrı bir sınıfa bölmek, tek bir değişikliğin beş sınıfa dağılmasına (shotgun surgery) yol açar. Ölçüt sınıf boyutu değil, değişim nedenidir.

O: Open/Closed

"Genişlemeye açık, değişikliğe kapalı": yeni davranış eklemek için mevcut, test edilmiş kodu değiştirmek gerekmemeli. Klasik ihlal, her yeni kargo firmasında else if eklenen metottur (k1 ve q19). Her ekleme test edilmiş metodu değiştirir, regresyon riski yaratır ve aynı metotta çalışan geliştiriciler sürekli çakışır.

Düzeltme genelde bir soyutlama (interface) ve çok biçimliliktir: yeni firma = yeni bir sınıf. Önemli nüans: OCP her yere uygulanmaz. Gerçekten değişmesi muhtemel noktaları korur. Hiç değişmeyecek bir hesaplamayı "ileride lazım olur" diye interface'in arkasına koymak, değişimi kolaylaştırmaz, sadece okumayı zorlaştırır. İlk firma için if yeterlidir; ikinci ve üçüncü geldiğinde değişim ekseni kendini gösterir.

L: Liskov Substitution

Alt tip, üst tipin kullanıldığı her yerde, kullanan kodun sözleşmeye dair varsayımlarını bozmadan kullanılabilmeli. Somut kurallar: alt tip ön koşulları (preconditions) sıkılaştıramaz, son koşulları (postconditions) gevşetemez, üst tipin invariant'larını bozamaz.

İhlal: ShippingFeePolicy.feeFor(quote) sözleşmesi "her geçerli teklif için negatif olmayan bir ücret döner" diyor. Yeni eklenen bir gel-al noktası politikası 30 kg üstü paketlerde null dönüyor:

java
class PickupPointFeePolicy implements ShippingFeePolicy {
    @Override
    public BigDecimal feeFor(ShipmentQuote quote) {
        if (quote.weightKg().compareTo(MAX_WEIGHT) > 0) {
            return null;                    // son koşulu gevşetiyor: "her zaman ücret döner" bozuldu
        }
        return FEE;
    }
}

Hesaplayıcı sınıf diğer tüm politikalarla doğru çalışırken bu politikayla NullPointerException verir. Kullanan kod artık "hangi alt tip geldi" diye kontrol etmek zorundadır ve bu, polimorfizmin bütün faydasını yok eder. Java'nın kendisinde de bilinen bir örnek vardır: List.of(...) bir List'tir ama add çağrısı UnsupportedOperationException fırlatır (koleksiyon API'si bu metotları "opsiyonel işlem" olarak tanımlayarak sözleşmeyi gevşetmiştir).

Düzeltme, kısıtı sözleşmenin açık bir parçası yapmaktır: interface'e boolean supports(ShipmentQuote quote) eklemek ya da dönüş tipini Optional<BigDecimal> yapmak. Senior pratik: interface'in sözleşmesini Javadoc'ta yazmak ve her implementasyonun extend ettiği soyut bir contract test sınıfıyla doğrulamak; yeni politika eklendiğinde sözleşme testleri otomatik koşar.

I: Interface Segregation

İstemci kullanmadığı metotlara bağımlı olmamalı. İhlal, her şeyi bilen şişman bir arayüzdür:

java
public interface PaymentProvider {
    PaymentResponse authorize(PaymentRequest request);
    PaymentResponse refund(RefundRequest request);
    List<InstallmentOption> installmentOptions(BinNumber bin, Money amount);
    ThreeDsSession startThreeDs(PaymentRequest request);
    PayoutResult payoutToSeller(Payout payout);
}

Hediye kartı sağlayıcısı taksit, 3D Secure ve satıcıya ödeme desteklemez; bu metotlarda UnsupportedOperationException fırlatır. Bu hem ISP hem LSP ihlalidir: arayüz desteklenmeyen davranışı vaat ediyor. Ayrıca taksit ekranını yapan ekip, hiç kullanmadığı payoutToSeller değiştiğinde de etkilenir.

Düzeltme, rol arayüzleridir: PaymentAuthorizer, RefundCapable, InstallmentProvider. Spring'de List<InstallmentProvider> enjekte edildiğinde sadece taksit destekleyen sağlayıcılar gelir; "destekliyor mu" kontrolü tip sistemine taşınır. Spring Data'da da aynı fikir uygulanabilir: her repository'yi JpaRepository'den türetmek yerine Repository<Order, UUID>'den türetip sadece gereken metotları (findById, save) tanımlamak, ör. silme işleminin hiç açılmamasını sağlar.

D: Dependency Inversion

Üst seviye politika (sipariş akışı) alt seviye detaya (Iyzico HTTP client'ı) değil, bir soyutlamaya bağlı olmalı; ve o soyutlamanın sahibi üst seviye modüldür.

java
// İhlal: iş akışı somut bir sağlayıcıya ve saate sıkıca bağlı
public class CheckoutService {
    private final IyzicoClient iyzico = new IyzicoClient("sk_live_...");

    public Receipt checkout(Cart cart) {
        var response = iyzico.pay(cart.total().doubleValue());
        return new Receipt(response.id(), LocalDateTime.now());
    }
}

Düzeltme: domain bir PaymentGateway port'u tanımlar; Iyzico, PayTR ve Papara bu port'un adapter'larıdır ve altyapı katmanında yaşar. Zaman da bir bağımlılıktır: Clock enjekte edilir.

java
public class CheckoutService {
    private final PaymentGatewayRegistry gateways;
    private final Clock clock;

    public CheckoutService(PaymentGatewayRegistry gateways, Clock clock) {
        this.gateways = gateways;
        this.clock = clock;
    }
}

DI ile DIP aynı şey değildir. Spring'in constructor injection'ı (Dependency Injection) bir mekanizmadır; somut IyzicoClient'ı da enjekte edebilir ve bu DIP'i sağlamaz. DIP bağımlılığın yönü ile ilgilidir: domain kodu altyapıyı import etmez, altyapı domain'in arayüzünü uygular. Hexagonal mimari (ports and adapters) bu ilkenin mimari ölçekteki uygulamasıdır.

Pattern'ler ve Spring'deki karşılıkları

PatternÇözdüğü problemSpring/Java'daki örnek
StrategyAynı sözleşmeli algoritmalar arasında çalışma anında seçimList/Map enjeksiyonuyla registry, Comparator
FactoryNesne oluşturmayı ve seçimini soyutlamakStatic factory (List.of, Order.create), FactoryBean
Template MethodSabit akış, değişen adımlarOncePerRequestFilter.doFilterInternal, AbstractHealthIndicator.doHealthCheck
Template + CallbackTemplate Method'un kompozisyonla yapılan haliJdbcTemplate, TransactionTemplate, RestTemplate
BuilderÇok parametreli, opsiyonel alanlı nesne kurmakRestClient.builder(), UriComponentsBuilder, ResponseEntity.status(...).body(...)
DecoratorDavranışı aynı arayüzü sararak katman katman eklemekBufferedInputStream, Resilience4j Decorators, request wrapper'ları
AdapterUyumsuz bir arayüzü beklenen arayüze uydurmakSpring MVC HandlerAdapter, sağlayıcı başına mapper'lar
ObserverBir olaya birden çok bağımsız tepkiApplicationEventPublisher, @EventListener, @TransactionalEventListener
Chain of Responsibilityİsteği bir işleyici zincirinden geçirmekServlet FilterChain, Spring Security filter chain, HandlerInterceptor
ProxyÇağrının önüne geçip davranış eklemek@Transactional, @Cacheable, @Async (AOP proxy'leri)
SingletonTek örnekSpring'in varsayılan bean scope'u (GoF Singleton'dan farklı)

Strategy. k1'in konusu. Spring'de tüm implementasyonlar List<ShippingFeePolicy> olarak enjekte edilir ve açılışta bir Map'e çevrilir. Spring Map<String, ShippingFeePolicy> de enjekte edebilir; anahtar bean adıdır. Kısa yoldur ama seçimi bean isimlendirmesine (string) bağlar; implementasyonun kendi anahtarını bir metotla (carrier()) bildirmesi daha açık ve tip güvenlidir.

Factory. Nesne oluşturmayı tek bir yerde toplar: Carrier.from(String) geçersiz girdiyi domain exception'a çevirir, Order.create(command, now) siparişin her zaman geçerli bir durumla doğmasını garanti eder. Strategy ile farkı: factory yeni nesne üretir (ya da hangi nesnenin üretileceğine karar verir), strategy davranış sağlar. Hazır bean'lerden birini seçen bir registry'ye bazen "factory" denir; isim tartışmasına girmek yerine ne yaptığını söylemek yeterlidir.

Template Method. Üst sınıf akışı final bir metotta sabitler, alt sınıflar adımları doldurur:

java
public abstract class OncePerRequestFilter /* Spring'in gerçek sınıfı, basitleştirilmiş */ {
    public final void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) {
        // "bu istekte zaten çalıştım mı" kontrolü, async/error dispatch kuralları...
        doFilterInternal((HttpServletRequest) req, (HttpServletResponse) res, chain);
    }
    protected abstract void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain);
}

Bedeli kalıtımdır: alt sınıf üst sınıfın iç detaylarına bağlanır ve Java'da tek bir üst sınıf hakkı vardır. Spring'in JdbcTemplate gibi *Template sınıfları bu yüzden kalıtım değil kompozisyon kullanır: akışı (connection al, statement hazırla, exception'ı çevir, kaynakları kapat) template yönetir, değişen kısmı sen bir callback (lambda) olarak verirsin. Mülakatta JdbcTemplate'i "Template Method" diye vermek yaygındır ama doğrusu "template-callback, Template Method'un kompozisyon hali"dir.

Builder. Çok sayıda opsiyonel parametresi olan nesnelerde telescoping constructor'ı önler ve build() anında invariant'ları doğrulama noktası verir. Record'larda az alan varsa canonical constructor yeterlidir; builder'ı alan sayısı ve opsiyonellik gerçekten arttığında ekle.

Decorator. Aynı arayüzü uygulayıp bir delegate'i sararak davranış ekler; k1'in takip sorusundaki "tüm firmalarda %50 kampanya" için doğal çözümdür:

java
final class DiscountedFeePolicy implements ShippingFeePolicy {

    private final ShippingFeePolicy delegate;
    private final BigDecimal discountRate;

    DiscountedFeePolicy(ShippingFeePolicy delegate, BigDecimal discountRate) {
        this.delegate = delegate;
        this.discountRate = discountRate;
    }

    @Override public Carrier carrier() { return delegate.carrier(); }

    @Override
    public BigDecimal feeFor(ShipmentQuote quote) {
        return delegate.feeFor(quote)
                .multiply(BigDecimal.ONE.subtract(discountRate))
                .setScale(2, RoundingMode.HALF_UP);
    }
}

Mevcut politikalara dokunulmaz; kampanya aktifse hesaplayıcı politikaları sarar. Spring'de bir bean'i dekore etmek için genelde sarmalayan bean @Primary yapılır ve orijinali @Qualifier ile alır.

Adapter. Her ödeme sağlayıcısının istek ve cevap formatı farklıdır; PaparaMapper gibi sınıflar domain'in PaymentRequest'ini sağlayıcının formatına çevirir. Daha büyük ölçekte buna anti-corruption layer denir: dış sistemin modeli domain'e sızmaz. Spring MVC'nin HandlerAdapter'ı da farklı handler türlerini DispatcherServlet'in tek bir arayüzle çağırmasını sağlar.

Observer. ApplicationEventPublisher.publishEvent(new OrderPlaced(...)) ve @EventListener metotları. Varsayılan olarak senkrondur: listener aynı thread'de ve aynı transaction'da çalışır, exception fırlatırsa yayınlayanı da etkiler. @TransactionalEventListener (varsayılan faz AFTER_COMMIT) listener'ı commit sonrasına erteler; "sipariş commit olmadan e-posta gitmesin" için doğru araçtır. Ama event bellektedir: commit ile listener arasında process ölürse kaybolur. Kaybolmaması gereken event'ler için transactional outbox gerekir (bkz. Bölüm 13).

Chain of Responsibility. İstek bir işleyici zincirinden geçer; her işleyici işi yapar ve devreder ya da zinciri keser. Servlet filter'ları ve Spring Security'nin filter chain'i bunun örneğidir. Domain'de dolandırıcılık kontrolleri (blacklist, hız sınırı, risk skoru; biri reddederse durur) iyi bir adaydır. k4'teki indirim kuralları listesi ise klasik CoR değil bir pipeline'dır: her kural çalışır, hiçbiri zinciri kesmez.

Proxy. Spring AOP, @Transactional, @Cacheable, @Async gibi anotasyonları bean'in etrafına bir proxy koyarak uygular. Interface için JDK dynamic proxy, sınıf için CGLIB alt sınıfı üretilir; Spring Boot varsayılan olarak CGLIB kullanır. Sonuçları mülakatta sıkça sorulur: aynı sınıf içinden yapılan çağrı (self-invocation) proxy'den geçmez ve anotasyon etkisiz kalır; final ve private metotlar CGLIB ile araya alınamaz (bkz. Bölüm 6 ve 8).

Singleton. GoF Singleton (private constructor, static örnek) global durum yaratır, test etmeyi zorlaştırır ve gizli bir bağımlılıktır; Java'da gerekirse Effective Java'nın önerdiği tek elemanlı enum ile yazılır. Spring'in singleton scope'u farklı bir şeydir: container başına, bean tanımı başına bir örnek. Aynı sınıftan iki farklı bean tanımı iki örnek demektir ve testte kolayca değiştirilebilir. Sonucu: singleton bean'ler tüm thread'ler tarafından paylaşılır, bu yüzden stateless olmalıdır; istek bazlı durumu alanda tutmak race condition'dır (bkz. Bölüm 6).

Pattern ezberi ve gerekçe

Mülakatçı "Strategy pattern kullanırım" cümlesinden bir şey öğrenmez. Öğrendiği şey gerekçedir: "Değişim ekseni kargo firması; firmalar birbirinden bağımsız değişiyor ve sık ekleniyor. Her firmanın kuralını ayrı bir sınıfa alırsam yeni firma mevcut koda dokunmadan eklenir, her kural tek başına test edilir." Pattern adı bu gerekçenin kısaltmasıdır, yerine geçmez.

İyi bir pattern cevabının yapısı: problem (ne değişiyor, ne acıtıyor) → seçenekler (if/else, enum içinde davranış, Strategy) → seçim ve maliyeti (daha çok sınıf, dolaylılık) → ne zaman geri adım atarım.

Over-engineering: ne zaman soyutlamam

  • Tek implementasyonlu interface: "İleride başka implementasyon gelebilir" tek başına gerekçe değildir. Mockito sınıfları da mock'layabilir; interface'i ikinci implementasyon geldiğinde çıkarmak bir IDE refactoring'idir.
  • Üç kuralı (rule of three): Bir şeyi ilk kez yaz, ikinci kez kopyala ve rahatsız ol, üçüncüde soyutla. Erken soyutlama yanlış ekseni seçer.
  • Yanlış soyutlama tekrar etmekten pahalıdır (Sandi Metz). Ortak bir üst sınıfa toplanmış ama her yeni ihtiyaçta bir boolean parametre ve bir if daha alan kod, iki kopyadan daha zor değişir. Böyle bir soyutlamayı geri açmak (inline etmek) meşru bir refactoring'dir.
  • Speculative generality: "Konfigüre edilebilir kural motoru" istemeyen bir iş için DSL yazmak.
  • Pattern sayısı kalite ölçütü değildir. 35 dakikalık bir live coding'de Strategy, Decorator, Factory, Builder ve Observer'ı birden kullanan aday genelde kötü izlenim bırakır.

YAGNI ile OCP çelişmez: OCP'yi değişimin kanıtlandığı eksene uygularsın. k1'de firmalar zaten üç tane ve "sık ekleniyor" denmiş; eksen kanıtlı. Aynı kodda "ücret para birimi" ekseni için soyutlama eklemek ise spekülatiftir.

Refactoring: davranışı bozmadan tasarımı değiştirmek

Refactoring, gözlemlenebilir davranışı değiştirmeden iç yapıyı iyileştirmektir. Davranış değişikliği (bir bug'ı düzeltmek dahil) ayrı bir adım ve tercihen ayrı bir commit'tir. Bu ayrım hem review'ı kolaylaştırır hem de bir şey bozulduğunda nedenini bulmayı.

Önce güvenlik ağı: characterization test. Michael Feathers'ın terimi: testi olmayan legacy kodda önce kodun şu anki davranışını sabitleyen testler yazılır; "doğru" davranışı değil, mevcut davranışı. Test yazarken beklenen değeri bilmiyorsan önce yanlış bir değer yazıp hata mesajındaki gerçek değeri alırsın. Bug gibi görünen davranışı da (bilinmeyen firma → 0 TL) testte belgeler, sonra bilinçli bir adımda değiştirirsin.

Küçük adımlar. Her adımdan sonra derle, testleri çalıştır, mümkünse commit et. IDE'nin otomatik refactoring'leri (Extract Method, Rename, Introduce Parameter Object, Change Signature) elle yapılanlardan güvenlidir.

Mülakatta adıyla söylenmeye değer refactoring'ler (Fowler'ın kataloğundan):

RefactoringNe yapark1'deki karşılığı
Extract Function (1. baskıda Extract Method)Bir kod parçasını isimli bir metoda çıkarırHer firmanın kuralını önce ayrı metoda çıkarmak
Introduce Parameter ObjectHep birlikte dolaşan parametreleri bir nesnede toplar(weight, cartTotal) → ShipmentQuote record'u
Replace Primitive with ObjectAnlamlı bir kavramı ilkel tipten çıkarırString carrier → Carrier enum; double → BigDecimal
Replace Conditional with PolymorphismTip bazlı if/switch'i alt tiplere/strategy'lere dağıtırif/else zinciri → ShippingFeePolicy implementasyonları
Replace Magic LiteralAnlamı olan sabit değere isim verir89.90 → HEAVY_FEE ya da konfigürasyon
Move FunctionDavranışı verisinin yanına taşırKural mantığını servisten politikaya taşımak
Inline Function / Inline ClassFayda sağlamayan dolaylılığı kaldırırYanlış soyutlamayı geri açmak

Büyük değişiklikler production'da tek PR ile yapılmaz. Yeni yapı eskisinin yanında kurulur, çağrılar kademeli taşınır, eski yol en son silinir (strangler ya da parallel change). Davranışın aynı kaldığından emin olmak için gerektiğinde iki yol bir süre gölge modda birlikte çalıştırılıp sonuçları karşılaştırılır.

Code smell'ler

Smell bir hata değil, daha yakından bakmaya değer bir belirtidir:

SmellBelirtiTipik refactoring
Long FunctionEkranı aşan, yorumlarla bölümlere ayrılmış metotExtract Function
Large ClassÇok sayıda alan, alakasız metot gruplarıExtract Class (SRP)
Long Parameter List / Data Clumps(carrier, weight, cartTotal) her yerde birlikteIntroduce Parameter Object
Primitive ObsessionPara için double, durum için String, id için longReplace Primitive with Object, value object
Repeated SwitchesAynı tip ayrımı birden çok yerdeReplace Conditional with Polymorphism
Divergent ChangeBir sınıf farklı nedenlerle sürekli değişiyorSınıfı nedenlere göre böl
Shotgun SurgeryBir değişiklik için çok sayıda sınıfa dokunuluyorİlgili davranışı bir araya taşı
Feature EnvyMetot kendi sınıfından çok başka sınıfın verisini kullanıyorMove Function
Duplicated CodeKopyala-yapıştır servisler (k2)Ortak yapıyı kompozisyonla çıkar
Speculative GeneralityKullanılmayan parametreler, tek implementasyonlu hiyerarşilerInline, sil
Message Chainsorder.getCustomer().getAddress().getCity().getName()Hide Delegate, tell-don't-ask
Mysterious Nameproduct_controller, calc, data2Rename

Quiz'deki sorular: çözüm ve nedenleri

k1: Kargo ücreti servisi: if/else'ten Strategy'ye

Senaryo. Ürün detay sayfasında kargo ücreti gösterilecek. Mülakatçı: "Bu kodu production'a çıkacak hale getir. Yeni kargo firmaları sık ekleniyor." Süre 35 dakika. Başlangıç kodu:

java
@Service
public class ShippingService {
    public double calculate(String carrier, double weight, double cartTotal) {
        if (carrier.equals("ARAS")) {
            if (weight > 5) return 89.90;
            return 49.90;
        } else if (carrier.equals("YURTICI")) {
            if (cartTotal >= 500) return 0;
            return 54.90;
        } else if (carrier.equals("MNG")) {
            return 39.90 + weight * 4.5;
        }
        return 0;
    }
}

@RestController
public class product_controller {
    @Autowired private ShippingService shippingService;
    @Autowired private ProductRepository productRepository;

    @GetMapping("/getShippingFee")
    public double getShippingFee(@RequestParam Long productId, @RequestParam String carrier) {
        // TODO: ürünü bul, kargo ücretini hesapla ve dön
        return 0;
    }
}

Adım 0: Önce oku, sorunları sesli listele, plan söyle (3-4 dk)

Koda dokunmadan önce gördüklerini önem sırasıyla söylemek, mülakatçıya hem analiz yeteneğini hem önceliklendirmeni gösterir:

  1. Sessiz bug: Bilinmeyen firma 0 dönüyor, yani bedava kargo. En kritik bulgu bu; para kaybettiren ve log bile bırakmayan bir hata.
  2. carrier.equals(...): carrier null ise NPE; büyük-küçük harfe duyarlı ("aras" bilinmeyen firma sayılıp bedava kargoya düşüyor).
  3. Open/Closed ihlali: Her yeni firma bu metodu değiştiriyor.
  4. Para için double: 0.1 + 0.2 gibi ikili kayan nokta hataları; 39.90 + weight * 4.5 yuvarlanmıyor.
  5. Magic number'lar: Ücretler ve eşikler kodun içinde, isimsiz.
  6. Primitive obsession ve data clump: Firma String; (weight, cartTotal) hep birlikte dolaşıyor.
  7. Controller: Java isimlendirme kuralına aykırı sınıf adı, field injection, fiil içeren URL (/getShippingFee), ürün yoksa davranış tanımsız, ilkel double cevap.

Plan cümlesi: "Önce Strategy refactor'ını ve controller'ı yapıp testlerini yazacağım, sonra isimlendirme ve konfigürasyona geçeceğim. Bilinmeyen firma için 0 yerine 400 döneceğim; bu bir davranış değişikliği, onaylıyor musunuz?" Son soru önemlidir: davranış değişikliğini sessizce yapmak yerine iş tarafına (mülakatçıya) teyit ettirirsin.

Netleştirme soruları: "cartTotal ürün detay sayfasında ürün fiyatı mı, sepet toplamı mı?", "MNG ücretinde yuvarlama kuralı ne?", "Ücretler deploy etmeden değişebilmeli mi?"

Adım 1: Güvenlik ağı (characterization test)

Zaman kısıtlı olduğu için kısa bir parameterized test yeter; mevcut davranışı belgeler:

java
@ParameterizedTest
@CsvSource({
        "ARAS,    5.0,   10, 49.90",
        "ARAS,    5.1,   10, 89.90",
        "YURTICI, 1.0,  500,  0.0",
        "YURTICI, 1.0, 499.99, 54.90",
        "MNG,     2.0,   10, 48.90",
        "UNKNOWN, 1.0,   10,  0.0"      // mevcut bug: bilinmeyen firma bedava; bilinçli olarak değişecek
})
void currentBehaviour(String carrier, double weight, double total, double expected) {
    assertThat(new ShippingService().calculate(carrier, weight, total)).isCloseTo(expected, within(0.001));
}

Bu testler yeni yapıya taşındığında BigDecimal ile yeniden yazılır; "UNKNOWN" satırı ise yeni davranışı (exception) bekleyen bir teste dönüşür.

Adım 2: Tipleri düzelt (Replace Primitive with Object, Introduce Parameter Object)

java
public enum Carrier {
    ARAS, YURTICI, MNG;

    public static Carrier from(String value) {
        if (value == null || value.isBlank()) {
            throw new UnsupportedCarrierException(value);
        }
        try {
            return valueOf(value.trim().toUpperCase(Locale.ROOT));
        } catch (IllegalArgumentException e) {
            throw new UnsupportedCarrierException(value);
        }
    }
}

public record ShipmentQuote(BigDecimal weightKg, BigDecimal cartTotal) {
    public ShipmentQuote {
        Objects.requireNonNull(weightKg, "weightKg");
        Objects.requireNonNull(cartTotal, "cartTotal");
        if (weightKg.signum() < 0 || cartTotal.signum() < 0) {
            throw new IllegalArgumentException("weight and total must be non-negative");
        }
    }
}

Neden Locale.ROOT: Türkçe locale'de "yurtici".toUpperCase() → "YURTİCİ" (noktalı büyük İ) olur ve valueOf başarısız olur. Sunucunun varsayılan locale'ine bağlı bir bug, sadece production'da çıkar. null kontrolü exception'ı yakalamak yerine açıkça yapıldı; NPE'yi akış kontrolü için yakalamak kötü bir alışkanlıktır.

Adım 3: Strategy: ortak sözleşme ve firma başına sınıf

java
public interface ShippingFeePolicy {
    Carrier carrier();
    /** Geçerli her teklif için negatif olmayan, 2 basamaklı ücret döner; null dönmez. */
    BigDecimal feeFor(ShipmentQuote quote);
}

@Component
class ArasFeePolicy implements ShippingFeePolicy {
    private static final BigDecimal HEAVY_THRESHOLD_KG = new BigDecimal("5");
    private static final BigDecimal HEAVY_FEE = new BigDecimal("89.90");
    private static final BigDecimal STANDARD_FEE = new BigDecimal("49.90");

    @Override public Carrier carrier() { return Carrier.ARAS; }

    @Override
    public BigDecimal feeFor(ShipmentQuote quote) {
        return quote.weightKg().compareTo(HEAVY_THRESHOLD_KG) > 0 ? HEAVY_FEE : STANDARD_FEE;
    }
}

@Component
class MngFeePolicy implements ShippingFeePolicy {
    private static final BigDecimal BASE_FEE = new BigDecimal("39.90");
    private static final BigDecimal PER_KG = new BigDecimal("4.50");

    @Override public Carrier carrier() { return Carrier.MNG; }

    @Override
    public BigDecimal feeFor(ShipmentQuote quote) {
        return BASE_FEE.add(PER_KG.multiply(quote.weightKg())).setScale(2, RoundingMode.HALF_UP);
    }
}
// YurticiFeePolicy aynı kalıpta: cartTotal >= 500 ise 0, değilse 54.90

Karşılaştırmada compareTo kullanılır, equals değil (scale farkı). Sözleşme Javadoc'ta yazılı: LSP'nin uygulamadaki karşılığı budur.

Adım 4: Seçim: List'ten Map'e, fail-fast

java
@Service
public class ShippingFeeCalculator {

    private final Map<Carrier, ShippingFeePolicy> policies;

    public ShippingFeeCalculator(List<ShippingFeePolicy> policyList) {
        Map<Carrier, ShippingFeePolicy> map = new EnumMap<>(Carrier.class);
        for (ShippingFeePolicy policy : policyList) {
            ShippingFeePolicy previous = map.putIfAbsent(policy.carrier(), policy);
            if (previous != null) {
                throw new IllegalStateException("Duplicate shipping policy for " + policy.carrier());
            }
        }
        this.policies = Collections.unmodifiableMap(map);
    }

    public BigDecimal calculate(Carrier carrier, ShipmentQuote quote) {
        ShippingFeePolicy policy = policies.get(carrier);
        if (policy == null) {
            throw new UnsupportedCarrierException(carrier.name());
        }
        return policy.feeFor(quote);
    }
}

Gerekçeler:

  • Spring koleksiyon enjeksiyonu: Aynı interface'i uygulayan tüm bean'ler List olarak gelir. Yeni firma = yeni @Component; hesaplayıcıya dokunulmaz.
  • Fail-fast: Aynı firma için iki politika tanımlanırsa uygulama açılırken patlar, production'da rastgele hangisinin seçildiğini merak etmezsin. Quiz çözümündeki Collectors.toUnmodifiableMap da duplicate anahtarda IllegalStateException fırlatır; EnumMap versiyonu hata mesajını kontrol etmeni sağlar.
  • Eksik politika: Enum'da olup politikası olmayan firma için iki seçenek var: açılışta EnumSet.allOf(Carrier.class) ile karşılaştırıp patlamak (tam kapsam garantisi) ya da çalışma anında 400 dönmek (enum değerini politikadan önce eklemeye izin verir). Seçimi söylemek yeterli.

Map.get parametresi Object'tir. Map<Carrier, ...> üzerinde policies.get("ARAS") (String) derlenir ama her zaman null döner. Firmayı String olarak taşıyan eski bir çağrı yeri kalırsa her istek "desteklenmeyen firma" hatası verir ve derleyici seni uyarmaz. Dönüşümü (Carrier.from) tek bir giriş noktasında yap ve iç katmanlarda sadece enum dolaştır.

Adım 5: Controller ve REST

java
@RestController
@RequestMapping("/api/v1/products")
public class ProductController {

    private final ProductRepository productRepository;
    private final ShippingFeeCalculator shippingFeeCalculator;

    public ProductController(ProductRepository productRepository, ShippingFeeCalculator shippingFeeCalculator) {
        this.productRepository = productRepository;
        this.shippingFeeCalculator = shippingFeeCalculator;
    }

    @GetMapping("/{productId}/shipping-fee")
    public ShippingFeeResponse shippingFee(@PathVariable long productId, @RequestParam String carrier) {
        Product product = productRepository.findById(productId)
                .orElseThrow(() -> new ProductNotFoundException(productId));
        Carrier parsed = Carrier.from(carrier);
        BigDecimal fee = shippingFeeCalculator.calculate(parsed,
                new ShipmentQuote(product.getWeightKg(), product.getPrice()));
        return new ShippingFeeResponse(productId, parsed, fee, "TRY");
    }
}

public record ShippingFeeResponse(long productId, Carrier carrier, BigDecimal fee, String currency) {}

Gerekçeler: kaynak odaklı URL (/products/{id}/shipping-fee), fiil yok, çoğul isim, sürüm; constructor injection (final alanlar, test edilebilirlik, eksik bağımlılıkta açılışta hata); ilkel yerine response DTO (para birimi gibi alanlar eklenebilir, sözleşme genişletilebilir). Alternatif olarak @RequestParam Carrier carrier yazılabilir; Spring'in String→enum dönüşümü geçersiz değerde MethodArgumentTypeMismatchException ile 400 döner, ama büyük-küçük harfe duyarlıdır. Mantık büyürse controller'dan bir ShippingQuoteService'e taşınır (controller sadece HTTP'yi bilir, bkz. Bölüm 18).

Adım 6: Hata yönetimi

java
@RestControllerAdvice
class GlobalExceptionHandler {

    @ExceptionHandler(ProductNotFoundException.class)
    ProblemDetail notFound(ProductNotFoundException e) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
    }

    @ExceptionHandler(UnsupportedCarrierException.class)
    ProblemDetail unsupportedCarrier(UnsupportedCarrierException e) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, e.getMessage());
    }
}

Domain exception'ları HTTP'yi bilmez; çeviri tek bir yerde olur ve API tüketicisi tek bir hata formatı (RFC 9457 ProblemDetail) görür.

Adım 7: Konfigürasyon (zaman kalırsa)

Ücretleri koddan çıkarmak takip sorusunun ("deploy etmeden ücret değiştirmek") ilk adımıdır:

yaml
shipping:
  aras:
    heavy-threshold-kg: 5
    heavy-fee: 89.90
    standard-fee: 49.90
java
@ConfigurationProperties(prefix = "shipping")
public record ShippingProperties(Aras aras) {
    public record Aras(BigDecimal heavyThresholdKg, BigDecimal heavyFee, BigDecimal standardFee) {}
}

@ConfigurationPropertiesScan ya da @EnableConfigurationProperties ile kaydedilir. Gerçekten deploy'suz değişiklik isteniyorsa ücretler bir tabloya ve önbelleğe taşınır; bankacılık ve e-ticarette fiyat değişikliklerinin kim tarafından ne zaman yapıldığı denetim kaydına yazılır.

Adım 8: Testler

java
class ShippingFeeCalculatorTest {

    private final ShippingFeeCalculator calculator = new ShippingFeeCalculator(
            List.of(new ArasFeePolicy(), new YurticiFeePolicy(), new MngFeePolicy()));

    @ParameterizedTest(name = "{0} {1}kg {2}TL → {3}")
    @CsvSource({
            "ARAS,    5.00,  10,     49.90",   // eşiğin kendisi: standart
            "ARAS,    5.01,  10,     89.90",   // eşiğin üstü: ağır
            "YURTICI, 1,     500,     0",      // tam eşik: bedava
            "YURTICI, 1,     499.99, 54.90",
            "MNG,     2.5,   10,     51.15"    // 39.90 + 4.50 x 2.5 = 51.15
    })
    void calculatesFeePerCarrier(Carrier carrier, BigDecimal weight, BigDecimal total, BigDecimal expected) {
        assertThat(calculator.calculate(carrier, new ShipmentQuote(weight, total))).isEqualByComparingTo(expected);
    }

    @Test
    void rejectsCarrierWithoutPolicy() {
        var partial = new ShippingFeeCalculator(List.of(new ArasFeePolicy()));
        assertThatThrownBy(() -> partial.calculate(Carrier.MNG, new ShipmentQuote(BigDecimal.ONE, BigDecimal.TEN)))
                .isInstanceOf(UnsupportedCarrierException.class);
    }

    @Test
    void failsFastOnDuplicatePolicies() {
        assertThatThrownBy(() -> new ShippingFeeCalculator(List.of(new ArasFeePolicy(), new ArasFeePolicy())))
                .isInstanceOf(IllegalStateException.class);
    }

    @ParameterizedTest
    @ValueSource(strings = {"aras", " Aras ", "ARAS"})
    void parsesCarrierCaseInsensitively(String raw) {
        assertThat(Carrier.from(raw)).isEqualTo(Carrier.ARAS);
    }
}

Testler gerçek politikalarla çalışır (sociable), mock yoktur; politikalar saf hesaplamadır. Sınır değerler (5.00/5.01, 499.99/500) mutation testing'in >/>= mutant'larını öldürür.

Takip soruları ve cevapları

  • "Yeni firma (SURAT) ekleyelim; hangi dosyalara dokunuyorsun?" Enum'a bir değer ve bir @Component politika sınıfı (artı testi). Hesaplayıcı ve controller değişmez. Enum'a dokunmak bile istenmiyorsa firma kodu String olarak kalır ama sadece politikaların bildirdiği kümeyle doğrulanır.
  • "Ücretleri deploy etmeden değiştirmek?" Konfigürasyon (config server, ConfigMap ile yeniden başlatma) ya da DB tablosu + önbellek + denetim kaydı. Kural yapısı (ağırlık eşiği mi, kg başı mı) değişiyorsa bu artık konfigürasyon değil kod değişikliğidir.
  • "Belirli tarihte tüm firmalarda %50 kampanya?" Decorator: DiscountedFeePolicy mevcut politikaları sarar; aktiflik Clock ile kontrol edilir ve kampanya bitince sarmalama ortadan kalkar. Mevcut sınıflara dokunulmaz.
  • "Saniyede 10.000 istek?" Hesaplama ucuz; maliyet ürünü DB'den okumak. Ürünün ağırlık ve fiyatını (ya da productId + carrier sonucunu) kısa TTL'li yerel bir önbellekte (Caffeine) tutmak; fiyat değişince invalidation ya da kısa TTL ile kabul edilebilir bayatlık.

Quiz çözümüne not. k1 çözümü sağlam. İki küçük iyileştirme yukarıda uygulandı: Carrier.from içinde NPE yakalamak yerine açık null kontrolü ve sınır değerleri daha sistematik test eden parameterized test.

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

Senaryo. Iyzico ve PayTR entegre; her birinin kendi controller'ı ve servisi var ve kod büyük ölçüde kopyala-yapıştır. Papara eklenecek. İşlemler: authorize, refund, redirect-url. Görevler: tek controller (/api/v1/payments/{provider}/...), ortak HTTP yapısı, BusinessException ve GlobalExceptionHandler, YAML konfigürasyonu, unit test. Süre 45 dakika.

Başlangıç kodunun özü:

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");
        }
    }
}

@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");
        ResponseEntity<PaymentResponse> resp = restTemplate.postForEntity(
                "https://api.iyzico.example/v2/auth", new HttpEntity<>(req, headers), PaymentResponse.class);
        if (resp.getStatusCode() != HttpStatus.OK) {
            throw new RuntimeException("iyzico error");
        }
        return resp.getBody();
    }
}

Adım 0: Sorun envanteri ve plan (4-5 dk)

Önem sırasıyla:

  1. Koda gömülü canlı API key (sk_live_...): Güvenlik açığı. Git geçmişinde kalır; koddan silmek yetmez, key rotate edilmeli. Bunu ilk söylemek güvenlik farkındalığı gösterir.
  2. catch (Exception e) → 500 "error": Hata yutuluyor (log yok), istemciye anlamsız bir string dönüyor, her hata 500. Validasyon hatası da sağlayıcı arızası da aynı görünüyor.
  3. new RestTemplate(): Timeout yok. Sağlayıcı yavaşlarsa Tomcat thread'leri sonsuza kadar bekler ve tüm servis kilitlenir (bkz. Bölüm 14). Ayrıca Spring'in builder'ından gelmediği için metrik ve tracing enstrümantasyonu da yok.
  4. Ölü kontrol: RestTemplate varsayılan hata işleyicisi 4xx/5xx'te zaten exception fırlatır; != HttpStatus.OK kontrolüne sadece 2xx cevaplar ulaşır ve 201/204 gibi başarılı cevaplar yanlışlıkla hata sayılır. Başarı kontrolü gerekiyorsa is2xxSuccessful() kullanılır.
  5. Kopyala-yapıştır: İki controller, iki servis, her metotta aynı header/POST/kontrol kodu. Üçüncü sağlayıcı bunu üç katına çıkaracak.
  6. REST ve isimlendirme: /iyzico/authorize sürümsüz ve sağlayıcıyı kaynak gibi kullanıyor; @Valid yok; ResponseEntity<?> sözleşmesiz.
  7. Field injection.

Plan cümlesi: "Önce ortak sözleşmeyi ve konfigürasyonu kuracağım, sonra tekrar eden HTTP kodunu ortak bir client'a alıp mevcut iki sağlayıcıyı ona taşıyacağım, ardından tek controller ve hata yönetimi, en son Papara ve testler. Spring sürümü nedir?" Son soru RestClient (Spring Boot 3.2+) kullanıp kullanamayacağını belirler.

Adım 1: Ortak sözleşme (Strategy)

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

Sağlayıcılar çalışma anında path'ten seçiliyor ve hepsi aynı sözleşmeye sahip: Strategy (z7). Sağlayıcılardan biri redirectUrl desteklemeseydi ISP gereği bu işlemi ayrı bir arayüze (RedirectCapable) almak gerekirdi.

Adım 2: Konfigürasyon ve secret'lar

yaml
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 }
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 provider = providers.get(name);
        if (provider == null) {
            throw new IllegalStateException("Missing payment configuration for " + name);
        }
        return provider;
    }
}

YAML'da sadece placeholder durur; gerçek değer ortam değişkeninden gelir, o da Kubernetes'te bir secret yöneticisinden (bkz. Bölüm 15). Spring Boot relaxed binding ile base-url'i baseUrl'e eşler, tip dönüşümüyle de Duration (3s) ve URI alanlarını doğrudan bağlar. Eksik konfigürasyonda quiz çözümü UnsupportedProviderException (404) fırlatıyor; bu bir istemci hatası değil, bir konfigürasyon hatasıdır ve uygulama açılırken patlamalıdır, bu yüzden IllegalStateException.

Adım 3: Tekrar eden HTTP kodunu ortak client'a almak (kompozisyon)

java
public final class ProviderClient {

    private final String provider;
    private final RestClient restClient;

    ProviderClient(String provider, RestClient restClient) {     // testte MockRestServiceServer'lı client verilir
        this.provider = provider;
        this.restClient = restClient;
    }

    public static ProviderClient create(String provider, PaymentProperties.Provider props,
                                        RestClient.Builder builder) {
        var factory = new SimpleClientHttpRequestFactory();
        factory.setConnectTimeout((int) props.timeout().toMillis());
        factory.setReadTimeout((int) props.timeout().toMillis());
        RestClient client = builder.clone()                      // paylaşılan builder'ı kirletmemek için
                .baseUrl(props.baseUrl().toString())
                .requestFactory(factory)
                .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + props.apiKey())
                .build();
        return new ProviderClient(provider, client);
    }

    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 (HttpClientErrorException e) {        // 4xx: sağlayıcı isteği reddetti
            throw new ProviderRejectedException(provider, e.getStatusCode().value(), e.getResponseBodyAsString());
        } catch (HttpServerErrorException e) {        // 5xx: sağlayıcı arızalı
            throw new ProviderException(provider, e.getStatusCode().value());
        } catch (ResourceAccessException e) {         // timeout, bağlantı hatası: sonuç BİLİNMİYOR
            throw new ProviderUnavailableException(provider, e);
        }
    }
}

Neden kalıtım (AbstractPaymentGateway) değil kompozisyon: gateway'ler HTTP mekaniğini kullanır ama HTTP mekaniği "olmaz"; ortak client tek başına test edilir, bir sağlayıcı farklı bir protokol (ör. SOAP ya da imzalı istek) gerektirirse sadece o gateway farklı bir client kullanır. Kalıtımla kurulan yapı her farklılıkta üst sınıfa bir hook daha ekletir. Quiz de üst sınıfı kabul edilebilir ama daha az esnek olarak not ediyor.

Hata çevirisinde üç durumu ayırmak senior bir detaydır:

  • 4xx: Sağlayıcı isteği reddetti. "Kart reddedildi, bakiye yetersiz" gibi iş sonuçları istemciye anlamlı bir hata koduyla (ör. 402 ya da 422 ve errorCode) dönmelidir; "bizim gönderdiğimiz istek hatalı" ise bizim entegrasyon hatamızdır, 502 ve alarm gerektirir. Hangisi olduğunu sağlayıcının hata kodu söyler; bu eşleme her gateway'in mapper'ındadır.
  • 5xx: Sağlayıcı arızalı: 502.
  • Timeout: Sonuç bilinmiyor. Sağlayıcı parayı çekmiş olabilir. Bu "başarısız" sayılıp kullanıcıya "tekrar dene" denirse çift çekim olur. Doğrusu idempotency key ile istek göndermek ve sonucu durum sorgusu ya da mutabakat (reconciliation) ile netleştirmektir: 504 ya da "işlem kontrol ediliyor" durumu.

Adım 4: Gateway'ler, Papara ve Adapter

java
@Component
class PaparaGateway implements PaymentGateway {

    private static final String PROVIDER = "papara";
    private final ProviderClient client;

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

    @Override public String provider() { return PROVIDER; }

    @Override
    public PaymentResponse authorize(PaymentRequest r) {
        return PaparaMapper.toResponse(client.post("/payments", PaparaMapper.toAuth(r), PaparaPaymentResult.class));
    }

    @Override
    public PaymentResponse refund(RefundRequest r) {
        return PaparaMapper.toResponse(client.post("/refunds", PaparaMapper.toRefund(r), PaparaPaymentResult.class));
    }

    @Override
    public RedirectUrlResponse redirectUrl(PaymentRequest r) {
        return PaparaMapper.toRedirect(client.post("/checkout-sessions", PaparaMapper.toSession(r), PaparaSession.class));
    }
}

PaparaMapper Adapter'dır: domain'in PaymentRequest'ini Papara'nın formatına, Papara'nın cevabını domain'in PaymentResponse'una çevirir. Sağlayıcıya özgü alanlar domain'e sızmaz. Quiz çözümü cevabı doğrudan domain tipine (PaymentResponse.class) deserialize ediyor; bu, her sağlayıcının aynı JSON'u döndüğünü varsayar ve gerçekte tutmaz. Iyzico ve PayTR gateway'leri aynı kalıpta, kendi mapper'larıyla yazılır. Sabit PROVIDER alanı, quiz çözümündeki "constructor içinde override edilebilir provider() metodunu çağırmak" küçük kokusunu da giderir.

Papara eklemenin maliyeti artık: bir gateway sınıfı, bir mapper ve bir YAML satırı. Controller, registry, client ve hata yönetimi değişmez. Görevdeki "tekrar eden kodu artırma" şartının kanıtı budur.

Adım 5: Registry ve tek controller

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;
    }
}

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

Controller'da try/catch yok: hata yönetimi merkezi. @Valid ile istek doğrulaması HTTP sınırında yapılır. Eski URL'ler (/iyzico/authorize) istemciler varsa bir süre yeni controller'a yönlenen deprecated alias'lar olarak tutulur; kırıcı değişiklik tek seferde yapılmaz.

Adım 6: Exception hiyerarşisi ve GlobalExceptionHandler

java
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 provider) {
        super("PAYMENT_PROVIDER_NOT_SUPPORTED", HttpStatus.NOT_FOUND, "Unsupported provider: " + provider);
    }
}

public class ProviderException extends BusinessException {
    public ProviderException(String provider, int upstreamStatus) {
        super("PAYMENT_PROVIDER_ERROR", HttpStatus.BAD_GATEWAY, provider + " returned " + upstreamStatus);
    }
}

@RestControllerAdvice
class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    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, istemciye değil
        return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "Unexpected error");
    }
}

Quiz çözümündeki hata: extends ResponseEntityExceptionHandler eksik. Quiz'deki handler @ExceptionHandler(Exception.class) tanımlıyor ama ResponseEntityExceptionHandler'dan türemiyor. Spring MVC'de @ExceptionHandler metotlarını işleten ExceptionHandlerExceptionResolver, Spring'in kendi exception'larını varsayılan status'lara çeviren DefaultHandlerExceptionResolver'dan önce çalışır. Sonuç: @Valid hatası (MethodArgumentNotValidException), bozuk JSON (HttpMessageNotReadableException), eksik parametre, desteklenmeyen HTTP metodu ve Spring 6.1+'da bilinmeyen URL (NoResourceFoundException) hepsi catch-all tarafından yakalanır ve 500 döner. Yani çözüm, görevin "anlamlı HTTP kodları" maddesini tam olarak bozuyor. ResponseEntityExceptionHandler bu exception'ların her biri için daha spesifik @ExceptionHandler metotları tanımlar; Spring en yakın eşleşmeyi seçtiği için bunlar Exception.class'tan önce gelir ve RFC 9457 formatında doğru status'larla (400, 405, 404...) cevap verir. Alternatif, bu exception'lar için kendi handler'larını tek tek yazmaktır; ama base sınıfı genişletmek hem kısa hem eksiksizdir.

Kurallar: domain exception'lar unchecked (@Transactional rollback'i de varsayılan olarak unchecked'te yapar), her biri bir errorCode taşır (istemci mesajı parse etmez, kodu kullanır), stack trace istemciye gitmez, beklenmeyen hata bir kez ve handler'da loglanır.

Adım 7: Testler

Registry için saf unit test:

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);
    }
}

Ortak client için HTTP seviyesinde test; hata çevirisi en kritik davranıştır:

java
class ProviderClientTest {

    @Test
    void translatesServerErrorToProviderException() {
        RestClient.Builder builder = RestClient.builder().baseUrl("https://api.papara.example");
        MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
        var client = new ProviderClient("papara", builder.build());

        server.expect(requestTo("https://api.papara.example/payments"))
              .andExpect(method(HttpMethod.POST))
              .andRespond(withServerError());

        assertThatThrownBy(() -> client.post("/payments", Map.of("amount", "100.00"), Object.class))
                .isInstanceOf(ProviderException.class);
        server.verify();
    }
}

Quiz çözümüne bir test edilebilirlik notu. Quiz'deki ProviderClient constructor'ı request factory'yi kendi içinde kuruyor (.requestFactory(factory)). MockRestServiceServer.bindTo(builder) builder'a sahte bir request factory koyarak çalışır; client içeride kendi factory'sini ayarladığında sahte factory ezilir ve test gerçek ağa gitmeye çalışır. Yukarıdaki yapı (hazır RestClient alan constructor + ayrı create factory metodu) bu yüzden seçildi. Timeout davranışının kendisi ise ancak gerçek bir HTTP sunucusuyla (WireMock ile gecikmeli cevap) güvenilir şekilde test edilir.

Takip soruları ve cevapları

  • "Authorize timeout aldı ama sağlayıcı parayı çekmişse?" Her authorize'a bizim ürettiğimiz bir idempotency key (sipariş/ödeme denemesi id'si) eklenir; sağlayıcı aynı key'le gelen tekrarı yeni işlem saymaz. Timeout "bilinmiyor" durumudur: ödeme kaydı PENDING kalır, durum sorgusu ya da periyodik mutabakat ile netleşir. Kullanıcıya "tekrar dene" denmeden önce bu kontrol yapılır.
  • "Bir sağlayıcı 10 dakika çöktü." Sağlayıcı başına circuit breaker (Resilience4j); açıkken hızlı hata ya da kullanıcıya alternatif sağlayıcı önerisi. Otomatik başka sağlayıcıya yönlendirme ancak ilk denemenin kesin başarısız olduğu durumda güvenlidir; timeout sonrası otomatik geçiş çift çekime yol açabilir (bkz. Bölüm 14).
  • "Refund neden senkron olmayabilir?" Kullanıcı refund sonucunu anlık beklemez; sağlayıcı geçici olarak cevap vermeyebilir. Refund talebi DB'ye ve outbox'a yazılır, asenkron bir işleyici retry ve DLQ ile gönderir (bkz. Bölüm 13).
  • "Production'da bu refactor'ı nasıl çıkarırsın?" Adım adım ve her adım ayrı PR: (1) secret'ları konfigürasyona taşı ve key'leri rotate et, (2) PaymentGateway arayüzünü ekle ve mevcut servisleri adapter olarak uygulat, (3) ortak client'a taşı (davranış aynı, testlerle doğrula), (4) yeni controller'ı ekle, eski URL'leri alias olarak tut, (5) Papara'yı feature flag arkasında aç, (6) eski controller'ları kaldır.

Eski Spring sürümü verilirse: RestClient Spring Boot 3.2+ (Spring Framework 6.1) ile geldi; daha eskide RestTemplateBuilder ile timeout'lu RestTemplate kurulur (setConnectTimeout/setReadTimeout). ProblemDetail Spring Framework 6 ile geldi; eski sürümde kendi ErrorResponse record'unu dönersin. Kütüphane eklemeye (Lombok, Feign) çalışıp build ile vakit kaybetmek yerine elle yaz ve bunu söyle.

q19: Her yeni kargo firmasında bu metoda dokunuyoruz

Senaryo. ShippingService.shippingFee(Order) içinde carrier'a göre switch: ARAS (ağırlık eşiği), YURTICI (tutar eşiği), TEX (calculateTexFee), default'ta exception. Her yeni firmada metot değişiyor, TEX mantığı büyüyor, PR'larda çakışma çıkıyor. Hangi ilke ihlal ediliyor, hangi pattern'le ve Spring'de nasıl refactor edersin?

Cevap.

  • İhlal: Birincil olarak Open/Closed: yeni davranış (firma) eklemek için test edilmiş kodu değiştirmek gerekiyor. İkincil olarak SRP: tek metot bütün firmaların kurallarını biliyor ve her firmanın iş biriminin talebiyle değişiyor; PR çakışmalarının nedeni de bu. Ayrıca magic value'lar ve String carrier (primitive obsession).
  • Pattern: Strategy; refactoring kataloğundaki adı Replace Conditional with Polymorphism.
  • Spring'de: ShippingFeePolicy interface'i, firma başına @Component, constructor'da List<ShippingFeePolicy>'yi Carrier anahtarlı bir Map'e çeviren hesaplayıcı, duplicate'te fail-fast, bilinmeyen firmada domain exception, ücretler ve eşikler konfigürasyonda, her politika tek başına test edilir. Kodun tamamı k1'de.

Quiz'deki model cevaba düzeltme. Model cevap Carrier enum'unu tanıtıyor ve policies.get(order.carrier()) çağırıyor; ama sorudaki Order.carrier() bir String döndürüyor (case "ARAS":). Map<Carrier, ShippingFeePolicy>.get(...) parametresi Object olduğu için bu kod derlenir ama her zaman null döner; refactor sonrası her sipariş UnsupportedCarrierException alır. Doğrusu ya Order'ın carrier alanını da Carrier tipine taşımak (tercih edilen, primitive obsession'ı kökten çözer) ya da giriş noktasında Carrier.from(order.carrier()) ile dönüştürmektir. Bu tuzağı fark etmek, refactor'ın tamamlandığını testle doğrulamanın neden şart olduğunu da gösterir: k1'deki characterization testleri bu hatayı ilk çalıştırmada yakalar.

TEX'in büyüyen mantığı ayrı bir sınıfa taşındığında kendi testleri ve kendi değişim hızıyla yaşar; diğer firmaların kodundan ve PR'larından bağımsızlaşır. Kesişen kurallar (kampanya) gerekirse Decorator ile eklenir, ama ihtiyaç doğmadan değil.

z7: Ödeme sağlayıcısı çalışma anında seçilecek; hangi pattern?

Doğru: a) Strategy (+ Spring'in List/Map enjeksiyonuyla bir registry). Iyzico, PayTR ve Papara aynı sözleşmeyi (authorize/refund) farklı algoritmalarla uyguluyor ve hangisinin kullanılacağı çalışma anında istek parametresine göre seçiliyor. Strategy'nin tanımı tam olarak budur: değiştirilebilir algoritmalar, ortak arayüz, istemci somut tipi bilmez.

Yanlış seçenekler:

  • b) Singleton: Örnek sayısını kısıtlar, seçim problemini çözmez; Spring bean'leri zaten varsayılan olarak singleton scope'tadır.
  • c) Observer: Bir olaya birden çok bağımsız dinleyicinin tepki vermesi içindir; burada tek bir sağlayıcı seçiliyor.
  • d) Builder: Karmaşık nesne kurulumu içindir.

Gerçek çözümde birden çok pattern bir arada bulunur ve bunu söylemek artı puandır: seçim Strategy'dir, sağlayıcıya özgü istek/cevap dönüşümü Adapter'dır, gateway'i seçen registry'ye bazen Factory denir, retry/circuit breaker sarmalayıcıları Decorator'dür.

z8: Her yeni kargo firması için yeni else if: hangi ilke?

Doğru: b) Open/Closed. Yeni davranış eklemek için mevcut, test edilmiş kod değiştiriliyor. Neden önemli olduğunu bir cümleyle söylemek gerekir: her ekleme regresyon riski, yeniden test ve aynı dosyada merge conflict demektir.

Yanlış seçenekler:

  • a) Liskov: Alt tiplerin üst tipin yerine sözleşmeyi bozmadan kullanılabilmesiyle ilgilidir; burada henüz bir tip hiyerarşisi bile yok.
  • c) Interface Segregation: Şişman arayüzlerle ilgilidir.
  • d) Dependency Inversion: Üst seviyenin somut detaya bağlanmasıyla ilgilidir; kısmen ilişkili (hesaplama bir soyutlamaya değil koşullara bağlı), ama doğrudan ihlal OCP'dir.

Aynı metot birden fazla firmanın kuralını bildiği için SRP de zayıftır; bunu ek olarak söylemek cevabı güçlendirir.

Üç seviyede cevap

Soru: "SOLID'i anlatır mısın? Projende en çok hangisinin ihlal edildiğini gördün?"

S tek sorumluluk, O açık/kapalı, L Liskov, I interface ayrımı, D bağımlılıkların tersine çevrilmesi. Spring'de dependency injection kullandığımız için D'yi zaten uyguluyoruz. En çok SRP ihlal ediliyor, çünkü servisler çok uzun oluyor.

SRP bir sınıfın değişmek için tek nedeni olması; OCP yeni davranışı mevcut kodu değiştirmeden eklemek, genelde Strategy ile; LSP alt tipin üst tipin sözleşmesini bozmaması; ISP istemcinin kullanmadığı metotlara bağımlı olmaması; DIP üst katmanın soyutlamaya bağımlı olması. En çok OCP ihlali görüyorum: tip bazlı switch'ler, her yeni ödeme tipinde bir case daha. Bunu Strategy ve Spring'in List enjeksiyonuyla çözüyorum.

Hepsinin ortak amacı değişimin maliyetini düşürmek; bu yüzden her birini "hangi değişimi kolaylaştırır" diye düşünürüm. SRP'de "neden" insanlar: fiyat kuralını pazarlama, fatura formatını finans değiştiriyorsa ikisi aynı sınıfta olmamalı. DI ile DIP'i ayırırım: Spring somut IyzicoClient'ı da enjekte eder, bu DIP değildir; DIP, domain'in port'u tanımlayıp altyapının onu uygulamasıdır.

En pahalı ihlal olarak LSP ve ISP'nin birlikte göründüğü durumu gördüm: her sağlayıcının uygulamak zorunda olduğu şişman bir ödeme arayüzü ve desteklenmeyen metotlarda UnsupportedOperationException. Çağıran kod "bu sağlayıcı taksit destekliyor mu" diye instanceof yapmaya başlamıştı; yeni sağlayıcı her eklendiğinde bu kontrollerin bir yerde eksik kalması production hatası üretiyordu. Rol arayüzlerine böldük ve desteklenen yetenekleri tip sistemine taşıdık. Ama ilkeleri körü körüne de uygulamam: tek implementasyonu olan, değişim ekseni kanıtlanmamış bir yer için interface eklemem; yanlış soyutlama tekrardan pahalıdır.

Junior cevap kısaltmaları açıyor ama yaygın bir yanılgıyı (DI = DIP) taşıyor ve "servisler uzun" gibi boyuta dayalı, mekanizmasız bir gözlem yapıyor. Mid cevap tanımları doğru veriyor ve somut bir ihlal ile çözümünü biliyor. Senior cevap ilkeleri ortak bir amaca bağlıyor, SRP'nin gerçek anlamını (değişim nedeni = aktör) ve DI/DIP farkını netleştiriyor, deneyimden gelen ve maliyeti anlatılan bir örnek veriyor ve ilkelerin ne zaman uygulanmayacağını söylüyor.

Takip sorusu: Liskov ihlalini derleyici yakalamaz. Production'a çıkmadan nasıl yakalarsın?

Soru: "Strategy ile Factory farkı nedir? Spring'de Strategy'yi nasıl kurarsın?"

Strategy farklı algoritmaları seçmek için, Factory nesne oluşturmak için. Spring'de bir factory sınıfı yazıp içinde switch ile doğru servisi döndürürüm.

Strategy davranış sağlar, Factory nesne üretir. Spring'de tüm strategy implementasyonlarını List olarak enjekte edip constructor'da Map'e çeviririm; her implementasyon hangi tipi desteklediğini bir metotla söyler. Böylece yeni implementasyon eklemek için factory'ye dokunmam gerekmez. Bilinmeyen tip için exception fırlatırım.

Strategy "nasıl yapılacağı"nı değiştirilebilir kılar; Factory "neyin oluşturulacağı"na karar verir. Spring'de strategy'ler zaten bean olduğu için çoğu zaman oluşturma yok, seçim var; o seçici bileşene registry demeyi tercih ederim. İçinde switch olan bir factory OCP sorununu sadece başka bir sınıfa taşır.

Kurulum: interface'e carrier() gibi bir anahtar metodu, List<Policy> enjeksiyonu, açılışta EnumMap'e çevirip duplicate'te fail-fast, isteğe bağlı olarak enum'un tamamının kapsandığını açılışta doğrulamak. Map<String, Bean> enjeksiyonu da var ama seçimi bean adına bağladığı için kırılgan buluyorum. Sıra önemliyse (k4'teki indirim kuralları) @Order ya da açık bir liste kullanırım, çünkü List enjeksiyonunun sırası aksi halde garanti değildir. Test tarafında her strategy saf unit test, registry için duplicate ve bilinmeyen anahtar testi yazarım. Strategy'yi ne zaman kullanmam: iki dal varsa ve yenisi beklenmiyorsa; o zaman bir if ya da davranışı enum sabitine gömmek daha okunur.

Junior cevap tanımı biliyor ama önerdiği çözüm (switch'li factory) sorunu taşıyor, çözmüyor. Mid cevap Spring'deki idiomatik kurulumu ve OCP faydasını biliyor. Senior cevap kavramsal farkı Spring bağlamında netleştiriyor (seçim ve oluşturma), kurulumu fail-fast ve kapsam kontrolüyle sağlamlaştırıyor, Map<String, Bean> ve sıralama gibi inceliklere değiniyor ve pattern'i ne zaman kullanmayacağını söylüyor.

Takip sorusu: Strategy'lerden birinin başka bir strategy'yi kullanması gerekiyor (kampanya ücreti, temel ücrete bağlı). Bunu nasıl modellersin?

Soru: "Ne zaman design pattern kullanmazsın? Over-engineering'e bir örnek verir misin?"

Pattern'ler kodu daha temiz ve genişletilebilir yaptığı için mümkün olduğunca kullanmaya çalışırım. Over-engineering çok fazla sınıf yazmaktır.

Basit bir problem için pattern kullanmam. Örneğin tek bir implementasyonu olan servis için interface yazmam; ikinci implementasyon gelince çıkarırım. YAGNI'ye uyarım. Ayrıca kodu okuyan kişinin pattern'i bilmesi gerekir, gereksiz soyutlama okumayı zorlaştırır.

Pattern'i değişim ekseni kanıtlandığında kullanırım; kanıt genelde ikinci ya da üçüncü tekrardır. Kanıtsız soyutlama yanlış ekseni seçer ve yanlış soyutlama tekrar etmekten pahalıdır: her yeni ihtiyaçta ortak sınıfa bir boolean parametre ve bir if eklenir, sonunda kimse ona dokunamaz.

Gördüğüm bir örnek: iki rapor tipi için "konfigüre edilebilir rapor motoru" yazılmıştı; abstract üst sınıf, factory, builder ve YAML'dan okunan adım tanımları. Üçüncü rapor bambaşka bir veri kaynağı gerektirdiğinde motorun her katmanına istisna eklendi. Motoru inline edip üç düz sınıfa döndük; kod kısaldı ve her rapor bağımsız değişmeye başladı. Bu yüzden soyutlamayı geri açmayı da meşru bir refactoring sayarım. Live coding'de de aynı ilke: 35 dakikada sadece görevin istediği ekseni soyutlarım (k1'de firma), geri kalanını "ihtiyaç olursa şöyle genişletirim" diye sözlü anlatırım.

Junior cevap pattern'i amaç sanıyor; tam olarak over-engineering'e götüren tutum. Mid cevap doğru sezgiye ve basit bir kurala sahip. Senior cevap kararı bir kanıt eşiğine bağlıyor (değişim ekseni), yanlış soyutlamanın maliyetini mekanizmasıyla anlatıyor, somut bir deneyim ve geri dönüş hikâyesi veriyor ve ilkeyi live coding'in zaman kısıtına uyguluyor.

Takip sorusu: Bir interface'in tek implementasyonu var ama ekip "test için mock'lamak lazım" diyor. Bu geçerli bir gerekçe mi?

Soru: "Testi olmayan legacy bir sınıfı nasıl güvenle refactor edersin?"

Önce kodu okuyup anlarım, sonra daha temiz bir versiyonunu yazarım ve eskisini silerim. Sonra test yazarım.

Önce mevcut davranışı sabitleyen testler yazarım, sonra küçük adımlarla refactor ederim: Extract Method, Rename, Strategy'ye geçiş. Her adımdan sonra testleri çalıştırırım. Davranış değişikliği gerekiyorsa refactor'dan ayrı yaparım.

Önce characterization testleri: doğru davranışı değil, mevcut davranışı sabitlerim; bug gibi görünen şeyleri de belgeler ve iş tarafına sorarım. Test yazılamayacak kadar bağımlıysa (static çağrılar, new ile oluşturulan client'lar, now()), önce küçük ve güvenli dikiş noktaları (seam) açarım: bağımlılığı constructor parametresine çıkarmak, Clock enjekte etmek. Bunlar IDE'nin otomatik refactoring'leriyle yapılır, risk düşüktür.

Sonra küçük, her biri derlenen ve testten geçen adımlar; refactor commit'leri ile davranış değişikliği commit'leri ayrı. Production'a büyük patlama ile değil, parallel change ile çıkarırım: yeni yol eskisinin yanında kurulur, kritik akışlarda bir süre ikisi birlikte çalışıp sonuçları karşılaştırılır, metriklerle doğrulanır, sonra eski yol silinir. Kapsamı da ölçerim: refactor'u değişmesi gereken yerle sınırlarım; dokunmayacağım legacy kodu "temizlemek" için risk almam.

Junior cevap güvenlik ağı olmadan yeniden yazmayı öneriyor; davranış farklarını fark etmenin yolu kalmıyor. Mid cevap doğru sırayı (önce test, küçük adımlar, refactor ile davranış değişikliğini ayırmak) biliyor. Senior cevap test yazılamayan kod için dikiş noktalarını, production'a çıkışta parallel change ve karşılaştırmalı doğrulamayı ve refactor'ın kapsamını sınırlamayı ekliyor; risk yönetimini teknik adımların önüne koyuyor.

Takip sorusu: Characterization testi yazarken mevcut davranışın bir bug olduğunu fark ettin. Testte neyi beklersin ve ne zaman düzeltirsin?

Sorulmadı ama sorulabilir

Composition over inheritance

Kalıtım "is-a" ilişkisidir ve alt sınıfı üst sınıfın iç detaylarına bağlar (kırılgan üst sınıf problemi): üst sınıftaki bir değişiklik, override eden alt sınıfları sessizce bozabilir. Kompozisyon davranışı bir alana konan işbirlikçiden alır; çalışma anında değiştirilebilir, tek başına test edilir ve Java'nın tek kalıtım kısıtına takılmaz. k2'deki ortak HTTP client bunun örneğidir. Kalıtım, gerçek bir tip hiyerarşisi ve kararlı bir sözleşme varsa (Template Method ile framework kancaları) hâlâ uygundur (bkz. Bölüm 1).

Tek cümle: "Kod paylaşmak için kalıtım yerine kompozisyon kullanırım; kalıtımı gerçek bir is-a ilişkisi ve kararlı bir sözleşme için saklarım."

Hexagonal mimari (ports and adapters)

Domain merkezdedir ve dış dünyayla sadece kendi tanımladığı port'lar (interface'ler) üzerinden konuşur: PaymentGateway, OrderRepository, EventPublisher. Adapter'lar bu port'ları uygular: REST controller (gelen), JPA repository, Kafka publisher, Iyzico client (giden). Faydası: domain framework'ten bağımsız test edilir, altyapı değiştirilebilir. Maliyeti: daha fazla tip ve mapping. Küçük bir CRUD servisinde gereksizdir; iş kuralı yoğun bir ödeme ya da fiyatlama servisinde değerlidir.

Tek cümle: "Hexagonal mimari DIP'in mimari ölçekteki uygulamasıdır; iş kuralı yoğun servislerde kullanırım, ince CRUD servislerinde kullanmam."

DRY ve yanlış soyutlama

DRY "aynı bilginin tek bir yetkili temsili olmalı" der; aynı görünen her kodu birleştir demez. İki kod parçası bugün aynıysa ama farklı nedenlerle değişecekse (Iyzico ve PayTR'nin istek mapping'i), birleştirmek onları birbirine bağlar ve ilk farklılıkta parametre ve if'lerle dolu bir yapı doğar. Gerçekten aynı bilgiyi temsil eden tekrar ise (HTTP header kurulumu, hata çevirisi) birleştirilir.

Tek cümle: "Aynı görünen değil, aynı nedenle değişen kodu birleştiririm; yanlış soyutlama tekrar etmekten pahalıdır."

Law of Demeter ve tell-don't-ask

order.getCustomer().getAddress().getCity() zinciri, çağıranı nesne grafiğinin bütün yapısına bağlar; ara nesnelerden biri değişince tüm çağıranlar değişir. Tell-don't-ask ise nesneden veriyi alıp kararı dışarıda vermek yerine nesneye ne yapacağını söylemektir: if (order.getStatus() == CREATED) order.setStatus(CANCELLED) yerine order.cancel(). Böylece kural (hangi durumdan iptal edilebilir) tek yerde yaşar.

Tek cümle: "Nesneye verisini sormak yerine ne yapacağını söylerim; kural verisinin yanında yaşar, zincirleme getter'lar yapıya bağımlılık yaratır."

Anemic ve rich domain model

Anemic modelde entity'ler sadece getter/setter'dır, tüm kurallar servislerdedir; aynı kural birden çok serviste tekrar eder ve geçersiz durumlar (setStatus ile her yere geçiş) mümkündür. Rich modelde entity kendi invariant'larını korur: Order.create(...) her zaman geçerli bir sipariş üretir, order.cancel() geçersiz geçişi reddeder. Demo projesindeki Order (public setter yok, static factory) bu yaklaşımı izler. JPA ile rich model mümkündür; protected no-arg constructor ve setter'sız alanlar yeterlidir.

Tek cümle: "Durum geçişleri ve invariant'ları entity'nin kendisine koyarım; servis akışı yönetir, kuralı entity korur."

Package by feature

Katmana göre paketleme (controller, service, repository) bir özelliğin kodunu tüm projeye dağıtır ve her şeyi public yapmaya zorlar. Özelliğe göre paketleme (order, stock, outbox, shipment; demo projesindeki gibi) ilgili kodu bir arada tutar, package-private görünürlükle kapsülleme sağlar ve bir özelliğin ileride ayrı bir servise ayrılmasını kolaylaştırır. Modüller arası sınırlar ArchUnit ya da Spring Modulith ile doğrulanabilir.

Tek cümle: "Kodu katmana değil özelliğe göre paketlerim; package-private ile sınırları derleyiciye korutururum."

Sık yapılan hatalar

  • Pattern adını gerekçe yerine kullanmak → mülakatçı karar verme yeteneğini göremez → problem, seçenekler, seçim ve maliyet sırasıyla anlat.
  • Switch'i bir "factory" sınıfına taşıyıp OCP'yi çözdüğünü sanmak → her yeni tipte yine mevcut kod değişir → implementasyonların kendi anahtarını bildirdiği registry.
  • Bilinmeyen tipte varsayılan değer dönmek (bilinmeyen firma → 0 TL) → sessiz ve para kaybettiren bug → domain exception ve anlamlı HTTP kodu.
  • Map<Enum, ...>'e String ile get yapmak → derlenir, her zaman null → dönüşümü tek giriş noktasında yap, içeride enum dolaştır.
  • Locale'siz toUpperCase() → Türkçe locale'de "i" → "İ" ve valueOf başarısız → Locale.ROOT.
  • Para için double ve equals ile karşılaştırma → yuvarlama hataları ve scale farkı → BigDecimal, compareTo, açık RoundingMode.
  • catch (Exception e) ile hatayı yutup 500 dönmek → log yok, istemci için anlamsız → domain exception hiyerarşisi ve merkezi handler.
  • Catch-all @ExceptionHandler(Exception.class) yazıp ResponseEntityExceptionHandler'ı genişletmemek → validasyon, bozuk JSON ve bilinmeyen URL hataları 500 olur → base sınıfı genişlet.
  • Koda gömülü secret'ı silip bırakmak → key Git geçmişinde yaşamaya devam eder → konfigürasyona taşı ve key'i rotate et.
  • Timeout'suz HTTP client → yavaş bir sağlayıcı tüm thread'leri kilitler → connect ve read timeout, gerekirse circuit breaker.
  • Timeout'u "başarısız" saymak → çift çekim → idempotency key ve durum sorgusu/mutabakat.
  • Kod paylaşmak için abstract üst sınıf → kırılgan üst sınıf, her farklılıkta yeni hook → kompozisyon.
  • Testsiz refactor ya da refactor ile davranış değişikliğini aynı commit'te yapmak → regresyonun nedeni bulunamaz → characterization test, küçük adımlar, ayrı commit'ler.
  • Spring singleton bean'de istek bazlı durumu alanda tutmak → thread'ler arası veri sızıntısı → stateless bean, durum parametrelerde.

Hızlı tekrar

S: SRP'deki "değişmek için tek neden" neyi kasteder?

C: Değişim talebinin geldiği aktörü (ekip/iş birimi). Farklı aktörlerin talebiyle değişen kodlar ayrı modüllerde olmalı.

S: Open/Closed ihlalinin tipik belirtisi ve çözümü nedir?

C: Her yeni tipte büyüyen if/else ya da switch. Çözüm ortak bir arayüz ve tip başına implementasyon (Strategy, Replace Conditional with Polymorphism).

S: Liskov'u ihlal etmenin üç yolu nedir?

C: Ön koşulu sıkılaştırmak, son koşulu gevşetmek (null dönmek, beklenmeyen exception) ve üst tipin invariant'ını bozmak.

S: DI ile DIP farkı nedir?

C: DI bağımlılığı dışarıdan verme mekanizmasıdır ve somut sınıfı da verebilir. DIP bağımlılığın yönüyle ilgilidir: üst seviye kendi tanımladığı soyutlamaya bağlıdır, altyapı onu uygular.

S: Spring'de Strategy nasıl kurulur?

C: Implementasyonlar List<Interface> olarak enjekte edilir, constructor'da kendi bildirdikleri anahtarla Map'e çevrilir, duplicate'te açılışta hata verilir, bilinmeyen anahtarda domain exception fırlatılır.

S: JdbcTemplate neden klasik Template Method değildir?

C: Kalıtım değil kompozisyon kullanır: akışı template yönetir, değişen adımı callback (lambda) olarak alırsın. Template-callback, Template Method'un kompozisyon halidir.

S: GoF Singleton ile Spring singleton scope farkı nedir?

C: GoF Singleton JVM'de tek örnek ve global durumdur. Spring singleton, container başına ve bean tanımı başına tek örnektir; testte değiştirilebilir ama thread'ler arasında paylaşıldığı için stateless olmalıdır.

S: @EventListener ile @TransactionalEventListener farkı nedir?

C: @EventListener senkron, aynı thread ve transaction'da çalışır. @TransactionalEventListener varsayılan olarak commit sonrası (AFTER_COMMIT) çalışır. İkisi de bellek içidir; kaybolmaması gereken event'ler için outbox gerekir.

S: Characterization test nedir?

C: Legacy kodun doğru değil, mevcut davranışını sabitleyen test. Refactoring'in güvenlik ağıdır; bug gibi görünen davranışlar da belgelenir ve bilinçli olarak ayrı adımda değiştirilir.

S: k1'deki başlangıç kodunun en kritik bug'ı nedir?

C: Bilinmeyen kargo firması için sessizce 0 dönmesi, yani bedava kargo. Doğrusu domain exception ve 400.

S: k2'de ortak HTTP kodu için neden abstract üst sınıf yerine kompozisyon?

C: Ortak client tek başına test edilir, farklı protokol gerektiren sağlayıcı farklı client kullanabilir ve kırılgan üst sınıf ile hook çoğalması önlenir.

S: Catch-all @ExceptionHandler(Exception.class) neyi bozar ve çözümü nedir?

C: ExceptionHandlerExceptionResolver varsayılan çözücüden önce çalıştığı için validasyon, bozuk JSON, eksik parametre ve bilinmeyen URL hataları 500 olur. Çözüm advice'ın ResponseEntityExceptionHandler'ı genişletmesidir.

S: Ödeme sağlayıcısına yapılan authorize çağrısında timeout ne anlama gelir?

C: Sonuç bilinmiyor; para çekilmiş olabilir. Başarısız sayılmaz; idempotency key ile tekrar edilir ya da durum sorgusu ve mutabakatla netleştirilir.

S: Ne zaman soyutlama eklemem?

C: Değişim ekseni kanıtlanmadığında (tek implementasyon, rule of three'ye ulaşılmamış). Yanlış soyutlama tekrar etmekten pahalıdır ve geri açmak (inline) meşru bir refactoring'dir.

Kaynaklar