У 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 відповідає тільки за момент синхронізації
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 відповідає за правила пошукового тексту
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 не варто зводити до кількості рядків, методів або залежностей. Питання простіше: через скільки незалежних причин цей клас доведеться змінювати?
Перевірка поведінки
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.



