Gdy jedno żądanie może obsłużyć kilka różnych komponentów, szybko pojawia się problem z warunkami, wyjątkami i rosnącą klasą odpowiedzialną za wszystko naraz. Wzorzec chain of responsibility porządkuje taki przepływ, przekazując żądanie przez kolejne obiekty aż do znalezienia właściwego wykonawcy. Pokażę, jak działa ten mechanizm, gdzie sprawdza się w aplikacjach webowych, jak go zaimplementować oraz kiedy lepiej wybrać inne rozwiązanie.
Jedna prosta reguła porządkuje obsługę żądań
- Żądanie przechodzi przez łańcuch handlerów, a każdy z nich może je obsłużyć albo przekazać dalej.
- Nadawca nie musi znać konkretnego odbiorcy, dzięki czemu kod jest luźniej powiązany.
- Wzorzec dobrze pasuje do middleware, walidacji, autoryzacji, filtrów i eskalacji zgłoszeń.
- Największe ryzyko to niejasna kolejność handlerów, brak obsługi końcowej i trudne śledzenie przepływu.
- Koszt wykonania wynosi zwykle O(n), ponieważ żądanie może przejść przez wszystkie elementy łańcucha.
Na czym polega łańcuch odpowiedzialności
W tym wzorcu żądanie trafia do pierwszego obiektu, zwanego handlerem. Handler podejmuje decyzję, czy potrafi je obsłużyć. Jeśli nie, przekazuje je do następnego elementu, a proces trwa do momentu znalezienia wykonawcy albo wyczerpania całego łańcucha.
Najważniejsza zasada brzmi: nadawca wysyła żądanie bez znajomości konkretnego odbiorcy. Dzięki temu kod wywołujący nie musi zawierać długiego bloku if lub switch, który rozrasta się przy każdej nowej regule biznesowej.
Typowa implementacja składa się z trzech elementów:
- interfejsu handlera, który definiuje sposób obsługi żądania,
- konkretnych handlerów, odpowiadających za poszczególne przypadki,
- łańcucha, czyli ustalonej kolejności przetwarzania.
Wyobraźmy sobie system zgłoszeń. Proste pytanie może obsłużyć chatbot, problem techniczny trafia do pierwszej linii wsparcia, a sprawa dotycząca płatności do działu finansowego. Każdy poziom sprawdza, czy ma odpowiednie kompetencje, zamiast przekazywać wszystkie zgłoszenia bezpośrednio do jednego, przeciążonego komponentu.
Jak zbudować ten wzorzec w praktyce
Zaczynam od określenia wspólnego kontraktu. Każdy handler powinien przyjmować ten sam typ żądania i zwracać przewidywalny rezultat. Dobrze, gdy potrafi również przekazać sprawę dalej, zamiast samodzielnie znać wszystkie możliwe przypadki.
Minimalny przepływ
- Tworzysz obiekty obsługujące różne przypadki.
- Ustawiasz ich kolejność.
- Przekazujesz żądanie do pierwszego elementu.
- Handler obsługuje żądanie albo wywołuje kolejny element.
- Ostatni element zwraca odpowiedź awaryjną, jeśli nikt nie przejął sprawy.
W aplikacji webowej podobny schemat często występuje w middleware. Jeden komponent zapisuje żądanie w logach, drugi sprawdza autoryzację, trzeci waliduje dane, a ostatni przekazuje ruch do kontrolera. W tym wariancie kilka handlerów może wykonać swoje zadanie, a nie tylko jeden z nich.
Przykład w JavaScript
class Handler {
setNext(handler) {
this.next = handler;
return handler;
}
handle(request) {
if (this.next) {
return this.next.handle(request);
}
return { handled: false, reason: "Brak obsługi" };
}
}
class AuthHandler extends Handler {
handle(request) {
if (!request.user) {
return { handled: true, status: 401 };
}
return super.handle(request);
}
}
class RoleHandler extends Handler {
handle(request) {
if (request.requiredRole && !request.user.roles.includes(request.requiredRole)) {
return { handled: true, status: 403 };
}
return super.handle(request);
}
}
class ControllerHandler extends Handler {
handle(request) {
return { handled: true, status: 200, data: "Gotowe" };
}
}
const auth = new AuthHandler();
const role = new RoleHandler();
const controller = new ControllerHandler();
auth.setNext(role).setNext(controller);
const response = auth.handle({
user: { roles: ["editor"] },
requiredRole: "editor"
});
W tym przykładzie pierwszy handler sprawdza obecność użytkownika, drugi jego uprawnienia, a trzeci wykonuje właściwą operację. Kolejność ma znaczenie: autoryzacja powinna nastąpić przed dostępem do logiki biznesowej, a walidacja danych przed zapisem.
Nie zawsze handler powinien kończyć obsługę. W potoku middleware często wykonuje własną operację, a później wywołuje następny element. Trzeba jednak jasno ustalić, czy dany komponent może zatrzymać przepływ, czy zawsze ma przekazywać żądanie dalej. Brak takiej reguły prowadzi do trudnych do wykrycia błędów.
Gdzie ten wzorzec naprawdę się sprawdza
Najbardziej naturalnym środowiskiem dla łańcucha jest aplikacja, w której żądanie przechodzi przez kilka niezależnych kontroli. W programowaniu webowym zamiast budować jedną ogromną funkcję, można podzielić przepływ na małe kroki, które da się testować i wymieniać osobno.
| Zastosowanie | Jak działa łańcuch | Co daje |
|---|---|---|
| Middleware HTTP | Logowanie, autoryzacja, limity i obsługa żądania wykonują się w ustalonej kolejności. | Łatwe dodawanie i usuwanie etapów. |
| Walidacja formularza | Kolejne handlery sprawdzają format, wymagane pola i reguły biznesowe. | Każda reguła ma własną odpowiedzialność. |
| Eskalacja zgłoszeń | Sprawa trafia do coraz bardziej wyspecjalizowanego poziomu wsparcia. | Proste przypadki nie obciążają ekspertów. |
| Filtrowanie zdarzeń | Każdy filtr może przepuścić, zmodyfikować albo zatrzymać zdarzenie. | Elastyczne reagowanie na różne typy danych. |
| Obsługa wyjątków | Kolejne komponenty próbują obsłużyć błąd o określonym typie. | Logika błędów nie jest skupiona w jednym miejscu. |
Dobrym przykładem jest walidacja zamówienia. Pierwszy etap sprawdza, czy użytkownik przesłał wymagane pola, drugi weryfikuje dostępność produktów, trzeci kontroluje limit płatności, a czwarty przekazuje poprawne zamówienie do realizacji. Każdy krok można zmienić bez przebudowy całego procesu.
Ten mechanizm sprawdza się także przy regułach o zmiennej kolejności. Na przykład w panelu administracyjnym najpierw można sprawdzić sesję, później uprawnienia, a dopiero potem ograniczenia wynikające z konkretnej organizacji. Gdy polityka dostępu się zmieni, wymieniam lub przestawiam odpowiedni handler.
Co zyskujesz, a za co płacisz
Największą zaletą jest luźne powiązanie nadawcy z odbiorcą. Kod, który rozpoczyna obsługę, nie musi znać szczegółów wszystkich możliwych przypadków. Dodanie nowego handlera zwykle nie wymaga modyfikowania istniejących klas.
Drugą korzyścią jest możliwość konfigurowania przepływu. Ten sam zestaw handlerów można ułożyć inaczej dla panelu administratora, API publicznego i zadania uruchamianego w tle. Dobrze zaprojektowany łańcuch sprzyja też testom jednostkowym, bo każdy element można sprawdzić niezależnie.
| Korzyść | Praktyczne znaczenie |
|---|---|
| Mniejsza liczba zależności | Handler nie musi znać całej logiki procesu. |
| Łatwe rozszerzanie | Nową regułę można dodać jako osobny komponent. |
| Elastyczna kolejność | Przepływ można dopasować do konkretnego scenariusza. |
| Lepsze testowanie | Każdy handler ma mały, wyraźny zakres odpowiedzialności. |
| Ryzyko ukrytej logiki | Bez dokumentacji trudno od razu ustalić, gdzie żądanie zostanie obsłużone. |
| Koszt liniowy | Przy n handlerach żądanie może wymagać do n sprawdzeń. |
Minusem jest przede wszystkim mniejsza przewidywalność przepływu. Jeśli łańcuch ma kilkanaście elementów, a każdy może zmienić dane lub przerwać operację, diagnozowanie problemu staje się trudniejsze niż w prostym wywołaniu funkcji.
Dlatego nie tworzę łańcucha tylko dlatego, że kilka warunków wygląda nieestetycznie. Jeżeli mamy dwa proste przypadki i stabilną logikę, zwykły if może być czytelniejszy. Wzorzec zaczyna się opłacać wtedy, gdy reguły często się zmieniają, rosną albo występują w kilku różnych przepływach.
Jak odróżnić go od podobnych rozwiązań
Najczęstsze nieporozumienie polega na utożsamianiu każdego potoku przetwarzania z tym wzorcem. Podobny wygląd nie oznacza identycznego celu. Różnica tkwi w tym, kto podejmuje decyzję i czy następny element jest opcjonalny.
| Rozwiązanie | Główna idea | Kiedy wybrać |
|---|---|---|
| Łańcuch odpowiedzialności | Handler obsługuje żądanie albo przekazuje je dalej. | Gdy nie chcesz wiązać nadawcy z konkretnym odbiorcą. |
| Pipeline | Każdy etap zwykle wykonuje swoją część i przekazuje wynik dalej. | Gdy wszystkie kroki mają uczestniczyć w przetwarzaniu. |
| Strategy | Wybierasz jedną alternatywną implementację algorytmu. | Gdy potrzebujesz jednej metody spośród kilku wariantów. |
| Decorator | Owijasz obiekt dodatkowymi zachowaniami. | Gdy chcesz rozszerzać funkcję bez zmiany jej interfejsu. |
| Observer | Powiadamiasz wiele niezależnych odbiorców o zdarzeniu. | Gdy każdy zainteresowany może zareagować na ten sam fakt. |
Przykładowo autoryzacja i kontrola limitu mogą tworzyć łańcuch, bo każdy etap może zatrzymać żądanie. Z kolei zestaw transformacji tekstu, w którym wszystkie operacje mają wykonać się po kolei, bliżej przypomina pipeline. To rozróżnienie pomaga uniknąć niewłaściwej abstrakcji.
W praktyce frameworki często mieszają te idee. Middleware może działać jak łańcuch odpowiedzialności, ale jednocześnie tworzyć pipeline, w którym każdy element wykonuje kod przed i po wywołaniu następnego. Nie przywiązuję się więc do nazwy tak mocno, jak do konkretnej semantyki przepływu.
Jak uniknąć typowych błędów
Nie zostawiaj żądania bez końcowej decyzji
Łańcuch powinien mieć jasno określone zakończenie. Jeśli żaden handler nie obsłuży żądania, system musi zwrócić błąd, wartość domyślną albo przekazać sprawę do kontrolera awaryjnego. Ciche pominięcie żądania jest szczególnie niebezpieczne w autoryzacji i walidacji.
Dokumentuj kolejność
Dwa poprawne handlery mogą stworzyć błędny system, jeśli zostaną ustawione w złej kolejności. Walidacja danych przed sprawdzeniem uprawnień może ujawnić informacje, do których użytkownik nie powinien mieć dostępu. Kolejność jest częścią konfiguracji biznesowej, a nie przypadkowym szczegółem technicznym.
Nie mieszaj zbyt wielu odpowiedzialności
Handler powinien wykonywać jedną spójną czynność. Komponent, który jednocześnie loguje, pobiera dane z bazy, sprawdza role i wysyła e-mail, przestaje być łatwy do testowania. Rozdzielenie tych zadań zwykle daje więcej korzyści niż skrócenie liczby klas.
Przeczytaj również: SOLID w aplikacjach webowych - Czy naprawdę go potrzebujesz?
Dodaj obserwowalność
Przy większym łańcuchu przydają się logi z nazwą handlera, identyfikatorem żądania i informacją o podjętej decyzji. Bez tego trudno ustalić, czy żądanie zostało zatrzymane przez autoryzację, walidację czy limit. W systemach produkcyjnych ślad przepływu jest równie ważny jak sama logika.
- Ustal, czy handler może zatrzymać przetwarzanie.
- Określ zachowanie, gdy nikt nie przejmie żądania.
- Testuj poprawną i błędną kolejność elementów.
- Sprawdź, czy handler nie modyfikuje danych w sposób nieudokumentowany.
- Mierz czas wykonania, jeśli łańcuch obsługuje duży ruch.
Najlepszy test dla tej architektury
Przed wdrożeniem zadaję sobie jedno pytanie: czy potrafię dodać nową regułę bez edytowania kodu, który rozpoczyna obsługę żądania? Jeśli odpowiedź brzmi tak, wzorzec prawdopodobnie dobrze pasuje do problemu.
Jeśli natomiast każdy przypadek musi przejść przez wszystkie elementy, a kolejność jest stała i obowiązkowa, prosty pipeline może być bardziej czytelny. Łańcuch odpowiedzialności daje najwięcej wtedy, gdy odpowiedzialność może przejąć różny komponent, a sam nadawca nie powinien wiedzieć, który z nich to zrobi.