Myślę, że każdy z nas nieraz spotkał się z opinią, że aby nadążyć za dzisiejszym światem, należy być wielozadaniowym. Jednak z jednej strony nie pozwala „dopieścić” konkretnej sprawy od początku do końca, zaś z drugiej niesamowicie męczy i wypala.

Nie inaczej jest w programowaniu obiektowego (OOP), którego głównym założeniem jest posługiwanie się wirtualnymi obiektami, które współpracując ze sobą, zachowują się podobnie do obiektów w świecie materialnym. Biorąc to pod uwagę, a także dokładając moją myśl na wstępie, możemy wysnuć tezę, że klasa, która robi wszystko nie jest niczym dobrym ani pożądanym. Jako przykładem posłużę się napisaną przeze mnie aplikacja budżetową, o której miałem przyjemność pisać wcześniej.

Na pewnym etapie pracy nad powyższym projektem, zacząłem bawić się wzorcem MVC i bardzo spodobało mi się tworzenie kontrolerów, jednak dzisiaj skupimy się na regule SRP. Oto wycinek z pierwotnego kontrolera zajmującego się ustawieniami, dla uproszczenia skupimy się na metodach zajmujących się dodawaniem kategorii przychodów oraz wydatków dla danego użytkownika:

<?php

declare(strict_types=1);

namespace App\Controllers;

use Framework\TemplateEngine;
use Framework\Database;
use Framework\Exceptions\ValidationException;
use Framework\Validator;
use Framework\Rules\RequireRule;

// ❌ NARUSZENIE SRP: jedna klasa odpowiada za:
// 1. Obsługę żądań HTTP (routing, przekierowania)
// 2. Logikę biznesową (walidacja, sanityzacja)
// 3. Bezpośredni dostęp do bazy danych (zapytania SQL)

class SettingsController
{
    private Validator $validator;

    public function __construct(
        private TemplateEngine $view,
        private Database $db        // ❌ Kontroler bezpośrednio zna bazę danych
    ) {
        // ❌ Kontroler sam buduje swój walidator
        $this->validator = new Validator();
        $this->validator->add('required', new RequireRule());
    }

    // -------------------------------------------------------------------------
    // ❌ ODPOWIEDZIALNOŚĆ 1 + 2 + 3 wymieszane w jednej metodzie
    // -------------------------------------------------------------------------

    public function addIncomeCategory()
    {
        $formData = $_POST;

        // ❌ Logika biznesowa (walidacja) wprost w kontrolerze
        $errors = [];
        if (empty($formData['newName'])) {
            $errors['newName'][] = 'To pole jest wymagane.';
        }
        if (!empty($errors)) {
            $_SESSION['errors'] = $errors;
            $_SESSION['oldFormData'] = $formData;
            redirectTo($_SERVER['HTTP_REFERER']);
            return;
        }

        // ❌ Logika biznesowa (sanityzacja) wprost w kontrolerze
        $sanitizedName = trim(preg_replace('/[\t\n\r\s]+/', ' ', $formData['newName']));

        // ❌ Logika biznesowa (sprawdzenie duplikatu) wprost w kontrolerze
        $tableName = 'incomes_category_assigned_to_users';
        $nameCount = $this->db->query(
            "SELECT COUNT(*) FROM {$tableName} 
             WHERE name = :name AND user_id = :user_id",
            [
                'name'    => $sanitizedName,
                'user_id' => $_SESSION['user']
            ]
        )->count();

        if ($nameCount > 0) {
            $_SESSION['errors'] = ['newName' => ['Podana nazwa jest już zajęta.']];
            redirectTo($_SERVER['HTTP_REFERER']);
            return;
        }

        // ❌ Dostęp do bazy danych wprost w kontrolerze
        $this->db->query(
            "INSERT INTO {$tableName}(user_id, name) VALUES (:user_id, :name)",
            [
                'user_id' => $_SESSION['user'],
                'name'    => $sanitizedName
            ]
        );

        $_SESSION['success'] = true;

        // Obsługa HTTP – jedyna rzecz, która tu powinna być
        redirectTo('/settings/add-income-category');
    }

    public function addExpenseCategory()
    {
        $formData = $_POST;

        // ❌ Ten sam blok walidacji kopiujemy ręcznie (brak reużycia)
        $errors = [];
        if (empty($formData['newName'])) {
            $errors['newName'][] = 'To pole jest wymagane.';
        }
        if (!empty($errors)) {
            $_SESSION['errors'] = $errors;
            $_SESSION['oldFormData'] = $formData;
            redirectTo($_SERVER['HTTP_REFERER']);
            return;
        }

        // ❌ Sanityzacja ponownie skopiowana
        $sanitizedName = trim(preg_replace('/[\t\n\r\s]+/', ' ', $formData['newName']));

        // ❌ Sprawdzenie duplikatu ponownie skopiowane
        $tableName = 'expenses_category_assigned_to_users';
        $nameCount = $this->db->query(
            "SELECT COUNT(*) FROM {$tableName} 
             WHERE name = :name AND user_id = :user_id",
            [
                'name'    => $sanitizedName,
                'user_id' => $_SESSION['user']
            ]
        )->count();

        if ($nameCount > 0) {
            $_SESSION['errors'] = ['newName' => ['Podana nazwa jest już zajęta.']];
            redirectTo($_SERVER['HTTP_REFERER']);
            return;
        }

        // ❌ Insert ponownie skopiowany
        $this->db->query(
            "INSERT INTO {$tableName}(user_id, name) VALUES (:user_id, :name)",
            [
                'user_id' => $_SESSION['user'],
                'name'    => $sanitizedName
            ]
        );

        $_SESSION['success'] = true;
        redirectTo('/settings/add-expense-category');
    }

    public function settingsView()
    {
        // ❌ Nawet widok wie teraz, że kontroler ma bazę danych –
        //    można by tu dorzucić zapytania SQL i nikt nie zauważy
        echo $this->view->render('settings.php', [
            'title' => 'Ustawienia'
        ]);
    }
}

Przyjrzyjmy się uważnie, z czym mamy tu do czynienia? Z wielozadaniowym kontrolerem, który przeprowadza walidację wprowadzonych danych, w przypadku pozytywnej weryfikacji modyfikuje bazę danych, bezpośrednio przesyłając hardcodowane zapytanie SQL. Wyobraźmy sobie sytuację, kiedy w tabeli z kategoriami przychodów przypisanych do użytkownika dodamy nową kolumnę, albo zechcemy dodać kryterium walidacji nazwy nowej kategorii (np. niezależnie od tego co wpisze użytkownik, każdą nową kategorię wprowadzimy do bazy danych z dużej litery), za każdym razem musimy zmodyfikować klasę SettingsController. Jest to oczywiste naruszenie reguły SRP mówiącej, że każda klasa powinna mieć jedynie jeden powód do zmiany.

Co zatem z tym zrobić? Przede wszystkim postanowiłem skupić się na tym, aby odciążyć naszego kontrolera z nadmiaru odpowiedzialności, zostawiając mu jedynie zadanie otrzymywania danych z routingu, przekazywania ich do modelu oraz zwracanie do TemplateEngine wyników wypracowanych przez model. Jest to nie tylko zgodne z zasadą SRP, ale także jedną z reguł wzorca MVC, że „skinny controllers, fat models”.

Oto „odchudzony” SettingsController:

<?php

declare(strict_types=1);

namespace App\Controllers;

use App\Services\{SettingsService, ValidatorService};
use Framework\TemplateEngine;

class SettingsController
{
      public function __construct(
            private TemplateEngine $view,
            private SettingsService $settingsService,
            private ValidatorService $validatorService
      ) {}

      private function checkAndAdd(string $tableName, array $formData)
      {
            $this->validatorService->validateElement($formData);

            $sanitizedName = $this->settingsService->sanitizeNewName($formData['newName']);

            $this->settingsService->isNameTaken($tableName, $sanitizedName);

            $this->settingsService->addElement($tableName, $sanitizedName);

            $_SESSION['success'] = true;
      }

      public function addIncomeCategory()
      {
            $tableName = 'incomes_category_assigned_to_users';

            $this->checkAndAdd($tableName, $_POST);

            redirectTo('/settings/add-income-category');
      }


      public function addExpenseCategory()
      {
            $tableName = 'expenses_category_assigned_to_users';

            $this->checkAndAdd($tableName, $_POST);

            redirectTo('/settings/add-expense-category');
      }
//Jakiś inny kod

}

Aby osiągnąć ten cel, utworzyłem klasę SettingsService, która otrzymuje informacje od kontrolera, przetwarza je, a następnie zwraca wszystkie potrzebne dane:

class SettingsService
{
      public function __construct(private Database $db) {}

      public function sanitizeNewName(string $newName): string
      {
            $newName = trim(preg_replace('/[\t\n\r\s]+/', ' ', $newName));
            return $newName;
      }

      public function isNameTaken(string $tableName, string $elementName)
      {
            $nameCount = $this->db->query(
                  "SELECT COUNT(*) FROM {$tableName} WHERE name = :name AND user_id = :user_id",
                  [
                        'name' => $elementName,
                        'user_id' => $_SESSION['user']
                  ]
            )->count();

            if ($nameCount > 0) {
                  throw new ValidationException(['newName' => ['Podana nazwa jest już zajęta.']]);
            }
      }

      public function addElement(string $tableName, string $newName)
      {
            $this->db->query("INSERT INTO {$tableName}(user_id, name)
            VALUES (:user_id, :name)", [
                  'user_id' => $_SESSION['user'],
                  'name' => $newName
            ]);
      }
//Jakiś inny kod
}

Powyższy układ mocno upraszcza sprawę i ułatwia dalszy rozwój aplikacji, kontroler nie zna bazy danych, walidatora ani reguł walidacji, nawet nie interesuje go to, jakie dane znajdują się w tablicy $formData. Cały nasz „silnik” przetwarzający dane to w tym momencie SettingService, korzystający z pomocy klas Validator oraz DataBase. Kontroler jedynie odbiera informacje zwrócone przez model i przekazuje je do wyświetlenia użytkownikowi (statusy błędu, treści komunikatów albo przetworzone informacje z bazy danych).

Teraz, nasz kontroler ma tylko jeden powód, aby go zmienić, jest to np. zmiana sposobu obsługi żądań HTTP i przekierowań. W przypadku modyfikacji istniejących funkcji, zmiany konfiguracji bazy danych, rozbudowy tablic albo rozbudowie walidacji, nasz kontroler pozostaje niezmieniony.

Prawidłowa wersja ✅Zepsuta wersja ❌
Powody do zmianyKażda klasa ma jedenKontroler zmienia się gdy zmienia się HTTP, SQL i logika
Duplikacja koduBrak – serwis reużywanyWalidacja i SQL kopiowane w każdej metodzie
TestowalnośćSerwis testujesz bez HTTPNie możesz przetestować logiki bez symulowania requestu
CzytelnośćKrótka metoda kontrolera (3 linijki kodu)Przerośnięta metoda kontrolera (30 linii kodu)
Baza danych w kontrolerzeNigdyKontroler zna SQL bezpośrednio

Podsumowując. reguła SRP jest w pełni tożsama ze starym powiedzeniem „Jak ktoś jest od wszystkiego, to jest do niczego”. O ile wzorce projektowe mają swoje wady i zalety, tak tworzenie jak najmniejszych klas o jak najściślejszych specjalizacjach jest zawsze dobrym rozwiązaniem, nie tylko zwiększa skalowalność aplikacji, ale także ułatwia testowanie pojedynczych klas oraz zwiększa ponowną używalność kodu.