Spring · Bölüm 6

Spring IoC, DI ve bean yaşam döngüsü

Spring container'ının bean'leri nasıl tanımladığını, bağladığını, proxy'lediğini ve yok ettiğini; scope, state ve AOP tuzaklarını mekanizmasıyla öğretir.

~45 dk okumaQuiz: j20, j21, j22, q7
Cevaplar:

Bu bölümde

Spring'in kalbi olan IoC container'ını içeriden göreceksin: bean tanımları nereden gelir, hangi sırayla oluşturulur, bağımlılıklar nasıl çözülür, @Transactional gibi davranışları ekleyen proxy hangi anda doğar ve neden bazen hiç devreye girmez. Mülakatta bu konu üç şekilde gelir: kavram sorusu ("IoC nedir, neden constructor injection?"), kod okuma ("bu servis neden başka kullanıcının indirimini gösteriyor?", "bu @PostConstruct içindeki transaction neden çalışmıyor?") ve tasarım sorusu ("yeni kargo firması eklendiğinde koda dokunmadan nasıl seçersin?"). Bu bölümdeki her başlık, bir production hatasının kök nedenini açıklayabilecek kadar derin.

Konu anlatımı

IoC ve DI: kontrolü kim tutuyor?

Inversion of Control (IoC, kontrolün tersine çevrilmesi), nesneleri oluşturma, bağlama ve yaşam döngülerini yönetme sorumluluğunun uygulama kodundan bir framework'e geçmesidir. Dependency Injection (DI, bağımlılık enjeksiyonu) bu tersine çevirmenin en yaygın uygulanış biçimidir: bir sınıf ihtiyaç duyduğu nesneleri kendisi new ile oluşturmaz, dışarıdan (constructor, setter veya alan üzerinden) alır.

IoC olmadan bir checkout servisi şöyle görünür:

java
public class CheckoutService {
    // Sınıf hem iyzico'yu seçiyor, hem HTTP client'ı kuruyor, hem API anahtarını biliyor.
    private final PaymentGateway gateway =
            new IyzicoGateway(HttpClient.newHttpClient(), System.getenv("IYZICO_KEY"));
}

Bu kodun üç somut problemi var:

  1. Test edilemez. Unit testte CheckoutService'i oluşturduğun anda gerçek bir iyzico client'ı kurulur. Sahte bir gateway veremezsin.
  2. Değişime kapalı. Stripe'a geçmek, bu sınıfın kaynak kodunu değiştirmek demek. Ödeme sağlayıcısı seçimi bir iş kuralı değil, bir konfigürasyon kararıdır.
  3. Yaşam döngüsü dağınık. HttpClient kimin, ne zaman kapatılacak? Uygulamada kaç tane oluşuyor? Her sınıf kendi kopyasını mı kuruyor?

DI ile sınıf yalnızca soyutlamaya bağlanır (SOLID'deki Dependency Inversion), hangi implementasyonun verileceğine container karar verir:

java
@Service
public class CheckoutService {

    private final PaymentGateway gateway;

    public CheckoutService(PaymentGateway gateway) {
        this.gateway = gateway;
    }

    public PaymentResult pay(Order order) {
        return gateway.charge(order.id(), order.total());
    }
}

Container'ın yaptığı iş özetle şu:

  1. Bean tanımlarını toplar: component scan, @Bean metotları, @Import, Spring Boot auto-configuration.
  2. Tanımları işler: BeanFactoryPostProcessor'lar tanımları okuyup değiştirebilir (ör. ${...} placeholder'larını çözmek).
  3. Bağımlılık grafiğini çıkarır ve bean'leri doğru sırayla oluşturur.
  4. Bağımlılıkları enjekte eder.
  5. Yaşam döngüsü callback'lerini çalıştırır (@PostConstruct vb.).
  6. Gerekirse bean'i bir proxy ile sarar (AOP: @Transactional, @Async, @Cacheable).
  7. Kapanışta destroy callback'lerini çalıştırır.

Mülakatta "IoC ile DI aynı şey mi?" sorusunun kısa cevabı: IoC genel prensiptir (kontrol framework'te), DI bu prensibin bağımlılıklar için uygulanmasıdır. Template method, event listener, servlet container'ın doGet çağırması da birer IoC örneğidir ama DI değildir.

Injection türleri ve neden constructor injection

Spring üç enjeksiyon yolu sunar:

java
// 1) Constructor injection (önerilen)
@Service
public class OrderService {
    private final OrderRepository orderRepository;
    private final Clock clock;

    public OrderService(OrderRepository orderRepository, Clock clock) {
        this.orderRepository = orderRepository;
        this.clock = clock;
    }
}

// 2) Setter injection (opsiyonel, sonradan değişebilen bağımlılıklar için)
@Service
public class ReportService {
    private ReportFormatter formatter = new PlainFormatter();

    @Autowired(required = false)
    public void setFormatter(ReportFormatter formatter) {
        this.formatter = formatter;
    }
}

// 3) Field injection (önerilmez)
@Service
public class LegacyService {
    @Autowired
    private OrderRepository orderRepository;
}

Constructor injection'ın üstünlüğü mekanizmadan gelir:

  • Alanlar final olabilir. Nesne oluşturulduktan sonra bağımlılık değişmez. final alanlar Java Memory Model'de güvenli yayın (safe publication) garantisi de taşır: constructor bittikten sonra nesneyi gören her thread final alanların doğru değerini görür.
  • Eksik bağımlılıkla nesne oluşamaz. Field injection'da Spring önce no-arg constructor ile boş nesneyi oluşturur, sonra reflection ile alanları doldurur. Arada geçen sürede veya Spring dışında new LegacyService() dendiğinde orderRepository null'dur.
  • Test kolaylığı. new OrderService(fakeRepo, Clock.fixed(...)) yazarsın. Spring context'i, @InjectMocks ya da reflection gerekmez.
  • Bağımlılıklar görünür. Constructor'ın 8 parametresi varsa sınıf muhtemelen çok fazla iş yapıyordur. Field injection bu kokuyu gizler: 8 tane @Autowired alan göze batmaz.
  • Döngüsel bağımlılık açılışta yakalanır. A'nın constructor'ı B'yi, B'ninki A'yı istiyorsa hiçbir nesne oluşturulamaz ve context açılmaz. Field injection'da Spring bunu "yarım nesne" paylaşarak çözebiliyordu, bu da tasarım hatasını saklıyordu (aşağıda ayrıntısı var).

Spring Framework 4.3'ten beri sınıfın tek constructor'ı varsa @Autowired yazmak gerekmez. Birden fazla constructor varsa Spring'e hangisini kullanacağını @Autowired ile söylemek gerekir.

Opsiyonel bağımlılık için setter yerine şu araçlar da var:

java
public NotificationService(ObjectProvider<SmsClient> smsClient) {
    // Bean yoksa null döner; çağrı anında çözülür.
    this.smsClient = smsClient.getIfAvailable();
}

public NotificationService(Optional<SmsClient> smsClient) { ... }

Lombok kullanan projelerde @RequiredArgsConstructor ile final alanlar için constructor otomatik üretilir. Mekanizma yine constructor injection'dır. Mülakatta "Lombok olmadan da yazarım" demek yeterli; asıl önemli olan alanların final olması.

ApplicationContext ve BeanFactory

BeanFactory, container'ın en temel arayüzüdür: bean tanımlarını tutar, getBean ile nesne verir, bağımlılıkları çözer. Varsayılan implementasyonu DefaultListableBeanFactory'dir.

ApplicationContext, BeanFactory'yi genişletir ve bir uygulama için gereken her şeyi ekler:

ÖzellikSade BeanFactoryApplicationContext
Singleton'ların oluşturulmasıTembel (ilk getBean'de)Açılışta eager (refresh sırasında)
BeanPostProcessor kaydıElle addBeanPostProcessorOtomatik algılanır
BeanFactoryPostProcessorElle çalıştırılırOtomatik çalıştırılır
Event yayınıYokApplicationEventPublisher
Environment, profiller, property kaynaklarıYokVar
i18n (MessageSource), ResourceLoaderYokVar

Pratikte her zaman ApplicationContext kullanılır; Spring Boot web uygulamalarında bu AnnotationConfigServletWebServerApplicationContext gibi bir sınıftır. BeanFactory'nin ayrı var olmasının nedeni katmanlı tasarımdır: container çekirdeği, uygulama özelliklerinden ayrı tutulur.

Eager oluşturmanın pratik önemi büyüktür: yanlış bir konfigürasyon (eksik bean, hatalı property, çözülemeyen bağımlılık) uygulama açılırken patlar, ilk müşteri isteğinde değil. Kubernetes'te bu, hatalı sürümün readiness'a hiç ulaşmaması ve rollout'un durması anlamına gelir.

Bean'ler nereden gelir: component scan, @Component ve @Bean

Component scan, belirtilen paket ve alt paketlerindeki sınıfları tarar ve @Component (veya onu meta-annotation olarak taşıyan) anotasyonlu sınıfları bean olarak kaydeder. Spring Boot'ta @SpringBootApplication içindeki @ComponentScan, ana sınıfın bulunduğu paketten başlar. Ana sınıfı com.example.order paketine koyarsan com.example.common paketindeki bean'ler bulunmaz.

Stereotype anotasyonları:

AnotasyonAnlamıEk davranış
@ComponentGenel beanYok
@Serviceİş mantığı katmanıYok, sadece semantik
@RepositoryVeri erişimiPersistenceExceptionTranslationPostProcessor ile JDBC/JPA exception'larını Spring'in DataAccessException hiyerarşisine çevirir
@Controller / @RestControllerWeb katmanıSpring MVC handler olarak algılanır
@ConfigurationBean tanım sınıfıCGLIB ile proxy'lenir (full mode, aşağıda)

@Service ile @Component arasında scope, thread-safety veya transaction açısından hiçbir fark yoktur. Bu, q7'deki bir yanlış seçeneğin de cevabıdır.

@Bean metotları, kaynak koduna anotasyon ekleyemediğin sınıflar (üçüncü parti SDK client'ları, Clock, ObjectMapper, RestClient) veya oluşturulması mantık gerektiren nesneler için kullanılır:

java
@Configuration(proxyBeanMethods = false)
public class InfrastructureConfig {

    @Bean
    public Clock clock() {
        return Clock.systemUTC();
    }

    @Bean
    public RestClient cargoRestClient(RestClient.Builder builder,
                                      CargoProperties props) {
        return builder
                .baseUrl(props.baseUrl())
                .build();
    }
}

Kural basit: kendi sınıfın → @Component + scan; başkasının sınıfı veya kurulum mantığı → @Bean. Aynı türden birden çok, farklı yapılandırılmış nesne gerekiyorsa da (iki ayrı RestClient) @Bean kullanılır.

@Configuration: full mode ve lite mode (proxyBeanMethods)

Şu konfigürasyona bak:

java
@Configuration
public class KafkaClientsConfig {

    @Bean
    public ProducerFactory<String, String> producerFactory() {
        return new DefaultKafkaProducerFactory<>(Map.of(/* ... */));
    }

    @Bean
    public KafkaTemplate<String, String> orderTemplate() {
        return new KafkaTemplate<>(producerFactory());   // metot çağrısı
    }

    @Bean
    public KafkaTemplate<String, String> auditTemplate() {
        return new KafkaTemplate<>(producerFactory());   // yine metot çağrısı
    }
}

Düz Java mantığıyla producerFactory() üç kez çağrılır ve üç ayrı factory (dolayısıyla üç ayrı Kafka producer ve bağlantı havuzu) oluşur. Ama @Configuration sınıfları varsayılan olarak full mode'dadır: Spring bu sınıfın CGLIB ile bir alt sınıfını üretir ve @Bean metotlarını override eder. Override edilen metot önce container'a bakar: "Bu isimde bir singleton zaten var mı?" Varsa onu döner. Sonuç: üç çağrı da aynı ProducerFactory singleton'ını alır.

Lite mode iki durumda geçerlidir:

  1. @Bean metotları @Configuration olmayan bir sınıfta (ör. @Component) tanımlıysa,
  2. Sınıf @Configuration(proxyBeanMethods = false) ile işaretliyse.

Lite mode'da CGLIB alt sınıfı üretilmez; @Bean metodu düz bir factory metodudur. Başka bir @Bean metodunu doğrudan çağırmak normal bir Java çağrısıdır ve her seferinde yeni nesne üretir. Spring dokümantasyonu bu yüzden lite mode'da bean'ler arası bağımlılığın metot çağrısıyla değil, metot parametresiyle kurulmasını söyler:

java
@Configuration(proxyBeanMethods = false)
public class KafkaClientsConfig {

    @Bean
    public ProducerFactory<String, String> producerFactory() { ... }

    @Bean
    public KafkaTemplate<String, String> orderTemplate(ProducerFactory<String, String> pf) {
        return new KafkaTemplate<>(pf);   // container'dan gelen singleton
    }
}

Neden lite mode tercih edilir? CGLIB alt sınıfı üretmek açılış süresine ve belleğe maliyet ekler; GraalVM native image gibi ortamlarda runtime sınıf üretimi ayrıca sorundur. Spring Boot'un kendi auto-configuration sınıfları bu yüzden proxyBeanMethods = false kullanır. Parametre ile bağımlılık bildirmek hem daha açık hem daha ucuzdur.

proxyBeanMethods = false yapıp içeride hâlâ producerFactory() gibi doğrudan metot çağrısı bırakmak sessiz bir hatadır: uygulama açılır, ama iki ayrı producer, iki ayrı bağlantı havuzu, iki kat bellek oluşur. Lite mode'a geçerken tüm iç çağrıları parametreye çevir.

Aynı tipte birden çok bean: @Primary, @Qualifier, List ve Map

İki ödeme sağlayıcısı düşün: IyzicoGateway ve StripeGateway, ikisi de PaymentGateway implementasyonu ve ikisi de @Component. CheckoutService constructor'ı tek bir PaymentGateway istiyor. Spring rastgele seçmez: NoUniqueBeanDefinitionException: expected single matching bean but found 2 ile context açılmaz. Bu iyi bir şeydir: belirsizlik açılışta patlar.

Çözüm yolları (@Qualifier adayları baştan daraltır; birden çok aday kalırsa Spring önce @Primary/@Fallback işaretlerine, en son isim eşleşmesine bakar):

  1. @Primary: Tek değerli bir enjeksiyon noktasında birden fazla aday varsa, @Primary işaretli olan kazanır. "Varsayılan implementasyon" anlamına gelir.
  2. @Fallback (Spring Framework 6.2+): @Primary'nin tersi. "Başka aday yoksa beni kullan" der. Normal adaylardan tek biri kalırsa o fiilen primary olur.
  3. @Qualifier("iyzicoGateway"): Enjeksiyon noktasında hangi bean'in istendiğini isimle (veya özel qualifier anotasyonuyla) belirtir. @Primary'yi ezer.
  4. Parametre adı eşleşmesi: Hiçbiri yoksa Spring, parametre adını bean adıyla eşleştirmeye çalışır (PaymentGateway iyzicoGateway). Bu, derleyicinin parametre adlarını bytecode'a yazmasını (-parameters bayrağı) gerektirir. Spring Boot'un Maven parent'ı ve Gradle eklentisi bunu açar; düz bir projede açık değilse eşleşme çalışmaz. Bu yüzden isme dayalı örtük seçime güvenmek kırılgandır.

Seçimin çalışma anında, veriye göre yapılması gerekiyorsa (sipariş hangi ödeme yöntemiyle geldi?) hepsini enjekte edip kendin seçersin. Spring, bir tipin tüm bean'lerini List<T> veya Map<String, T> (anahtar: bean adı) olarak enjekte edebilir:

java
public enum PaymentProvider { IYZICO, STRIPE }

public interface PaymentGateway {
    PaymentProvider provider();
    PaymentResult charge(UUID orderId, BigDecimal amount);
}

@Service
public class PaymentRouter {

    private final Map<PaymentProvider, PaymentGateway> gateways;

    public PaymentRouter(List<PaymentGateway> gateways) {
        // toUnmodifiableMap aynı key iki kez gelirse IllegalStateException fırlatır:
        // iki bean aynı provider'ı iddia ederse uygulama AÇILIŞTA patlar (fail fast).
        this.gateways = gateways.stream()
                .collect(Collectors.toUnmodifiableMap(PaymentGateway::provider, Function.identity()));
    }

    public PaymentResult charge(PaymentProvider provider, UUID orderId, BigDecimal amount) {
        PaymentGateway gateway = gateways.get(provider);
        if (gateway == null) {
            throw new UnsupportedPaymentProviderException(provider);
        }
        return gateway.charge(orderId, amount);
    }
}

Bu, Strategy pattern + Spring registry'dir. Yeni bir sağlayıcı eklemek yeni bir @Component yazmaktan ibarettir; PaymentRouter'a dokunulmaz (Open/Closed). Map<String, PaymentGateway> doğrudan enjekte edilebilir ama anahtar bean adı olduğu için ("iyzicoGateway") sınıf adını değiştirmek davranışı değiştirir; domain'e ait bir anahtar (enum) daha sağlamdır.

List<T> enjeksiyonunda sıra, bean'ler @Order veya Ordered taşıyorsa ona göre belirlenir; yoksa kayıt sırasına bağlıdır ve buna güvenilmemelidir. Zincir halinde çalışan validator'lar gibi sıranın önemli olduğu durumlarda @Order açıkça yazılmalıdır.

Döngüsel bağımlılık (circular dependency)

OrderService → PaymentService → OrderService gibi bir döngü düşün.

Constructor injection ile döngü her zaman çözülemezdi ve çözülemez: OrderService'i oluşturmak için önce PaymentService gerekir, onu oluşturmak için OrderService gerekir. Spring ikinci kez "şu an oluşturulmakta olan" bean'i istediğini fark eder ve BeanCurrentlyInCreationException fırlatır. Spring Boot'un failure analyzer'ı bunu "The dependencies of some of the beans in the application context form a cycle" mesajıyla ve döngüyü çizerek gösterir.

Field veya setter injection ile durum farklıydı. Spring, singleton'lar için üç seviyeli bir önbellek kullanır:

  1. singletonObjects: tamamen hazır bean'ler,
  2. earlySingletonObjects: oluşturulmuş ama henüz tam doldurulmamış (erken referansı alınmış) bean'ler,
  3. singletonFactories: erken referansı üretecek factory'ler.

OrderService constructor'la oluşturulur (alanlar boş), erken referansı singletonFactories'e konur, sonra alan enjeksiyonu sırasında PaymentService istenir. PaymentService oluşturulurken OrderService istenince Spring hazır olmayan erken referansı verir. Döngü "çözülür", ama PaymentService bir süre yarım bir OrderService'e bakar. AOP devredeyse erken referansın proxy olması için ayrıca getEarlyBeanReference kancası çalışır; bu, karmaşık ve kırılgan bir yoldur.

Spring Boot 2.6+ bu davranışı varsayılan olarak kapattı: spring.main.allow-circular-references varsayılan false'tur ve field/setter döngüleri de açılışta hata verir. Gerekçe: döngü neredeyse her zaman bir tasarım hatasıdır (iki sınıf birbirinin işini yapıyor) ve sessizce çözülmesi bunu gizler. Property'yi true yapmak sadece field/setter döngülerini eski haline döndürür; constructor döngüleri yine çözülemez.

Döngüyü kırmanın doğru yolları:

  • Sorumluluğu ayır: İki sınıfın birbirinden istediği ortak mantığı üçüncü bir sınıfa (PricingPolicy, OrderLookup) çıkar. En sık doğru çözüm budur.
  • Event kullan: OrderService sipariş oluştuğunda PaymentService'i çağırmak yerine bir OrderCreated event'i yayınlar; ödeme tarafı dinler. Bağımlılık tek yönlü kalır.
  • @Lazy: Constructor parametresine @Lazy koymak, gerçek bean yerine tembel çözümleme yapan bir proxy enjekte eder; gerçek bean ilk metot çağrısında çözülür. Döngüyü teknik olarak kırar ama tasarım sorununu çözmez; son çare olarak kullanılır.

Bean scope'ları

Scope, container'ın bir bean tanımından kaç tane ve ne kadar ömürlü nesne üreteceğini belirler.

ScopeÖmürDestroy callbackTipik kullanım
singleton (varsayılan)Container başına 1Context kapanınca çağrılırStateless servisler, repository'ler, client'lar
prototypeHer enjeksiyonda veya her getBean'de yeniÇağrılmaz; temizlik çağıranın işiStateful, kısa ömürlü yardımcı nesneler (nadir)
requestHTTP isteği başına 1İstek bitinceİstek bağlamı (correlation id, tenant)
sessionHTTP oturumu başına 1Oturum bitinceSunucu tarafı oturum tutan web uygulamaları
applicationServletContext başına 1Context kapanıncaNadiren

İki önemli ayrıntı:

  • Singleton, JVM başına değil container başına tektir. GoF Singleton pattern'iyle karıştırılmamalı. Aynı JVM'de iki ayrı ApplicationContext iki ayrı "singleton" üretir; testlerde farklı context konfigürasyonları bu yüzden farklı instance'lar görür.
  • Prototype bean'in yaşam döngüsünün sonu yönetilmez. Spring nesneyi oluşturur, initialization callback'lerini çalıştırır ve teslim eder. Sonrasında referans tutmaz; @PreDestroy çağrılmaz. Prototype bir bean dosya veya bağlantı açıyorsa kapatmak kullananın sorumluluğundadır.

request ve session scope'ları sadece web-aware bir context'te ve aktif bir HTTP isteği varken çalışır. @Async bir thread'de, bir Kafka listener'ında veya @Scheduled görevde request-scoped bir bean'e erişmek IllegalStateException: Scope 'request' is not active for the current thread hatası verir.

Singleton'a kısa ömürlü bean enjekte etmek

Prototype bir bean'i singleton bir bean'e constructor ile enjekte ettiğinde ne olur? Singleton bir kez oluşturulur, enjeksiyon da bir kez yapılır. Prototype tanımından bir nesne üretilir ve singleton'ın alanında sonsuza kadar kalır. Yani prototype fiilen singleton olur. Bu, j21'in konusudur.

Her kullanımda gerçekten yeni (veya o anki isteğe ait) bir nesne gerekiyorsa üç yol var:

1) ObjectProvider<T>: Bean'i her seferinde container'dan iste.

java
@Service
public class InvoiceService {

    private final ObjectProvider<InvoicePdfBuilder> builders;

    public InvoiceService(ObjectProvider<InvoicePdfBuilder> builders) {
        this.builders = builders;
    }

    public byte[] render(Invoice invoice) {
        InvoicePdfBuilder builder = builders.getObject();   // prototype: her çağrıda yeni
        return builder.header(invoice).lines(invoice.lines()).build();
    }
}

ObjectProvider, ObjectFactory'yi genişletir; getIfAvailable, getIfUnique, stream() gibi metotlarla opsiyonel ve çoklu bean senaryolarını da karşılar. JSR-330'un jakarta.inject.Provider<T> arayüzü de aynı işi görür.

2) @Lookup metodu: Spring, sınıfın CGLIB alt sınıfını üretir ve @Lookup işaretli metodu "container'dan bean döndür" diye override eder.

java
@Component
public abstract class InvoiceService {

    @Lookup
    protected abstract InvoicePdfBuilder newBuilder();

    public byte[] render(Invoice invoice) {
        return newBuilder().header(invoice).build();
    }
}

Kısıtları: sınıf ve metot final olamaz (alt sınıf üretilecek), component scan ile bulunan sınıflarda çalışır; @Bean factory metoduyla oluşturulan bean'lerde çalışmaz çünkü o nesneyi container değil senin kodun new ile oluşturur. Kod Spring'e daha sıkı bağlanır; ObjectProvider genelde daha okunurdur.

3) Scoped proxy: Singleton'a gerçek bean yerine bir proxy enjekte edilir; proxy her metot çağrısında o anki scope'taki (o anki HTTP isteğindeki) gerçek bean'i bulup çağrıyı ona iletir.

java
@Component
@RequestScope   // = @Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class RequestContext {
    private String correlationId;
    private String sellerId;
    // getter/setter
}

@Service
public class SellerAuditService {
    private final RequestContext requestContext;   // aslında proxy

    public SellerAuditService(RequestContext requestContext) {
        this.requestContext = requestContext;
    }

    public void audit(String action) {
        // Her çağrı o anki isteğin RequestContext'ine gider.
        log.info("seller={} action={} cid={}",
                requestContext.getSellerId(), action, requestContext.getCorrelationId());
    }
}

Scoped proxy request/session scope için doğal çözümdür. Prototype için kullanıldığında ise her metot çağrısında yeni bir nesne üretir: cart.add(item) bir nesneye, cart.items() başka bir nesneye gider. Bu yüzden prototype + scoped proxy neredeyse hiçbir zaman istenen davranış değildir.

Sepet, oturum, kullanıcı tercihi gibi kullanıcıya ait durum bir bean scope'u problemi değildir. Bu veri Redis'te veya veritabanında userId anahtarıyla tutulur. Uygulama 20 pod'da çalışırken session scope'lu bir bean, kullanıcının bir sonraki isteği başka pod'a düştüğünde kaybolur. Stateless servis + dış depo, yatay ölçeklemenin ön şartıdır.

Singleton bean'de state tutmak

Spring'in varsayılan scope'u singleton olduğu için, bir servis bean'i Tomcat'in (veya virtual thread executor'ın) yüzlerce thread'i tarafından aynı anda kullanılır. Bean'in bir alanına yazılan her değer, o bean'i kullanan tüm isteklerle paylaşılır.

java
@Component
public class PriceCalculator {

    private BigDecimal discount = BigDecimal.ZERO;   // tüm thread'lerin ortak alanı

    public BigDecimal calculate(Cart cart, Campaign campaign) {
        this.discount = campaign.discountFor(cart);   // (1) A yazar
        BigDecimal total = cart.total();               // (2) bu arada B kendi discount'unu yazar
        return total.subtract(discount);               // (3) A, B'nin indirimini okur
    }
}

Bu bir race condition'dır: sonuç thread'lerin zamanlamasına bağlıdır. Düşük trafikte iki isteğin bu üç satırın arasına denk gelme olasılığı çok düşüktür, bu yüzden lokal testte ve QA'da görünmez; kampanya trafiğinde ortaya çıkar. Üstelik discount alanı volatile olmadığı için bir thread'in yazdığı değeri diğerinin ne zaman göreceği de garanti değildir (visibility). synchronized eklemek doğruluğu sağlar ama tüm fiyat hesaplamalarını tek sıraya sokar; asıl çözüm alanı kaldırmaktır:

java
@Component
public class PriceCalculator {

    public BigDecimal calculate(Cart cart, Campaign campaign) {
        BigDecimal discount = campaign.discountFor(cart);   // local değişken: thread'in kendi stack'inde
        return cart.total().subtract(discount);
    }
}

Local değişkenler her thread'in kendi stack frame'inde yaşar; paylaşılmaz. İstek bazlı veri parametre, local değişken ve dönüş değeri ile taşınır.

Singleton bir bean'de güvenle tutulabilecek alanlar:

  • final bağımlılıklar (diğer bean'ler),
  • Immutable konfigürasyon (record tipli properties, Duration, BigDecimal),
  • Thread-safe yapılar: ConcurrentHashMap, Caffeine cache, AtomicLong, LongAdder (metrik sayaçları), Micrometer Counter.

ThreadLocal istek bağlamı taşımak için kullanılabilir (Spring Security'nin SecurityContextHolder'ı, MDC), ama thread havuzlarında temizlenmezse bir sonraki isteğe sızar; finally ile remove() şarttır. Java 21'de virtual thread'ler ile milyonlarca thread'de ThreadLocal bellek maliyeti artar; Java 25'te final olan ScopedValue (JEP 506) değişmez bağlam taşımak için daha uygun bir alternatiftir.

Bean yaşam döngüsü: tanımdan yok edilişe

Bir singleton bean'in tam yolculuğu şu sıradadır. Ayrıntılar AbstractAutowireCapableBeanFactory içindeki doCreateBean ve initializeBean metotlarından gelir.

diyagram yükleniyor…
flowchart TD
    A[Bean tanımları toplanır: scan, @Bean, @Import, auto-config] --> B[BeanFactoryPostProcessor'lar tanımları değiştirir]
    B --> C[Instantiation: constructor çalışır, constructor injection]
    C --> D[Populate: @Autowired alan ve setter injection]
    D --> E[Aware: BeanNameAware, BeanClassLoaderAware, BeanFactoryAware]
    E --> F[BeanPostProcessor.postProcessBeforeInitialization: ApplicationContextAware, @PostConstruct]
    F --> G[InitializingBean.afterPropertiesSet, sonra @Bean initMethod]
    G --> H[BeanPostProcessor.postProcessAfterInitialization: AOP proxy burada oluşur]
    H --> I[Bean kullanıma hazır; context refresh biter]
    I --> J[Kapanış: @PreDestroy, DisposableBean.destroy, destroyMethod]

Adım adım:

  1. Tanımlar toplanır. Container henüz nesne üretmez; BeanDefinition (sınıf, scope, bağımlılıklar, init/destroy metotları) nesneleri oluşur. @Configuration sınıflarını, @ComponentScan ve @Import'u işleyen de aslında bir BeanDefinitionRegistryPostProcessor'dır: ConfigurationClassPostProcessor.
  2. BeanFactoryPostProcessor'lar çalışır. Bunlar tanımlar (metadata) üzerinde, hiçbir bean örneği oluşmadan çalışır. Klasik örnek PropertySourcesPlaceholderConfigurer: tanımlardaki ${...} ifadelerini çözer. Bu yüzden @Bean ile tanımlanan bir BeanFactoryPostProcessor'ın metodu static olmalıdır; aksi halde onu içeren @Configuration sınıfı çok erken oluşturulur ve o sınıftaki @Value/@Autowired işlenmez.
  3. Instantiation. Constructor çağrılır. Constructor injection bu adımda olur.
  4. Populate. AutowiredAnnotationBeanPostProcessor alan ve setter enjeksiyonunu yapar.
  5. Aware arayüzleri. BeanNameAware, BeanClassLoaderAware, BeanFactoryAware doğrudan çağrılır.
  6. postProcessBeforeInitialization. Tüm BeanPostProcessor'ların "önce" kancası. İki önemli ayrıntı: ApplicationContextAware (ve EnvironmentAware gibi context Aware'leri) ApplicationContextAwareProcessor adlı bir BeanPostProcessor tarafından burada çağrılır. @PostConstruct metotları da CommonAnnotationBeanPostProcessor tarafından bu aşamada çalıştırılır. Yani @PostConstruct teknik olarak bir BPP'nin "before" kancasının içindedir.
  7. Init metotları. InitializingBean.afterPropertiesSet(), sonra @Bean(initMethod = "..."). Spring dokümantasyonuna göre üç mekanizma birlikte kullanılırsa sıra: @PostConstruct → afterPropertiesSet() → özel init metodu.
  8. postProcessAfterInitialization. @Transactional, @Async, @Cacheable, @Validated gibi davranışları ekleyen AOP proxy'si burada oluşur (AbstractAutoProxyCreator). BPP orijinal nesneyi alır ve yerine proxy döner; container bundan sonra herkese proxy'yi verir. (@Async ve @Validated için proxy'yi aynı aşamada AbstractAdvisingBeanPostProcessor alt sınıfları, yani AsyncAnnotationBeanPostProcessor ve MethodValidationPostProcessor üretir.)
  9. Kullanım. Tüm singleton'lar oluşunca SmartInitializingSingleton.afterSingletonsInstantiated() çağrılır, Lifecycle bean'leri başlatılır (embedded web server, Kafka listener container'ları), ContextRefreshedEvent yayınlanır. Spring Boot bunun üzerine ApplicationStartedEvent, runner'lar (ApplicationRunner, CommandLineRunner) ve ApplicationReadyEvent ekler.
  10. Kapanış. SmartLifecycle bean'leri faz sırasının tersine durdurulur (varsayılan faz başına 30 saniye bekleme), sonra singleton'lar için @PreDestroy → DisposableBean.destroy() → özel destroy metodu çalışır. Prototype bean'ler için destroy çağrılmaz.

Bu sıralamanın iki kritik sonucu var:

Birincisi: @PostConstruct ham nesne üzerinde çalışır. Spring dokümantasyonu bunu açıkça söyler: initialization callback ham bean referansı üzerinde çağrılır, AOP interceptor'ları henüz uygulanmamıştır. @PostConstruct içinden this.loadAll() çağırırsan iki sebeple transaction açılmaz: çağrı this üzerinden yapılır (self-invocation) ve proxy henüz yoktur.

İkincisi: @PostConstruct singleton oluşturma kilidinin içinde çalışır. Dokümantasyon, init metotlarının yalnızca konfigürasyon doğrulama ve basit veri yapısı hazırlığı için olduğunu, dış bean erişimi gibi ağır işlerin initialization deadlock riski taşıdığını belirtir. Açılışta ürün kataloğunu yüklemek, cache ısıtmak, uzak servise bağlanmak gibi işler @PostConstruct'ta yapılmaz.

Doğru warm-up yeri ApplicationRunner'dır:

java
@Component
public class CatalogWarmupRunner implements ApplicationRunner {

    private final CatalogLoader catalogLoader;   // ayrı bean, @Transactional(readOnly = true) metodu var

    public CatalogWarmupRunner(CatalogLoader catalogLoader) {
        this.catalogLoader = catalogLoader;
    }

    @Override
    public void run(ApplicationArguments args) {
        catalogLoader.loadAll();   // proxy'den geçer, transaction açılır
    }
}

Spring Boot runner'ları ApplicationStartedEvent'ten sonra, ApplicationReadyEvent'ten önce çalıştırır ve readiness durumunu (ReadinessState.ACCEPTING_TRAFFIC) ancak runner'lar bittikten sonra yayınlar. Yani runner içindeki senkron warm-up bitene kadar Kubernetes readiness probe'u başarısız olur ve pod trafik almaz. Warm-up'ı asenkron yapacaksan readiness'ı AvailabilityChangeEvent.publish(context, ReadinessState.REFUSING_TRAFFIC) ile kendin yönetmelisin.

BeanPostProcessor olarak kayıtlı bean'ler ve onların bağımlılıkları, diğer BPP'lerden önce oluşturulmak zorundadır. Bu yüzden auto-proxy gibi işlemlere uğramazlar ve Spring açılışta "Bean 'x' is not eligible for getting processed by all BeanPostProcessors" uyarısı yazar. Bir BPP'ye servis bean'i enjekte edersen o servisin @Transactional'ı sessizce çalışmayabilir. BPP'leri bağımlılıksız ve static @Bean olarak tanımla; bu log satırını gördüğünde ciddiye al.

AOP ve proxy: Spring "sihri" nasıl çalışır

Spring AOP proxy tabanlıdır. @Transactional bir anotasyondur; tek başına hiçbir şey yapmaz. İşi yapan, postProcessAfterInitialization aşamasında oluşturulan ve gerçek bean'in önüne konan proxy nesnesidir:

text
Controller --> [Proxy: TransactionInterceptor -> transaction aç] --> gerçek OrderService.placeOrder()
           <-- [Proxy: commit veya rollback]                     <--

İki proxy türü vardır:

JDK dynamic proxyCGLIB proxy
Nasıljava.lang.reflect.Proxy ile arayüzü implemente eden sınıfHedef sınıfın runtime'da üretilen alt sınıfı
ŞartHedef en az bir arayüz implemente etmeliSınıf final olmamalı
Yakalanan metotlarSadece arayüzdeki metotlarOverride edilebilen metotlar (private ve final hariç)
Somut sınıfla enjeksiyonBaşarısız (BeanNotOfRequiredTypeException)Çalışır

Spring Framework'ün kuralı: hedef bir arayüz implemente ediyorsa JDK proxy, etmiyorsa CGLIB. Spring Boot ise varsayılan olarak her zaman CGLIB kullanır (spring.aop.proxy-target-class=true). Sebep: bean'i somut sınıfıyla enjekte eden kodun (OrderServiceImpl) JDK proxy ile BeanNotOfRequiredTypeException alması çok yaygın bir şaşkınlık kaynağıydı.

CGLIB proxy'nin sınırları mekanizmasından gelir:

  • private metotlar override edilemez, bu yüzden yakalanamaz. Anotasyon sessizce yok sayılır.
  • final metotlar override edilemez. Daha sinsi bir yan etkisi var: Spring, CGLIB proxy örneğini Objenesis ile constructor çağırmadan oluşturur, bu yüzden proxy nesnesinin kendi alanları boştur. final bir metot proxy üzerinde çağrıldığında hedefe delege edilmez, proxy'nin kendi (boş) alanlarıyla çalışır; bağımlılıklar null geldiği için NullPointerException alabilirsin.
  • final sınıflar alt sınıflanamaz; proxy gereken bir final sınıf açılışta hata verir. Kotlin'de sınıflar varsayılan final olduğu için kotlin-spring (all-open) eklentisi kullanılır.
  • static metotlar nesneye bağlı değildir, proxy'lenemez.
  • Spring Framework 6.0+ ile class-based proxy'lerde protected ve package-private metotlar da @Transactional olabilir; private hâlâ olamaz.

Self-invocation en sık görülen tuzaktır. Proxy sadece dışarıdan gelen çağrıyı yakalar. Bean'in kendi içinden this.otherMethod() dediğinde çağrı proxy'ye hiç uğramaz:

java
@Service
public class OrderService {

    public void placeOrder(OrderCommand cmd) {
        saveOrder(cmd);          // this.saveOrder(...): proxy atlanır
    }

    @Transactional
    public void saveOrder(OrderCommand cmd) { ... }   // transaction AÇILMAZ
}

Spring dokümantasyonunun önerdiği çözümler, tercih sırasıyla:

  1. Refactor: @Transactional metodu ayrı bir bean'e taşı veya anotasyonu dışarıdan çağrılan giriş metoduna koy. En temiz çözüm.
  2. Self-injection: Bean'in kendi proxy'sini enjekte et (@Lazy ile veya ObjectProvider<OrderService>), self.saveOrder(...) çağır. Çalışır ama kodu okuyanın kafasını karıştırır.
  3. AopContext.currentProxy(): exposeProxy=true gerektirir, kodu Spring AOP'ye sıkı bağlar; dokümantasyon "highly discouraged" der.
  4. AspectJ weaving (compile-time veya load-time): Advice bytecode'a gömüldüğü için self-invocation sorunu yoktur; ama build ve operasyon karmaşıklığı ekler.

Transaction özelinde programatik bir alternatif de var: TransactionTemplate (bkz. Bölüm 8).

Aynı kurallar @Async, @Cacheable, @Retryable, @PreAuthorize, @Validated gibi proxy tabanlı tüm anotasyonlar için geçerlidir.

Event'ler: @EventListener ve @TransactionalEventListener

ApplicationEventPublisher, bean'lerin birbirini doğrudan tanımadan haberleşmesini sağlar. Event herhangi bir nesne (genelde bir record) olabilir:

java
public record OrderPlaced(UUID orderId, long sellerId, BigDecimal total) {}

@Service
public class OrderService {
    private final OrderRepository orderRepository;
    private final ApplicationEventPublisher events;

    public OrderService(OrderRepository orderRepository, ApplicationEventPublisher events) {
        this.orderRepository = orderRepository;
        this.events = events;
    }

    @Transactional
    public UUID place(PlaceOrderCommand cmd) {
        Order order = orderRepository.save(Order.from(cmd));
        events.publishEvent(new OrderPlaced(order.getId(), order.getSellerId(), order.getTotal()));
        return order.getId();
    }
}

@EventListener varsayılan olarak senkrondur: listener, publishEvent çağrısının içinde, aynı thread'de ve dolayısıyla aynı transaction'da çalışır. Listener exception fırlatırsa exception yayınlayana kadar çıkar ve transaction'ı geri aldırabilir. Bu, çoğu geliştiricinin beklemediği bir bağlılıktır: "decoupling" sadece derleme zamanındadır.

@Async eklenirse listener ayrı bir thread'de çalışır; yayınlayanın transaction'ını görmez, exception'ı yayınlayana ulaşmaz (AsyncUncaughtExceptionHandler'a gider).

@TransactionalEventListener event'i yayınlandığı anda değil, transaction'ın belirli bir fazında teslim eder:

FazNe zaman
AFTER_COMMIT (varsayılan)Transaction başarıyla commit olduktan sonra
AFTER_ROLLBACKRollback sonrası
AFTER_COMPLETIONCommit veya rollback, hangisi olursa
BEFORE_COMMITCommit'ten hemen önce, transaction içinde
java
@Component
public class OrderNotificationListener {

    private final NotificationClient notificationClient;

    public OrderNotificationListener(NotificationClient notificationClient) {
        this.notificationClient = notificationClient;
    }

    @TransactionalEventListener   // AFTER_COMMIT
    public void on(OrderPlaced event) {
        notificationClient.sendOrderConfirmation(event.orderId());   // sadece commit olmuş sipariş için
    }
}

Bilinmesi gereken dört kural:

  1. Aktif transaction yoksa listener hiç çağrılmaz. @Transactional olmayan bir metottan yayınlanan event sessizce kaybolur. fallbackExecution = true bu durumda listener'ı hemen çalıştırır.
  2. AFTER_COMMIT içinde veritabanına yazmak için REQUIRES_NEW gerekir. Spring'in TransactionSynchronization.afterCommit() dokümantasyonu şunu söyler: transaction commit olmuştur ama kaynaklar hâlâ bağlıdır; bu noktada tetiklenen veri erişimi orijinal transaction'a "katılır" ve artık commit gelmeyecektir. Yazdığın her şey sessizce kaybolur. Bu yüzden listener'daki yazma işlemi Propagation.REQUIRES_NEW ile ayrı bir transaction'da yapılmalıdır.
  3. Spring Framework 6.1+ bu hatayı açılışta yakalar: @TransactionalEventListener metodu @Transactional ise yalnızca REQUIRES_NEW veya NOT_SUPPORTED propagation'a izin verilir, aksi halde context açılmaz.
  4. Event'ler bellekte yaşar. Commit oldu, listener çalışmadan pod öldü: bildirim hiç gitmez. Garanti gereken entegrasyonlarda (başka servise event yollamak) çözüm Transactional Outbox'tır (bkz. Bölüm 13). Spring Modulith'in event publication registry'si de event'leri kalıcı olarak kaydederek bu boşluğu kapatmaya yönelik bir alternatiftir.

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

j20: IoC container ve dependency injection

Senaryo: IyzicoGateway ve StripeGateway ikisi de PaymentGateway implementasyonu ve @Component. CheckoutService constructor'ında tek bir PaymentGateway istiyor. Hangi ifadeler doğru?

Doğru cevaplar: a, b, d, e.

  • a) IoC, kontrolün container'a geçmesidir; DI bunun uygulanış biçimidir. Doğru. Sınıf bağımlılığını new ile kendisi kurmaz, dışarıdan alır. IoC daha geniş bir prensiptir; DI onun bağımlılıklara uygulanmış halidir.
  • b) Constructor injection tercih edilir: final alanlar, eksiksiz nesne, testte new. Doğru. Ek olarak: bağımlılık sayısı görünür olur ve constructor döngüleri açılışta yakalanır. Tek constructor varsa @Autowired gerekmez (Spring 4.3+).
  • d) Kendi sınıfların @Component + scan, üçüncü parti sınıflar @Bean ile. Doğru. Kaynak koduna anotasyon ekleyemediğin sınıflar için tek yol @Bean (veya programatik kayıt).
  • e) @Configuration'da @Bean metodundan diğerini çağırmak aynı singleton'ı döner; @Component içindeki @Bean (lite mode) her çağrıda yeni nesne üretir. Doğru. Full mode'da CGLIB alt sınıfı metot çağrısını container'a yönlendirir. Lite mode'da metot çağrısı düz Java çağrısıdır; metot new ile nesne oluşturuyorsa her çağrıda yeni nesne çıkar.

Yanlışlar:

  • c) Spring aynı tipte iki bean'den birini rastgele seçer. Yanlış. NoUniqueBeanDefinitionException ile context açılmaz. Çözüm: @Primary, @Fallback (6.2+), @Qualifier, List/Map enjeksiyonu. Quiz'deki açıklamada sayılan "parametre adının bean adıyla eşleşmesi" de bir çözümdür, ama derleyicinin -parameters bayrağıyla çalışmasına bağlıdır; Spring Boot'un build eklentileri bunu açar, düz projede açık olmayabilir. Bu yüzden ona güvenmek yerine @Qualifier yazmak daha sağlamdır.
  • f) Singleton'lar varsayılan olarak lazy oluşturulur. Yanlış. ApplicationContext singleton'ları refresh sırasında eager oluşturur; hatalı konfigürasyon açılışta patlar. @Lazy veya Spring Boot'un spring.main.lazy-initialization=true ayarı bunu değiştirir (bedeli aşağıda, "Sorulmadı ama sorulabilir" kısmında).

Ana kavram: Container'ın bean seçimi deterministiktir ve belirsizlik bir hatadır. Production'da bu, "yanlış ödeme sağlayıcısına istek gitti" türünden sessiz hataların açılışta engellenmesi demektir. Çalışma anında seçim gerekiyorsa Strategy + List enjeksiyonu (konu anlatımındaki PaymentRouter) doğru kalıptır.

j21: Prototype sepet herkese ortak oldu

Senaryo: ShoppingCart @Scope("prototype"), içinde ArrayList var. Singleton CheckoutService sepeti constructor ile alıyor ve add metodunda listeye ekliyor.

Doğru cevap: b. CheckoutService singleton olduğu için enjeksiyon bir kez yapılır; bir tane ShoppingCart üretilir ve tüm kullanıcılar aynı sepeti paylaşır. Üstelik ArrayList thread-safe değildir; eşzamanlı add çağrıları elemanların kaybolmasına, ArrayIndexOutOfBoundsException'a veya iç dizinin tutarsız kalmasına yol açabilir. Hem yanlış veri (başkasının ürünü sepette) hem bozuk veri riski var.

Yanlışlar:

  • a) Her HTTP isteği yeni sepet alır. Prototype "her enjeksiyonda veya her getBean'de yeni" demektir, "her istekte" veya "her metot çağrısında" değil. Enjeksiyon bir kez oldu.
  • c) Uygulama açılmaz. Singleton'a prototype enjekte etmek tamamen geçerlidir ve sessizce çalışır. Tehlikesi de bu sessizlik.
  • d) Spring prototype'ı her metot çağrısında proxy ile yeniler. Bu ancak proxyMode = ScopedProxyMode.TARGET_CLASS açıkça verilirse olur ve o zaman da her metot çağrısı ayrı bir sepete gider: add bir sepete, items() başka bir sepete. O da yanlış.

Ana kavram: Scope, nesnenin ne zaman üretileceğini söyler; enjeksiyon ise üretilen nesneyi bir kez alıp tutar. Kısa ömürlü bir nesneyi uzun ömürlü bir nesnede tutmak, kısa ömürlüyü fiilen uzun ömürlü yapar.

Daha iyi çözüm: Sepet bir bean değil, kullanıcıya ait veridir:

java
public record CartItem(long productId, int quantity, BigDecimal unitPrice) {}

@Service
public class CartService {

    private final CartRepository cartRepository;   // Redis veya DB, userId anahtarlı

    public CartService(CartRepository cartRepository) {
        this.cartRepository = cartRepository;
    }

    public Cart addItem(long userId, CartItem item) {
        Cart cart = cartRepository.findByUserId(userId).orElseGet(() -> Cart.empty(userId));
        Cart updated = cart.with(item);   // immutable Cart, yeni nesne döner
        cartRepository.save(updated);
        return updated;
    }
}

CartService stateless'tır; hangi pod'a gidersen git aynı sepeti görürsün. Aynı kullanıcının iki sekmeden eşzamanlı ekleme yapması ise artık veri katmanının işidir (Redis'te atomik hash işlemleri veya DB'de optimistic locking, bkz. Bölüm 8).

j22: Bean yaşam döngüsü ve proxy'nin doğduğu an

Senaryo: CatalogWarmup bean'i @PostConstruct init() içinden aynı sınıftaki @Transactional(readOnly = true) loadAll() metodunu çağırıyor.

Doğru cevaplar: a, b, c, f.

  • a) Sıra: constructor → enjeksiyon → BPP before → @PostConstruct → BPP after (proxy burada) → kullanım → @PreDestroy. Doğru. Daha hassas bir okuma: @PostConstruct'ı çağıran CommonAnnotationBeanPostProcessor'dır ve bunu kendi "before" kancasında yapar; yani @PostConstruct "BPP before" aşamasının içinde çalışır. Ardından afterPropertiesSet ve initMethod gelir. Pratik sonuç aynı: proxy, tüm init callback'lerinden sonra oluşur.
  • b) init() içindeki loadAll() çağrısında @Transactional devreye girmez. Doğru, iki bağımsız sebeple: çağrı this üzerinden yapılıyor (self-invocation) ve proxy henüz oluşmamış. Transaction yokken lazy ilişkilere erişim LazyInitializationException verebilir; readOnly optimizasyonları da uygulanmaz.
  • c) ApplicationReadyEvent veya ApplicationRunner ile işi başka bir bean'in transactional metoduna yaptırmak uygundur. Doğru. Context hazırdır, proxy'ler oluşmuştur, çağrı başka bir bean üzerinden yapıldığı için proxy'den geçer. Ek bilgi: Spring Boot readiness'ı ancak runner'lar bittikten sonra ACCEPTING_TRAFFIC yapar; ApplicationRunner içinde senkron yapılan warm-up bitene kadar pod trafik almaz. Spring Boot dokümantasyonu da açılış görevleri için @PostConstruct yerine runner'ları önerir.
  • f) Constructor injection ile A→B→A döngüsü Boot 2.6+ varsayılanında açılışta hata verir. Doğru, ama ifade eksik bir izlenim bırakıyor: constructor döngüleri her Spring sürümünde hata verir, çünkü hiçbir nesne diğeri olmadan oluşturulamaz. Boot 2.6'nın değiştirdiği şey, field ve setter döngülerinin de varsayılan olarak yasaklanmasıdır (spring.main.allow-circular-references=false). Bu property'yi true yapmak constructor döngülerini çözmez.

Yanlışlar:

  • d) Spring prototype bean'ler için de @PreDestroy çağırır. Yanlış. Container prototype'ı oluşturup teslim eder, referans tutmaz; yok edilişi yönetmez.
  • e) BeanFactoryPostProcessor bean örnekleri oluşturulduktan sonra onları değiştirir. Yanlış. BFPP tanımlar üzerinde, örnekler oluşmadan çalışır. Örnekleri işleyen BeanPostProcessor'dır.

Ana kavram: AOP davranışı bir proxy'ye bağlıdır ve proxy yaşam döngüsünün en sonunda doğar. Yaşam döngüsü callback'leri ham nesne üzerinde çalışır. Production karşılığı: açılış işleri yanlış yerde yapıldığında transaction'sız, yarım cache'le veya readiness'ı erken açılmış bir pod ile trafiğe çıkılır.

q7: Kullanıcılar birbirinin indirimini görüyor

Senaryo: PriceCalculator @Component, private BigDecimal discount alanına her hesaplamada yazıyor. Kampanya trafiğinde müşteriler başkasının indirimini görüyor, düşük trafikte sorun yok.

Doğru cevap: b. Bean singleton'dır; discount alanı tüm istek thread'leri arasında paylaşılır. A thread'i alana yazar, B thread'i araya girip kendi değerini yazar, A çıkarma işleminde B'nin indirimini kullanır. Çözüm: alanı kaldırıp local değişken kullanmak.

Yanlışlar:

  • a) Spring her istek için yeni nesne oluşturur, sorun JIT'ten. Varsayılan scope singleton'dır. JIT optimizasyonları tek thread'li programın anlamını değiştirmez; buradaki sorun çok thread'li paylaşılan durumdur.
  • c) BigDecimal mutable olduğu için subtract paylaşılan değeri değiştirir. BigDecimal immutable'dır; subtract yeni nesne döner. Paylaşılan olan BigDecimal nesnesi değil, ona işaret eden alandır.
  • d) @Service thread-safe'tir. @Service sadece semantik bir stereotype'tır; scope'u veya eşzamanlılık davranışını değiştirmez.

Neden düşük trafikte görünmüyor? Race window üç satırlık bir aralıktır (nanosaniyeler). İki isteğin tam bu aralıkta çakışma olasılığı eşzamanlı istek sayısıyla artar. Bu tür hatalar log'da exception üretmez; ancak müşteri şikayetiyle veya fiyat tutarlılığı kontrolleriyle (sepet toplamını bağımsız yeniden hesaplayan bir doğrulama, sipariş anında fiyat snapshot'ı karşılaştırması) yakalanır.

Production'da yakalamak için: Singleton bean'lerde final olmayan alanları yasaklayan bir ArchUnit kuralı yazılabilir:

java
@AnalyzeClasses(packages = "com.example.checkout")
class StatelessBeanRules {

    @ArchTest
    static final ArchRule servicesHaveOnlyFinalFields =
            fields().that().areDeclaredInClassesThat().areMetaAnnotatedWith(Component.class)   // @Service, @Component, ...
                    .should().beFinal()
                    .because("singleton bean'ler istekler arasında paylaşılır; mutable alan race condition demektir");
}

Üç seviyede cevap

Soru: "Neden constructor injection kullanıyorsun? Field injection'ın nesi kötü?"

Constructor injection best practice olduğu için kullanıyorum. Field injection'da @Autowired yazınca Spring alanı dolduruyor, ama test yazmak zorlaşıyor. Constructor injection'da bağımlılıkları constructor'dan veriyorum, IntelliJ de field injection için uyarı veriyor.

Constructor injection ile alanlar final olur, nesne eksik bağımlılıkla oluşamaz ve testte Spring context'i kaldırmadan new OrderService(mockRepo, fixedClock) diye kurabilirim. Field injection'da Spring önce boş nesneyi oluşturur, sonra reflection ile alanları doldurur; Spring dışında new dersem alanlar null kalır. Ayrıca constructor döngüleri açılışta hata verir, field injection'da Spring bunları erken referansla çözebiliyordu. Opsiyonel bağımlılıklar için ObjectProvider kullanırım.

Üç gerekçem var: doğruluk, tasarım geri bildirimi, test. Doğruluk: final alanlar constructor bittiğinde tüm thread'lere doğru görünür (JMM'nin final field garantisi), nesne hiçbir an yarım durmaz. Field injection'da nesne önce boş oluşur; bir @PostConstruct veya BPP sırası hatası, hatta Spring dışında new ile oluşturma null bağımlılıkla çalışmaya yol açar. Tasarım: constructor'ın 7 parametreye çıkması bana sınıfın bölünmesi gerektiğini söyler; field injection bu sinyali gizler. Döngüsel bağımlılık da constructor'la açılışta patlar; Boot 2.6'dan beri field döngüleri de varsayılan yasak, ama ben döngüyü zaten ortak mantığı üçüncü bir sınıfa çıkararak veya event ile kırarım, allow-circular-references açmam. Test: unit testte context yok, Mockito'nun @InjectMocks sihri yok, ne enjekte ettiğim derleyici tarafından kontrol ediliyor. İstisnalar: test sınıflarında @Autowired alan kabul edilebilir; framework'ün instantiate ettiği ve constructor'ı kontrol edemediğim nadir durumlarda setter kullanırım. Bir ekipte bunu kural haline getirmek için ArchUnit ile "servis sınıflarında @Autowired alan olmasın, tüm alanlar final olsun" kuralı koyduk; q7'deki gibi singleton'da state tutma hatalarını da aynı kural yakaladı.

Junior "best practice" ve "IDE uyarıyor" diyor; nedeni yok. Mid mekanizmayı biliyor (boş nesne + reflection, final, test) ama gerekçeleri liste olarak sayıyor. Senior bir adım öteye gidiyor: final alanın eşzamanlılık garantisi, constructor boyutunun bir tasarım metriği olması, döngüyü teknik olarak değil tasarımla kırmak ve kuralın ekip ölçeğinde nasıl uygulatılacağı (ArchUnit). Ayrıca "ne zaman kullanmam" istisnasını da veriyor.

Takip sorusu: "Constructor'ında 9 bağımlılık olan bir servisi devraldın. Ne yaparsın?"

Soru: "Spring bean'leri thread-safe mi?"

Evet, Spring bean'leri singleton olduğu için thread-safe'tir. Spring her şeyi kendisi yönettiği için eşzamanlılık sorunu olmaz.

Hayır, Spring thread-safety garantisi vermez. Singleton bean tüm istek thread'leri tarafından paylaşılır, bu yüzden mutable alan tutarsa race condition oluşur. Servisleri stateless tutarım: istek verisi parametre ve local değişkenlerde, alanlar sadece final bağımlılıklar. Prototype veya request scope kullanabilirim ama genelde gerek olmaz.

Container sadece nesne sayısını ve ömrünü yönetir; eşzamanlılık bean'i yazanın sorumluluğu. Singleton bir bean'i Tomcat'in 200 thread'i veya virtual thread'lerle binlerce görev aynı anda kullanır. Kuralım: singleton'da sadece final bağımlılıklar, immutable konfigürasyon ve bilinçli seçilmiş thread-safe yapılar (ConcurrentHashMap, Caffeine, LongAdder) olur. Paylaşılan bir sayaç gerekiyorsa bile JVM içi state'in 20 pod'da 20 ayrı kopya olduğunu hatırlarım; global sınır (rate limit, stok) Redis veya DB'de tutulur. Bağlam taşımak için ThreadLocal kullanıyorsam havuzlu thread'lerde finally içinde temizlerim, yoksa bir sonraki istek öncekinin tenant'ını görür. synchronized eklemek doğruluğu sağlar ama metodu tek şeride indirir; Java 21'de virtual thread'leri carrier'a pin'leyebilir (Java 24'te JEP 491 ile çözüldü). Bu hataları testte yakalamak zor; bu yüzden statik kural (ArchUnit) ve kritik hesaplamalarda bağımsız tutarlılık kontrolü (sipariş toplamını yeniden hesaplayıp karşılaştıran bir metrik) koyarım.

Junior'ın cevabı yaygın ve tehlikeli bir yanlış inanç: "singleton = thread-safe". Aslında tam tersi: singleton paylaşıldığı için risklidir. Mid doğru kuralı biliyor. Senior kuralın sınırlarını çiziyor: JVM içi state ile dağıtık state farkı, ThreadLocal sızıntısı, synchronized'ın throughput ve virtual thread maliyeti, tespit stratejisi.

Takip sorusu: "Bir servisin içinde ürün kategorilerini bellekte cache'lemek istiyorsun. Nasıl yaparsın, güncellemeyi nasıl yönetirsin?"

Soru: "Aynı sınıftaki @Transactional metodu çağırınca neden transaction açılmıyor?"

Spring'de aynı sınıftan çağrılan metotlarda @Transactional çalışmıyor. Bu yüzden metodu başka bir servise taşımak gerekiyor.

@Transactional proxy ile çalışır. Spring bean'in CGLIB alt sınıfını üretip dışarıya onu verir; proxy çağrıyı yakalayıp transaction açar. Ama sınıfın içinden this.save() dediğimde çağrı proxy'ye değil gerçek nesneye gider, interceptor çalışmaz. Aynı sebeple private metotlarda da çalışmaz. Çözüm metodu ayrı bir bean'e taşımak veya anotasyonu dışarıdan çağrılan metoda koymak.

Spring AOP proxy tabanlı: postProcessAfterInitialization aşamasında AbstractAutoProxyCreator bean'i bir proxy ile sarar; Boot'ta bu varsayılan CGLIB alt sınıfıdır. Proxy sadece kendisine gelen dış çağrıyı yakalar, this her zaman ham nesnedir. Aynı mekanizmadan dört ayrı tuzak çıkar: self-invocation, private metot (override edilemez; 6.0'dan beri protected ve package-private çalışıyor), final metot (yakalanmaz, üstelik Objenesis ile oluşturulan proxy'nin alanları boş olduğu için NPE verebilir) ve @PostConstruct (proxy henüz yok). Tercihim önce tasarım: transaction sınırı use-case'in giriş metodudur, iç adımlar o transaction'a katılır. Gerçekten ayrı bir transaction gerekiyorsa (ör. her durumda kalması gereken audit) onu ayrı bir bean'e alırım. Tek bir blok için programatik kontrol gerekiyorsa TransactionTemplate kullanırım; self-injection ve AopContext.currentProxy() okunurluğu bozar. Bu hatayı yakalamak için mock'lu unit test yetmez; Testcontainers ile gerçek DB'de "exception sonrası satır sayısı sıfır mı" diye bakan entegrasyon testi yazarım. Bunu bir projede, geçersiz siparişin DB'de kaldığını gösteren testle kanıtladık: self-invocation yüzünden her save kendi mini transaction'ında commit olmuştu.

Junior kuralı ezberlemiş ama nedenini bilmiyor; benzer bir tuzakla (@Async, final metot) karşılaşınca tanıyamaz. Mid proxy'yi biliyor. Senior proxy'nin hangi aşamada ve hangi bileşen tarafından oluşturulduğunu, aynı mekanizmadan doğan diğer tuzakları, çözümler arasındaki tercihi ve hatanın nasıl test edileceğini söylüyor; somut bir deneyimle bitiriyor.

Takip sorusu: "Bu servis bir interface implemente etseydi ve Boot kullanmasaydık hangi proxy oluşurdu? Somut sınıfı enjekte eden kod ne olurdu?"

Soru: "Bean yaşam döngüsünü anlat. Açılışta cache ısıtma işini nereye koyarsın?"

Bean önce oluşturulur, sonra @PostConstruct çalışır, uygulama kapanırken @PreDestroy çalışır. Cache ısıtmayı @PostConstruct içinde yaparım çünkü bean hazır olduğunda çalışıyor.

Sıra: tanımlar toplanır, BeanFactoryPostProcessor'lar çalışır, constructor, alan enjeksiyonu, Aware arayüzleri, BeanPostProcessor before, @PostConstruct, afterPropertiesSet, init-method, BeanPostProcessor after. AOP proxy son adımda oluşur. Bu yüzden @PostConstruct içinde @Transactional metot çağırırsam transaction açılmaz. Cache ısıtmayı ApplicationReadyEvent dinleyen bir bean'den yaparım.

Önce tanım aşaması: ConfigurationClassPostProcessor scan ve @Bean'leri tanıma çevirir, diğer BFPP'ler (placeholder çözümü) tanımları değiştirir, örnek yok. Sonra her singleton için constructor, populate, Aware, BPP before (context Aware'leri ve @PostConstruct aslında burada çalışır), afterPropertiesSet, init-method ve BPP after; proxy burada doğar ve container o andan sonra herkese proxy'yi verir. Init callback'leri ham nesne üzerinde ve singleton oluşturma kilidi içinde çalışır; dokümantasyon da ağır işi oraya koymamayı söyler. Cache ısıtmayı ayrı bir CatalogLoader bean'inin transactional metodunu çağıran bir ApplicationRunner'a koyarım: proxy'ler hazır ve Boot readiness'ı runner'lar bitmeden ACCEPTING_TRAFFIC yapmaz, yani pod soğuk cache'le trafik almaz. Uzun sürebilecek bir ısıtmada Kubernetes'in startup probe süresini buna göre ayarlarım, ısıtmanın süresini metrik olarak yayınlarım ve kısmi başarısızlıkta ne yapılacağına karar veririm: kritik cache ise açılışı durdur, değilse lazy doldurmaya düş. Kapanış tarafında da SmartLifecycle fazlarını (yüksek faz önce durur) ve @PreDestroy sırasını bilirim: Kafka listener container'ları elindeki mesajı bitirip offset'i commit ettikten sonra durmalı, DB havuzu gibi bağımlılıklar ise lifecycle bean'leri durduktan sonra, singleton destroy aşamasında kapanır.

Junior'ın cevabı en yaygın production hatasını içeriyor: ağır ve transactional işi @PostConstruct'a koymak. Mid sırayı ve proxy sonucunu biliyor, doğru yere yakın bir öneri veriyor. Senior tanım ve örnek aşamalarını ayırıyor, hangi callback'in hangi bileşen tarafından çağrıldığını biliyor, kararı Kubernetes readiness ile bağlıyor ve başarısızlık senaryosunu düşünüyor.

Takip sorusu: "Isıtma 3 dakika sürüyor ve liveness probe 60 saniyede pod'u öldürüyor. Ne yaparsın?"

Sorulmadı ama sorulabilir

@Lazy ve global lazy initialization

@Lazy bir bean'in ilk kullanıldığı ana kadar oluşturulmamasını sağlar. Enjeksiyon noktasında kullanıldığında gerçek bean yerine tembel bir proxy enjekte edilir. Spring Boot'ta spring.main.lazy-initialization=true tüm bean'leri tembel yapar ve açılışı hızlandırır.

Bedeli Spring Boot dokümantasyonunda açıkça yazar: hatalı yapılandırılmış bir bean açılışta değil ilk kullanımda patlar; ilk istek yavaşlar (web bean'leri ilk HTTP isteğinde oluşur); bellek ihtiyacı açılışta görünmez, sonradan ortaya çıkar. Production'da genellikle kapalı tutulur; lokal geliştirmede veya CLI benzeri uygulamalarda işe yarar. Belirli bir bean'i eager tutmak için @Lazy(false) kullanılır.

Mülakatta tek cümle: "Lazy init açılışı hızlandırır ama hatayı açılıştan çalışma anına taşır; production servislerinde fail-fast'i tercih ederim."

FactoryBean

FactoryBean<T>, kendisi bir bean olan ama container'a başka bir nesne (T) üreten özel bir arayüzdür. getBean("x") çağrıldığında FactoryBean'in kendisi değil getObject() sonucu döner; factory'nin kendisine &x adıyla erişilir. Spring Data repository'leri (JpaRepositoryFactoryBean) ve LocalContainerEntityManagerFactoryBean bu mekanizmayla çalışır: sen sadece bir interface yazarsın, FactoryBean runtime'da implementasyonunu (bir proxy) üretir.

Mülakatta tek cümle: "Spring Data'nın implementasyonu olmayan repository interface'lerini bean'e çeviren mekanizma FactoryBean + proxy'dir."

@Async nasıl çalışır ve tuzakları

@EnableAsync ile @Async metotlar bir proxy üzerinden TaskExecutor'a gönderilir; çağıran hemen döner. @Transactional ile aynı proxy kuralları geçerlidir: self-invocation ve private metotta çalışmaz. Ek tuzaklar:

  • Transaction context, SecurityContext, MDC gibi ThreadLocal tabanlı bağlamlar yeni thread'e taşınmaz. Çağıranın transaction'ı commit olmadan async metot o veriyi okumaya çalışırsa bulamaz.
  • void dönen async metodun exception'ı çağırana ulaşmaz; AsyncUncaughtExceptionHandler'a gider. Sonucu izlemek için CompletableFuture<T> dön.
  • Spring Boot'un auto-configure ettiği applicationTaskExecutor varsayılan olarak az sayıda çekirdek thread'li ve sınırsız kuyruklu bir ThreadPoolTaskExecutor'dır (spring.task.execution.pool.* ile ayarlanır); Boot 3.2+'da spring.threads.virtual.enabled=true ile virtual thread kullanan bir executor'a geçer. Kuyruk sınırı bilinçli seçilmezse yük altında görevler bellekte birikir.

Mülakatta tek cümle: "@Async de bir proxy'dir; thread değiştiği için transaction ve güvenlik bağlamı taşınmaz, exception'lar çağırana ulaşmaz."

@Conditional, @Profile ve ortam bazlı bean seçimi

@Profile("!prod") ile sahte ödeme gateway'ini sadece lokal ve test ortamında açabilirsin; profil ifadeleri !, & ve | destekler. @Profile aslında @Conditional(ProfileCondition.class)'ın kısayoludur. Spring Boot'un @ConditionalOnProperty, @ConditionalOnClass, @ConditionalOnMissingBean anotasyonları da aynı Condition mekanizmasının üzerine kuruludur (bkz. Bölüm 7).

Tuzak: profillerle iş mantığını dallandırmak (if prod then ...). Profiller altyapı seçimi içindir (gerçek veya sahte client, farklı datasource); aynı imajın her ortamda aynı kodu çalıştırması gerekir.

Mülakatta tek cümle: "@Profile bir @Conditional'dır; altyapıyı ortama göre seçmek için kullanırım, iş kuralını dallandırmak için değil."

BeanPostProcessor ile kendi davranışını eklemek

Örneğin @ExternalCall anotasyonlu metotları zamanlayıp Micrometer'a yazan bir mekanizma istiyorsun. Spring AOP ile bir @Aspect yazmak en kısa yoldur; bu da arka planda bir BPP'nin proxy oluşturmasıyla çalışır:

java
@Aspect
@Component
public class ExternalCallTimingAspect {

    private final MeterRegistry registry;

    public ExternalCallTimingAspect(MeterRegistry registry) {
        this.registry = registry;
    }

    @Around("@annotation(externalCall)")
    public Object time(ProceedingJoinPoint pjp, ExternalCall externalCall) throws Throwable {
        Timer.Sample sample = Timer.start(registry);
        String outcome = "success";
        try {
            return pjp.proceed();
        } catch (Throwable t) {
            outcome = "error";
            throw t;
        } finally {
            sample.stop(registry.timer("external.call", "target", externalCall.value(), "outcome", outcome));
        }
    }
}

Birden fazla aspect aynı metoda uygulanıyorsa sıra @Order ile belirlenir; @Transactional'ın interceptor'ıyla göreli sırası (ör. retry transaction'ın dışında mı içinde mi) davranışı değiştirir. Retry'ın her denemede yeni transaction açması için retry interceptor'ı transaction interceptor'ının dışında olmalıdır.

Mülakatta tek cümle: "Kendi cross-cutting davranışımı @Aspect ile eklerim; birden çok aspect varsa sırayı @Order ile, özellikle retry ile transaction arasındaki sırayı bilinçli belirlerim."

Test context caching

Spring TestContext framework, aynı konfigürasyona sahip testler için ApplicationContext'i önbelleğe alır ve tekrar kullanır. Her farklı @MockitoBean (Spring Framework 6.2+; Boot 3.4 ile gelir, Boot 4'te eski @MockBean kaldırıldı) kombinasyonu, farklı @ActiveProfiles veya properties yeni bir context anahtarı oluşturur ve context sıfırdan açılır. 300 entegrasyon testinin 40 ayrı context açması CI süresini dakikalarca uzatır. @DirtiesContext de cache'i bilinçli olarak bozar.

Mülakatta tek cümle: "Entegrasyon testlerini ortak bir taban sınıfta aynı konfigürasyonla toplarım ki Spring context'i bir kez açılıp paylaşılsın."

Sık yapılan hatalar

  • Singleton serviste istek verisini alanda tutmak → alan tüm thread'lerce paylaşılır, yük altında veri karışır → istek verisini parametre ve local değişkende taşı, alanları final bağımlılıklarla sınırla.
  • "@Service / singleton thread-safe'tir" sanmak → stereotype ve scope eşzamanlılık garantisi vermez → thread-safety'yi bean'in tasarımıyla sağla.
  • Prototype bean'i singleton'a doğrudan enjekte etmek → enjeksiyon bir kez yapılır, prototype fiilen singleton olur → ObjectProvider.getObject(), @Lookup veya request için scoped proxy kullan; kullanıcı verisini Redis/DB'ye taşı.
  • @PostConstruct içinde transactional veya ağır iş yapmak → proxy henüz yok, init singleton kilidinin içinde → ApplicationRunner ve ayrı bir transactional bean kullan.
  • Self-invocation ile @Transactional/@Async/@Cacheable beklemek → this çağrısı proxy'yi atlar → anotasyonu giriş metoduna koy veya metodu ayrı bean'e taşı.
  • private veya final metoda proxy tabanlı anotasyon koymak → override edilemez, sessizce yok sayılır; final metotta NPE bile çıkabilir → public (veya Spring 6+ için protected/package-private), final olmayan metot kullan.
  • proxyBeanMethods = false yapıp @Bean metotlarını birbirinden çağırmaya devam etmek → her çağrı yeni nesne üretir → bağımlılığı metot parametresiyle al.
  • Döngüsel bağımlılığı allow-circular-references=true veya @Lazy ile "çözmek" → tasarım sorunu gizlenir → ortak mantığı ayrı sınıfa çıkar veya event kullan.
  • Aynı tipte iki bean'de parametre adına güvenmek → -parameters olmadan veya isim değişince kırılır → @Qualifier veya domain anahtarlı Map registry kullan.
  • @TransactionalEventListener içinde REQUIRES_NEW olmadan DB'ye yazmak → commit sonrası yazılan veri sessizce kaybolur → @Transactional(propagation = REQUIRES_NEW) kullan (Spring 6.1+ bunu zaten zorunlu kılar).
  • @TransactionalEventListener'ı transaction dışından yayınlanan event için kullanmak → listener hiç çağrılmaz → yayınlayan metodun transactional olduğundan emin ol veya fallbackExecution = true.
  • Bellek içi event'lere servisler arası garanti yüklemek → pod ölürse event kaybolur → Transactional Outbox (bkz. Bölüm 13).
  • BeanPostProcessor'a servis bean'i enjekte etmek → o servis erken oluşur ve proxy'lenmez → BPP'leri static @Bean ve bağımlılıksız tanımla.

Hızlı tekrar

S: IoC ile DI arasındaki fark nedir?

C: IoC, nesne oluşturma ve yaşam döngüsü kontrolünün framework'e geçmesi prensibidir; DI bu prensibin bağımlılıkların dışarıdan (constructor/setter) verilmesiyle uygulanmasıdır.

S: Constructor injection'ın field injection'a üç somut üstünlüğü nedir?

C: Alanlar final olur (immutability ve güvenli yayın), nesne eksik bağımlılıkla oluşamaz, testte Spring olmadan new ile kurulur. Ek olarak bağımlılık sayısı görünür ve constructor döngüleri açılışta yakalanır.

S: Aynı tipte iki bean varken tek değerli enjeksiyonda ne olur, nasıl çözülür?

C: NoUniqueBeanDefinitionException ile context açılmaz. @Primary, @Fallback (6.2+), @Qualifier veya tümünü List/Map olarak alıp kendin seçmek.

S: @Configuration full mode ile lite mode farkı nedir?

C: Full mode'da sınıf CGLIB ile alt sınıflanır ve @Bean metotları arası çağrılar container'daki singleton'ı döner. Lite mode'da (@Component içinde veya proxyBeanMethods = false) metot çağrısı düz Java çağrısıdır ve her seferinde yeni nesne üretir; bağımlılık parametreyle alınmalı.

S: Spring Boot 2.6 döngüsel bağımlılıkta neyi değiştirdi?

C: Field/setter döngülerini de varsayılan olarak yasakladı (spring.main.allow-circular-references=false). Constructor döngüleri zaten her sürümde çözülemezdi.

S: Prototype bean'i singleton'a enjekte edersen ne olur?

C: Enjeksiyon bir kez yapıldığı için tek bir prototype örneği oluşur ve singleton'la birlikte yaşar; fiilen singleton olur. Her seferinde yeni nesne için ObjectProvider.getObject() veya @Lookup.

S: Prototype bean'lerde @PreDestroy çağrılır mı?

C: Hayır. Spring prototype'ı oluşturup teslim eder, referans tutmaz; temizlik kullananın sorumluluğundadır.

S: BeanFactoryPostProcessor ile BeanPostProcessor farkı nedir?

C: BFPP bean tanımları (metadata) üzerinde, örnekler oluşmadan çalışır (ör. placeholder çözümü). BPP oluşturulmuş bean örneklerini initialization öncesi ve sonrası işler veya sarar (ör. AOP proxy).

S: AOP proxy'si yaşam döngüsünün hangi adımında oluşur?

C: BeanPostProcessor.postProcessAfterInitialization aşamasında, tüm init callback'lerinden (@PostConstruct, afterPropertiesSet, init-method) sonra.

S: @PostConstruct içinden aynı sınıfın @Transactional metodunu çağırınca neden transaction açılmaz?

C: Çağrı this üzerinden yapılır (self-invocation) ve proxy henüz oluşmamıştır; init callback'leri ham nesne üzerinde çalışır.

S: Spring Boot hangi proxy türünü varsayılan kullanır ve CGLIB neyi proxy'leyemez?

C: Boot varsayılan olarak CGLIB kullanır (spring.aop.proxy-target-class=true). CGLIB private, final ve static metotları yakalayamaz, final sınıfları hiç proxy'leyemez.

S: @EventListener hangi thread ve transaction'da çalışır?

C: Varsayılan olarak senkron: yayınlayanla aynı thread'de ve aynı transaction'da. Exception yayınlayana yayılır.

S: @TransactionalEventListener'ın varsayılan fazı nedir, transaction yoksa ne olur?

C: AFTER_COMMIT. Aktif transaction yoksa listener çağrılmaz; fallbackExecution = true ile hemen çalıştırılabilir.

S: AFTER_COMMIT listener'ında DB'ye yazmak için neden REQUIRES_NEW gerekir?

C: Kaynaklar hâlâ eski transaction'a bağlıdır; yazma ona katılır ama artık commit gelmeyecektir, veri kaybolur. Spring 6.1+ bu yüzden yalnızca REQUIRES_NEW veya NOT_SUPPORTED'a izin verir.

S: Açılışta cache ısıtma işi nereye konur ve neden?

C: Ayrı bir transactional bean'i çağıran ApplicationRunner'a. Proxy'ler hazırdır ve Spring Boot readiness'ı runner'lar bitmeden ACCEPTING_TRAFFIC yapmaz.

Kaynaklar