Bir iş akışında arka arkaya uygulanması gereken kurallar arttıkça kodun tek bir servisin içinde büyümesi kaçınılmaz hale gelebilir. Özellikle validation ağırlıklı sistemlerde bunu çok sık görürüz.

Bir havayolu rezervasyonunu düşünün. Bilet kesilmeden önce:

  • Uçakta yeterli koltuk var mı?
  • Yolcu no-fly listesinde mi?
  • Pasaport geçerli mi?
  • Rezervasyon tutarı belirli bir sınırı aşıyor mu?
  • Promosyon kodu geçerli mi?
  • Vize gerekiyor mu?
  • Fraud kontrolünden geçiyor mu?

İlk başta bunları birkaç if ile kontrol etmek kolaydır. Ama kurallar 5’ten 10’a, 10’dan 20’ye çıktığında BookingService yavaş yavaş dev bir metoda dönüşmeye başlar.

İşte Chain of Responsibility burada devreye girer. Temel fikir:

Her kuralı zincirin bağımsız bir halkası yap ve request’i bu halkalardan sırayla geçir.

Bir kural başarısız olursa zincir durur. Başarılı olursa request bir sonraki kurala geçer.

Senaryo: Uçak bileti rezervasyonu

Rezervasyon akışımızı kabaca şöyle düşünelim:

graph TD
    R[Booking Request] --> F[Flight Check]
    F --> S[Seat Check]
    S --> N[No-Fly Check]
    N --> P[Passport Check]
    P --> A[Total Amount Check]
    A --> C[Promo Check]
    C --> B([Booking Created])

Buradaki her kutu bağımsız bir kural. SeatAvailabilityCheck sadece koltuk sayısıyla ilgileniyor, PassportValidityCheck sadece pasaportla, PromoCodeCheck sadece promosyonla. Birbirlerinin iç işleyişini bilmiyorlar.

🔴 Kötü yaklaşım: Her şeyi BookingService’e koymak

Önce klasik yaklaşımı görelim. Bütün kontrolleri tek bir servise koyarsak:

@Service
public class BookingService {

    public void createBooking(BookingRequest req) {

        // 1. Koltuk müsaitliği
        int available = seatRepository.getAvailableSeats(
                req.flightId(), req.cabinClass());

        if (available < req.passengers().size()) {
            throw new BookingException("Yetersiz koltuk: " + req.flightId());
        }

        // 2. No-fly kontrolü
        for (Passenger p : req.passengers()) {
            if (noFlyListRepository.isBanned(p.identityNumber())) {
                throw new BookingException(
                        "Yolcu uçuş yasaklılar listesinde: " + p.fullName());
            }
        }

        // 3. Pasaport kontrolü
        Flight flight = flightRepository.find(req.flightId());

        if (flight.isInternational()) {
            for (Passenger p : req.passengers()) {

                LocalDate requiredUntil = flight.arrivalDate().plusMonths(6);

                if (p.passportExpiry().isBefore(requiredUntil)) {
                    throw new BookingException(
                            "Pasaport geçerlilik süresi yetersiz: " + p.fullName());
                }
            }
        }

        // 4. Tutar kontrolü
        BigDecimal total = calculateTotal(req);

        if (total.compareTo(new BigDecimal("100000")) > 0) {
            if (!req.customer().isVerified()) {
                throw new BookingException(
                        "100.000 TL üzeri rezervasyon için doğrulanmış hesap gerekli");
            }
        }

        // 5. Promosyon kontrolü
        if (req.promoCode() != null) {
            PromoCode promo = promoRepository.find(req.promoCode());

            if (promo == null || promo.isExpired()) {
                throw new BookingException("Geçersiz promosyon kodu");
            }

            if (!promo.validRoutes().contains(flight.route())) {
                throw new BookingException("Promosyon kodu bu rota için geçerli değil");
            }
        }

        // 6. Bebek/refakatsiz çocuk
        // 7. Vize
        // 8. Fraud
        // 9. ...

        bookingRepository.save(new Booking(...));
    }
}

Kod henüz çok büyük görünmeyebilir. Ama gerçek sistemlerde bu liste burada bitmez. Bir süre sonra:

BookingService
 ├── Seat check
 ├── No-fly check
 ├── Passport check
 ├── Amount check
 ├── Promo check
 ├── Visa check
 ├── Fraud check
 ├── Child passenger check
 ├── Document check
 ├── Country restriction
 └── ...

gibi bir yapı oluşur. Ve BookingService giderek bir God Object haline gelir.

Buradaki asıl problemler

  1. Yeni kural eklemek zorlaşıyor

    Yeni bir ülke için vize kuralı geldiğinde mevcut servise girip yeni bir if eklemeniz gerekiyor. Kural sayısı arttıkça servis büyüyor.

  2. Testler birbirine bağımlı hale geliyor

    Sadece pasaport kontrolünü test etmek istiyorsanız bile BookingService‘in diğer bütün dependency’leriyle uğraşmak zorunda kalabilirsiniz.

  3. Sıralamayı değiştirmek riskli

    Örneğin pasaport kontrolünün uçuş bilgisine ihtiyacı varsa önce uçuşun yüklenmiş olması gerekir. Bu bağımlılıkları büyük bir metodun içinde takip etmek zorlaşır.

  4. Aynı dosyaya herkes dokunuyor

    Bir geliştirici promosyon kontrolü ekliyor, başka biri fraud kontrolü, üçüncü kişi vize kuralını değiştiriyor. Hepsi aynı BookingService üzerinde çalışınca merge conflict ihtimali de artıyor.

🟢 İyi yaklaşım: Chain of Responsibility

Şimdi kuralları bağımsız halkalara bölelim. Her kural aynı interface’i uygulayacak:

public interface BookingCheck {

    void check(BookingContext ctx);
}

Bir kural başarısız olursa exception fırlatacak. Başarılıysa hiçbir şey yapmadan devam edecek.

Aynı koltuk kontrolünün iki hali:

// BookingService'in içinde, diğer beş kuralın arasında
int available = seatRepository.getAvailableSeats(
        req.flightId(), req.cabinClass());

if (available < req.passengers().size()) {
    throw new BookingException("Yetersiz koltuk: " + req.flightId());
}
@Component
@Order(20)
public class SeatAvailabilityCheck implements BookingCheck {

    private final SeatRepository seatRepository;

    public SeatAvailabilityCheck(SeatRepository seatRepository) {
        this.seatRepository = seatRepository;
    }

    @Override
    public void check(BookingContext ctx) {
        int requested = ctx.getRequest().passengers().size();
        int available = seatRepository.getAvailableSeats(
                ctx.getFlight().id(), ctx.getRequest().cabinClass());

        if (available < requested) {
            throw new BookingRejectedException(
                    RejectReason.NO_SEATS, "Seçilen kabinde yeterli koltuk yok");
        }
    }
}

Bu yapının en güzel tarafı, her halkanın kendi sorumluluğuna sahip olması.

1. Context: Halkalar arasında veri taşımak

Bazı kuralların, kendisinden önce çalışan kuralların ürettiği verilere ihtiyacı olabilir:

  • FlightLoadCheck database’den uçuşu bulur.
  • SeatAvailabilityCheck bu uçuşu kullanır.
  • TotalAmountCheck toplam tutarı hesaplar.
  • PromoCodeCheck hem uçuşu hem de toplam tutarı kullanır.

Bu bilgileri her metoda ayrı ayrı geçirmek yerine bir Context nesnesinde tutabiliriz:

public class BookingContext {

    private final BookingRequest request;

    private Flight flight;
    private BigDecimal totalAmount;
    private PromoCode appliedPromo;

    public BookingContext(BookingRequest request) {
        this.request = request;
    }

    // getter / setter
}

Böylece zincirin bir halkası Context’i zenginleştirir, sonraki halka da bunu kullanabilir:

FlightLoadCheck
      │ ctx.setFlight(...)
SeatAvailabilityCheck
TotalAmountCheck
      │ ctx.setTotalAmount(...)
PromoCodeCheck

Bu yüzden Context sadece request’i taşımıyor. Pipeline boyunca üretilen bilgileri de taşıyor.

2. Her kural kendi sınıfında

FlightLoadCheck

@Component
@Order(10)
public class FlightLoadCheck implements BookingCheck {

    private final FlightRepository flightRepository;

    public FlightLoadCheck(FlightRepository flightRepository) {
        this.flightRepository = flightRepository;
    }

    @Override
    public void check(BookingContext ctx) {

        Flight flight = flightRepository
                .find(ctx.getRequest().flightId())
                .orElseThrow(() -> new BookingRejectedException(
                        RejectReason.FLIGHT_NOT_FOUND, "Uçuş bulunamadı"));

        if (flight.departureTime().isBefore(LocalDateTime.now().plusHours(2))) {
            throw new BookingRejectedException(
                    RejectReason.CHECK_IN_CLOSED,
                    "Kalkışa 2 saatten az kaldığı için online satış kapandı");
        }

        ctx.setFlight(flight);
    }
}

Bu sınıfın tek işi uçuşu kontrol etmek. Başka kuralları bilmiyor.

NoFlyListCheck

@Component
@Order(30)
public class NoFlyListCheck implements BookingCheck {

    private final NoFlyListRepository noFlyListRepository;

    public NoFlyListCheck(NoFlyListRepository noFlyListRepository) {
        this.noFlyListRepository = noFlyListRepository;
    }

    @Override
    public void check(BookingContext ctx) {

        for (Passenger passenger : ctx.getRequest().passengers()) {

            if (noFlyListRepository.isBanned(passenger.identityNumber())) {
                throw new BookingRejectedException(
                        RejectReason.NO_FLY_LIST,
                        "Yolcu uçuşa kabul edilemez: " + passenger.fullName());
            }
        }
    }
}

Yukarıdaki SeatAvailabilityCheck‘te dikkat edilmesi gereken bir detay vardı: uçuşu kendisi database’den çekmiyor, önceki halkanın Context’e koyduğu Flight nesnesini kullanıyordu (ctx.getFlight()). Bu da zincirdeki adımlar arasında kontrollü bir veri akışı oluşturuyor.

PassportValidityCheck

@Component
@Order(40)
public class PassportValidityCheck implements BookingCheck {

    @Override
    public void check(BookingContext ctx) {

        if (!ctx.getFlight().isInternational()) {
            return;
        }

        LocalDate requiredUntil = ctx.getFlight().arrivalDate().plusMonths(6);

        for (Passenger passenger : ctx.getRequest().passengers()) {

            if (passenger.passportExpiry().isBefore(requiredUntil)) {
                throw new BookingRejectedException(
                        RejectReason.PASSPORT_EXPIRING,
                        "Pasaportun varıştan itibaren en az 6 ay geçerli olması gerekir: "
                                + passenger.fullName());
            }
        }
    }
}

Burada return kullanılması önemli. Uçuş iç hat ise bu kontrolün yapacağı bir şey yok, dolayısıyla zincirin bir sonraki halkasına geçiyoruz. Bu, Chain of Responsibility’nin doğal davranışlarından biri:

“Bu kural bu request için geçerli değil, devam et.”

3. Bir halka Context’i zenginleştirebilir

Şimdi toplam tutarı hesaplayan kurala bakalım:

@Component
@Order(50)
public class TotalAmountCheck implements BookingCheck {

    private static final BigDecimal THRESHOLD = new BigDecimal("100000");

    private final FareCalculator fareCalculator;

    public TotalAmountCheck(FareCalculator fareCalculator) {
        this.fareCalculator = fareCalculator;
    }

    @Override
    public void check(BookingContext ctx) {

        BigDecimal total = fareCalculator.calculate(
                ctx.getRequest(), ctx.getFlight());

        ctx.setTotalAmount(total);

        if (total.compareTo(THRESHOLD) > 0
                && !ctx.getRequest().customer().isVerified()) {

            throw new BookingRejectedException(
                    RejectReason.VERIFICATION_REQUIRED,
                    "100.000 TL üzeri rezervasyonlar için doğrulanmış hesap gerekli");
        }
    }
}

Burada iki iş yapıyoruz: toplam tutarı hesaplıyoruz ve hesaplanan tutarı Context’e koyuyoruz. Sonraki halkalar artık ctx.getTotalAmount() ile bunu kullanabilir.

4. PromoCodeCheck

Şimdi önceki halkaların ürettiği verileri kullanan bir kural yazalım:

@Component
@Order(60)
public class PromoCodeCheck implements BookingCheck {

    private final PromoRepository promoRepository;

    public PromoCodeCheck(PromoRepository promoRepository) {
        this.promoRepository = promoRepository;
    }

    @Override
    public void check(BookingContext ctx) {

        String code = ctx.getRequest().promoCode();

        if (code == null) {
            return;
        }

        PromoCode promo = promoRepository
                .find(code)
                .filter(p -> !p.isExpired())
                .orElseThrow(() -> new BookingRejectedException(
                        RejectReason.INVALID_PROMO, "Geçersiz promosyon kodu"));

        if (!promo.validRoutes().contains(ctx.getFlight().route())) {
            throw new BookingRejectedException(
                    RejectReason.PROMO_ROUTE_MISMATCH,
                    "Promosyon kodu bu rota için geçerli değil");
        }

        if (promo.minAmount().compareTo(ctx.getTotalAmount()) > 0) {
            throw new BookingRejectedException(
                    RejectReason.PROMO_MIN_NOT_MET,
                    "Promosyon için minimum bilet tutarı karşılanmıyor");
        }

        ctx.setAppliedPromo(promo);
    }
}

Burada PromoCodeCheck iki farklı bilgiyi önceki halkalardan alıyor:

graph LR
    F[FlightLoadCheck] -->|"ctx.flight"| P[PromoCodeCheck]
    T[TotalAmountCheck] -->|"ctx.totalAmount"| P

Bu yüzden zincirdeki sıra rastgele değil. Önce uçuş yüklenmeli, sonra tutar hesaplanmalı, daha sonra promosyon kontrolü yapılmalı.

5. Zinciri çalıştıran Pipeline

Şimdi bütün bu halkaları bir araya getirmemiz gerekiyor. Spring burada işimizi oldukça kolaylaştırıyor: BookingCheck interface’ini uygulayan bütün bean’leri List<BookingCheck> olarak inject edebiliriz. @Order sayesinde Spring bunları doğru sırada verir:

@Service
public class BookingCheckPipeline {

    private final List<BookingCheck> checks;

    public BookingCheckPipeline(List<BookingCheck> checks) {
        this.checks = checks;
    }

    public BookingContext run(BookingRequest request) {

        BookingContext ctx = new BookingContext(request);

        for (BookingCheck check : checks) {
            check.check(ctx);
        }

        return ctx;
    }
}

Spring tarafında elimizde kabaca şu liste oluşur:

@Order(10) FlightLoadCheck
@Order(20) SeatAvailabilityCheck
@Order(30) NoFlyListCheck
@Order(40) PassportValidityCheck
@Order(50) TotalAmountCheck
@Order(60) PromoCodeCheck

Pipeline’ın tek sorumluluğu zinciri çalıştırmak. Bu kadar.

Ya ilk hatada durmak yerine bütün hataları toplamak istersek?

Yukarıdaki pipeline fail-fast çalışıyor: ilk başarısız kural exception fırlatıyor ve zincir orada duruyor. Kullanıcı hatayı düzeltip tekrar gönderiyor, bu sefer bir sonraki hataya çarpıyor.

Form doğrulaması gibi senaryolarda genellikle bütün ihlalleri bir kerede göstermek istersiniz. Bunun için kuralları exception yerine ihlal listesi döndürecek şekilde yazmak gerekir:

public interface BookingCheck {
    List<Violation> check(BookingContext ctx);
}

Pipeline da toplayarak ilerler:

public BookingContext run(BookingRequest request) {
    BookingContext ctx = new BookingContext(request);
    List<Violation> violations = new ArrayList<>();

    for (BookingCheck check : checks) {
        violations.addAll(check.check(ctx));
    }

    if (!violations.isEmpty()) {
        throw new BookingRejectedException(violations);
    }
    return ctx;
}

Ama bir uyarı: bu ancak kurallar birbirinden bağımsızsa güvenlidir. FlightLoadCheck başarısız olduğunda ctx.getFlight() null kalır ve sonraki kurallar NullPointerException ile patlar. Pratikte iyi çalışan çözüm melez oluyor: veri yükleyen ve kritik olan adımlar fail-fast, saf doğrulama yapan adımlar toplayarak.

@Order neden önemli?

Burada @Order sadece estetik bir detay değil. Zincirdeki bazı kurallar birbirlerine bağımlı:

FlightLoadCheck          TotalAmountCheck
      ↓                        ↓
  ctx.flight              ctx.totalAmount
      ↓                        ↓
PassportValidityCheck     PromoCodeCheck

Pasaport kontrolü çalışmadan önce flight Context’e konmuş olmalı. Aynı şekilde promosyon kontrolü çalışmadan önce toplam tutarın hesaplanmış olması gerekiyor. Bu yüzden sıralama, veri bağımlılıkları tarafından belirleniyor.

Pratikte 10, 20, 30... şeklinde gitmek de faydalı. Yarın araya yeni bir kontrol koymanız gerekirse @Order(45) gibi bir değer kullanabilirsiniz.

Sıralama gizli bir bağımlılık yaratıyor

Bu yaklaşımın bedeli şu: SeatAvailabilityCheck, ctx.getFlight()‘in dolu olmasına güveniyor ama bunu hiçbir yerde beyan etmiyor. Bağımlılık yalnızca iki @Order sayısı arasındaki farkta yaşıyor.

Biri yeni bir kontrolü @Order(5) ile eklerse ya da mevcut sayıları düzenlerse, derleyici uyarmaz — çalışma zamanında NullPointerException alırsınız.

İki basit önlem işe yarar:

  • Context getter’larını sessiz bırakmayın. getFlight() null yerine anlamlı bir hata versin:

    public Flight getFlight() {
        return Objects.requireNonNull(flight,
                "FlightLoadCheck bu kuraldan önce çalışmalı");
    }
    
  • Zincirin sırasını test edin. Aşağıdaki hint bunun nasıl yapıldığını gösteriyor.

6. BookingService artık küçücük

Bütün bu kurallar ayrı sınıflara taşındığında asıl servisimizin ne kadar sadeleştiğine bakın:

@Service
public class BookingService {

    private final BookingCheckPipeline pipeline;
    private final BookingRepository bookingRepository;

    public BookingService(BookingCheckPipeline pipeline,
                          BookingRepository bookingRepository) {
        this.pipeline = pipeline;
        this.bookingRepository = bookingRepository;
    }

    @Transactional
    public Booking createBooking(BookingRequest request) {

        BookingContext ctx = pipeline.run(request);

        return bookingRepository.save(Booking.from(ctx));
    }
}

300 satırlık validation mantığı artık burada yok. Servis sadece şunu yapıyor:

Request
Pipeline
BookingContext
Save

Daha önemlisi, BookingService hangi kuralların var olduğunu bile bilmiyor. Bu önemli bir ayrım.

Yeni bir kural eklemek ne kadar zor?

Diyelim ki yeni bir kural geldi:

İngiltere uçuşlarında ETA zorunlu.

Eski yaklaşımda BookingService‘e girip yeni bir if eklemeniz gerekir. Chain of Responsibility’de ise yeni bir sınıf ekliyorsunuz:

@Component
@Order(45)
public class EtaRequirementCheck implements BookingCheck {

    @Override
    public void check(BookingContext ctx) {

        if (!ctx.getFlight().isToUnitedKingdom()) {
            return;
        }

        if (!ctx.getRequest().customer().hasValidEta()) {
            throw new BookingRejectedException(
                    RejectReason.ETA_REQUIRED, "İngiltere uçuşu için ETA gerekli");
        }
    }
}

Ve bitti. BookingService değişmedi, BookingCheckPipeline değişmedi, diğer kurallar değişmedi. Sadece yeni bir halka eklendi. Bu, pattern’in en güçlü taraflarından biri.

Unit test: Her kuralı ayrı ayrı test etmek

Chain of Responsibility’nin en güzel avantajlarından biri de test edilebilirlik. Sadece SeatAvailabilityCheck‘i test etmek istiyorsak diğer kuralların repository’lerini mock’lamamıza gerek yok. Sadece bu sınıfın ihtiyacı olan dependency’yi veriyoruz:

@Test
void yeterliKoltukYoksaRezervasyonuReddeder() {

    when(seatRepository.getAvailableSeats("TK1923", CabinClass.ECONOMY))
        .thenReturn(1);

    var check = new SeatAvailabilityCheck(seatRepository);

    BookingContext context = contextWithPassengers("TK1923", 3);

    assertThatThrownBy(() -> check.check(context))
        .isInstanceOf(BookingRejectedException.class);
}

Testin yaptığı şey çok net:

3 yolcu
1 koltuk
SeatAvailabilityCheck
BookingRejectedException

No-fly kontrolü yok, promosyon kontrolü yok, pasaport kontrolü yok. Çünkü bunların bu testle ilgisi yok. Bu da testlerin hem daha hızlı hem de daha anlaşılır olmasını sağlıyor.

Halkaları tek tek test etmek yetmez; sıranın kendisi de bir davranıştır ve test edilmeyi hak eder:

@Test
void zincirDogruSiradaCalisir() {
    List<String> order = pipeline.getChecks().stream()
            .map(c -> c.getClass().getSimpleName())
            .toList();

    assertThat(order).containsExactly(
            "FlightLoadCheck",
            "SeatAvailabilityCheck",
            "NoFlyListCheck",
            "PassportValidityCheck",
            "TotalAmountCheck",
            "PromoCodeCheck");
}

Biri @Order değerini bozduğunda ya da yeni bir kuralı yanlış yere koyduğunda bu test kırılır — production’da null bir Flight ile karşılaşmadan önce.

Guard Clauses ile birlikte kullanmak

Burada Guard Clauses ve Early Return yazısıyla güzel bir bağlantı var. Örneğin:

@Override
public void check(BookingContext ctx) {

    if (!ctx.getFlight().isInternational()) {
        return;
    }

    // Asıl passport kontrolü
}

Burada guard clause kullanıyoruz. Yani:

  • Chain of Responsibility, büyük akışı parçalara ayırıyor.
  • Guard Clause, her halkanın kendi içindeki akışı sade tutuyor.

İki teknik birbirinin alternatifi değil; birlikte gayet güzel çalışabilirler.

Feature flag ile dinamik kurallar

Chain of Responsibility’nin bir başka güzel tarafı da Spring’in conditional bean özellikleriyle birlikte kullanılabilmesi. Örneğin fraud kontrolünü sadece belirli ortamlarda çalıştırmak istediğimizi düşünelim:

@Component
@Order(70)
@ConditionalOnProperty(name = "checks.fraud.enabled", havingValue = "true")
public class FraudCheck implements BookingCheck {

    @Override
    public void check(BookingContext ctx) {
        // Fraud kontrolü
    }
}

Konfigürasyon:

checks.fraud.enabled=true
checks:
  fraud:
    enabled: true

Property açık olduğunda bean zincire dahil edilir. Kapalı olduğunda ise bean hiç oluşturulmaz ve pipeline açısından fraud kontrolü hiç yokmuş gibi davranılır. Bu, feature flag mantığını zincire oldukça temiz bir şekilde eklemenizi sağlar.

Bu, Adapter ve Strategy yazısındaki single-tenant kurulumla aynı mekanizma: @ConditionalOnProperty ile bean’i açılışta zincire dahil etmek ya da hiç yaratmamak. Orada hangi adaptörün çalışacağını, burada hangi kuralın zincirde olacağını belirliyor.

Ne zaman Chain of Responsibility kullanmalı?

Her if bloğu için Chain of Responsibility kullanmak gerekmiyor. 5 tane basit kontrolünüz varsa guard clause gayet yeterli olabilir. Ama kurallar büyümeye başladıysa:

  • Her kuralın ayrı dependency’leri varsa.
  • Kurallar farklı ekipler tarafından geliştiriliyorsa.
  • Yeni kurallar sık sık ekleniyorsa.
  • Kuralların sırası önemliyse.
  • Bazı kurallar feature flag ile açılıp kapanıyorsa.
  • Aynı kurallar farklı akışlarda tekrar kullanılabiliyorsa.
  • Tek bir servis yüzlerce satırlık validation koduna dönüşüyorsa.

Chain of Responsibility ciddi şekilde anlam kazanmaya başlar.

DurumYaklaşım
Az sayıda, basit, birbirinden bağımsız kontrolGuard clauses
Çok sayıda kural, ama hepsi aynı veriyle çalışıyorÖnce metodu sadeleştir
Kuralların ayrı dependency’leri, sırası ve feature flag’leri varChain of Responsibility / Pipeline

Buradaki sınır kesin değil. “5 kural oldu, artık kesin Chain of Responsibility” diye bir matematik yok. Asıl soru:

Bu kurallar hala tek bir metodun içinde kolayca anlaşılabiliyor mu?

Cevap hayırsa parçalamayı düşünmenin zamanı gelmiştir.

Sektörde nerelerde görürüz?

Bu pattern özellikle ardışık kontrollerin olduğu sistemlerde oldukça doğal. Havayolu sistemlerinde:

Booking Validation
Check-in Validation
Document Check
Boarding Rules

gibi akışlar Chain of Responsibility için çok uygun. Ama bununla sınırlı değil. Spring Security’nin filter chain yapısı da aynı temel fikri taşır:

graph LR
    R[Request] --> A[Authentication Filter]
    A --> Z[Authorization Filter]
    Z --> C[CSRF Filter]
    C --> D["…"]
    D --> App([Application])

API gateway filtreleri, HTTP middleware’leri, request validation pipeline’ları ve çeşitli fraud/risk kontrolleri de aynı düşünceye oldukça yakındır.

Altta yatan fikir

Chain of Responsibility’nin özünü tek cümlede anlatmak gerekirse:

Büyük bir kural setini tek bir yerde yönetmek yerine, her kuralı bağımsız bir halka yap ve request’i zincir boyunca ilerlet.

Sonuçta elimizde şöyle bir yapı kalıyor:

  • Bir kural başarısızsa zincir orada duruyor.
  • Bir kuralın request için yapacağı bir şey yoksa sessizce devam ediyor.
  • Her kural kendi sınıfında test ediliyor.
  • Yeni bir kural geldiğinde mevcut servisleri değiştirmek yerine yeni bir halka ekleniyor.

Yani amaç sadece kodu bölmek değil. Amaç, değişen kuralları birbirinden bağımsız hale getirmek.

Böylece 300 satırlık bir BookingService yerine, her biri tek bir işi yapan küçük ve test edilebilir kurallardan oluşan bir pipeline elde ediyoruz.