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 zmiany | Każda klasa ma jeden | Kontroler zmienia się gdy zmienia się HTTP, SQL i logika |
| Duplikacja kodu | Brak – serwis reużywany | Walidacja i SQL kopiowane w każdej metodzie |
| Testowalność | Serwis testujesz bez HTTP | Nie 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 kontrolerze | Nigdy | Kontroler 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.

