0% прочитано

SOLID. Принцип 1 - Single Responsibility Principle на практиці в реальному Laravel-проєкті

На реальному прикладі ContentBlockSearchTextObserver і ContentBlockSearchTextExtractor показую, чому Single Responsibility Principle не про маленькі класи, а про одну чітку причину для зміни.

22 вересня 2026 р. 7 хв читанняЛаравель

У ICanUp кожен ContentBlock зберігає свої дані в data, а для пошуку має окрему проєкцію search_text. Після збереження блока ця проєкція повинна відповідати актуальному data.

На перший погляд це одна задача. Насправді тут є дві різні відповідальності: коли оновлювати search_text і як визначити, який текст із довільної структури блока справді потрібно індексувати.

Single Responsibility Principle

Single Responsibility Principle: A module should have one, and only one, reason to change.

Простіше: якщо дві частини коду змінюються з різних причин, не варто змушувати один клас відповідати за обидві.

Дві різні причини для зміни

Для search_text ці причини видно досить чітко:

  • Життєвий цикл блока. Якщо зміниться момент або спосіб синхронізації під час збереження, змінюється Observer.
  • Правила пошукової проєкції. Якщо потрібно по-іншому обробляти HTML, пропускати нові технічні поля або індексувати нові видимі значення, змінюється Extractor.

Ці зміни не зобов’язані відбуватися разом, тому в ICanUp вони не живуть в одному класі.

Observer відповідає тільки за момент синхронізації

PHP - ContentBlockSearchTextObserver
final readonly class ContentBlockSearchTextObserver{    public function __construct(        private ContentBlockSearchTextExtractor $extractor,    ) {}     public function saving(ContentBlock $contentBlock): void    {        $contentBlock->search_text = $this->extractor->extract(            is_array($contentBlock->data)                ? $contentBlock->data                : [],        );    }}

ContentBlockSearchTextObserver дуже мало знає про сам пошуковий текст. Він реагує на saving, передає data в Extractor і записує результат у search_text.

У ньому немає списків технічних полів, правил для HTML, URL, медіаметаданих чи нормалізації пробілів. Це не його відповідальність.

Extractor відповідає за правила пошукового тексту

PHP - ContentBlockSearchTextExtractor
public function extract(array $data): string{    $parts = [];     $this->collect($data, $parts);     return implode("\n", $parts);} private function collect(    mixed $value,    array &$parts,    ?string $key = null,): void {    $normalizedKey = $key !== null        ? $this->normalizeKey($key)

ContentBlockSearchTextExtractor має іншу роботу. Він рекурсивно проходить структуру data, бере видимий текст і відкидає технічні значення.

У реальному коді окремо виключаються configuration, settings, ID, URL, alt та інші метадані. HTML перетворюється на звичайний текст, а код із HTML-синтаксисом, навпаки, зберігається як код.

Якщо завтра зміняться ці правила, Observer не потрібно переписувати.

Чому це саме Single Responsibility Principle

Тут важливий не розмір класів. Важливі причини для зміни.

  • ContentBlockSearchTextObserver змінюється, коли змінюється життєвий цикл синхронізації.
  • ContentBlockSearchTextExtractor змінюється, коли змінюються правила того, який вміст потрапляє в пошук.

Один компонент відповідає на питання "коли?", інший - "як?". Саме така межа робить відповідальність кожного з них зрозумілою.

SRP не означає "один метод на клас"

ContentBlockSearchTextExtractor має не один метод. Усередині є окремі кроки для обходу структури, перевірки ключів, HTML і нормалізації пробілів.

Це не порушує Single Responsibility Principle, бо всі ці методи працюють на одну мету: побудувати коректний текст для пошуку з даних ContentBlock.

Тому SRP не варто зводити до кількості рядків, методів або залежностей. Питання простіше: через скільки незалежних причин цей клас доведеться змінювати?

Перевірка поведінки

PHP - Search projection verification
it('updates search text whenever a ContentBlock is saved', function (): void {    $block = ContentBlock::query()->create([        'blockable_type' => 'post',        'blockable_id' => 9001,        'locale' => 'uk',        'type' => ContentBlockTypeEnum::PARAGRAPH->value,        'position' => 1,        'data' => [            'text' => 'Перший searchable текст',        ],        'is_active' => true,    ]);     expect($block->search_text)        ->toBe('Перший searchable текст'); 

Feature-тест окремо перевіряє життєвий цикл: після створення і після наступного save() значення search_text відповідає актуальному data.

Правила самого виділення тексту покриті окремими unit-тестами: видимий текст індексується, технічні метадані пропускаються, HTML перетворюється на звичайний текст, а вміст блока коду не губиться.

Так тести повторюють ту саму межу відповідальності, що й в основному коді.

Практичний висновок

Для мене найпростіше практичне формулювання Single Responsibility Principle таке: не намагатися помістити в один клас речі, які доведеться змінювати з різних причин.

У цьому кейсі Observer знає, коли синхронізувати search_text. Extractor знає, як його побудувати. Обидва класи залишаються невеликими, але це наслідок правильної межі, а не сама мета SRP.