Після того як Page Builder у ICanUp отримав Hero, CTA, Highlight, Cards, Steps і FAQ, я побачила інший тип повторення.
Ці блоки працюють із різним контентом, мають різні поля налаштувань і різні варіанти оформлення. Але загальна структура в них однакова: потрібно сформувати початкові дані, окремо перевірити content і configuration, додати обов’язковий variant і правильно сформувати назви полів для повідомлень валідації.
Якби я копіювала цей каркас у кожен опис блока, з’явилося б кілька майже однакових реалізацій, які з часом неминуче почали б відрізнятися в деталях.
Саме тут у коді природно з’явився Template Method.
Повторювалася не структура даних, а сам алгоритм роботи
Спільна частина | Що відрізняється |
|---|---|
| Поля всередині |
Початкові дані формуються за однаковим принципом. | Конкретні початкові значення. |
Валідація має спільну структуру. | Правила валідації для конкретного блока. |
| Набір доступних варіантів оформлення. |
Назви полів для валідації отримують префікси | Конкретні локалізовані назви полів. |
Це важлива ознака Template Method. Я не намагалася зробити Hero і Cards однаковими - вони й далі залишаються різними блоками.
Спільним є порядок побудови їхнього контракту. Базовий клас фіксує цей порядок, а дочірні класи визначають лише ті частини, які мають відрізнятися.
Базовий клас фіксує шаблон алгоритму за допомогою final-методів
abstract class AbstractPresentationBlockDefinition implements PresentationBlockDefinitionInterface{ final public function defaultData(): array { return [ 'content' => $this->defaultContent(), 'configuration' => $this->defaultConfiguration(), ]; } final public function rules(): array {Тут три публічні методи виконують роль Template Method.
defaultData() завжди повертає однакову верхньорівневу структуру.
rules() завжди додає структурні правила, префікси для правил конкретного блока та валідацію variant.
attributes() завжди формує назви полів за тим самим принципом.
Конкретний блок не може випадково перевизначити цей спільний алгоритм, тому ці методи позначені як final.
Дочірні класи визначають змінні кроки всередині спільного алгоритму
abstract protected function defaultContent(): array; abstract protected function defaultConfiguration(): array; abstract protected function contentAttributes(): array; abstract protected function configurationAttributes(): array; // Provided by concrete presentation definitions:public function contentRules(): array; public function configurationRules(): array; public function variants(): array;Базовий клас знає, коли потрібно отримати початкові значення, правила валідації, доступні варіанти та назви полів, але не знає, якими саме вони будуть.
У цьому і полягає принцип Template Method: спільний алгоритм належить батьківському класу, а окремі змінні кроки визначають конкретні реалізації.
defaultData() завжди дотримується єдиної структури даних
final public function defaultData(): array{ return [ 'content' => $this->defaultContent(), 'configuration' => $this->defaultConfiguration(), ];}Hero може містити title, text і buttons. Steps - title, introduction та items. Cards має ще складнішу внутрішню структуру.
Але редактор і backend завжди працюють з однаковою верхньорівневою структурою: content і configuration.
Конкретний опис блока не повинен щоразу пам’ятати про цю зовнішню обгортку. Він повертає лише власні дані, а базовий клас уже збирає їх у спільний формат.
Hero визначає тільки свою domain-specific частину
final class HeroBlockDefinition extends AbstractPresentationBlockDefinition{ public function type(): ContentBlockTypeEnum { return ContentBlockTypeEnum::HERO; } public function contentRules(): array { return [ 'title' => [ 'required', 'string', 'max:255', ],Hero не реалізує defaultData() самостійно. Він не формує префікси й не додає спільні правила валідації для variant.
Він знає лише власну частину: які поля належать Hero, які початкові значення вони мають і які правила валідації потрібні саме для них.
Коли батьківський клас викликає ці точки розширення, значення, визначені конкретним блоком, автоматично стають частиною спільного алгоритму.
rules показує Template Method найчіткіше
final public function rules(): array{ return [ 'content' => ['required', 'array'], 'configuration' => ['required', 'array'], ...$this->prefixKeys( 'content', $this->contentRules(), ), ...$this->prefixKeys( 'configuration',У цьому методі порядок дій не передається дочірньому класу.
Батьківський клас завжди очікує два масиви, завжди додає префікси до правил для content і configuration, а також завжди перевіряє variant за списком із variants().
Дочірній клас визначає лише конкретні значення всередині цих заздалегідь визначених частин алгоритму.
Навіть технічне додавання префіксів залишається відповідальністю базового класу
private function prefixKeys( string $prefix, array $values,): array { $prefixed = []; foreach ( $values as $key => $value ) { $prefixed[ "{$prefix}.{$key}" ] = $value; } return $prefixed;Саме такі дрібниці часто й призводять до розбіжностей після копіювання коду.
Один опис блока міг би повертати alignment, інший випадково - configuration.alignment, а третій по-різному додавати префікси до правил валідації та назв полів.
Базовий клас бере цю структурну логіку на себе, тому конкретні описи блоків можуть працювати лише з локальними назвами власних полів.
Чому final тут важливіший за abstract
abstract означає для дочірнього класу: цей крок ти повинна реалізувати.
final означає інше: цей алгоритм не можна перевизначати.
Без final один presentation block міг би перевизначити rules() і забути про спільну перевірку configuration.variant, а інший - повернути початкові дані в іншій структурі.
Template Method корисний саме тоді, коли частина процесу має залишатися стабільною, а змінюватися повинні лише окремі кроки всередині нього.
Template Method фіксує те, що має залишатися незмінним, і залишає точки розширення лише там, де справді потрібні відмінності.
Steps використовує той самий алгоритм, але працює із зовсім іншими даними.
final class StepsBlockDefinition extends AbstractPresentationBlockDefinition{ public function contentRules(): array { return [ 'title' => [ 'nullable', 'string', 'max:255', ], 'introduction' => [ 'nullable', 'string', ],Steps зовсім не схожий на Hero за структурою даних. Він має масив items і власні правила валідації.
Але загальний контракт представлення залишається тим самим, тому спільний алгоритм у батьківському класі все ще добре працює.
Це хороший критерій для використання успадкування: дочірні класи не зобов’язані мати однакові дані, але всі вони повинні природно вписуватися в один і той самий каркас алгоритму.
Не кожен Content Block повинен наслідувати базовий клас
Це одна з найважливіших частин цього прикладу.
Звичайні Code, Image, Notice та інші блоки можуть напряму реалізовувати ContentBlockDefinitionInterface. Навіть FormBlockDefinition реалізує інтерфейс для налаштувань представлення без AbstractPresentationBlockDefinition, тому що його контракт даних відрізняється.
Я не намагаюся змусити всі описи блоків успадковувати один базовий клас. Template Method має сенс лише для сімейства класів, які справді мають спільний алгоритм роботи.
Template Method і Strategy вирішують схожу проблему по-різному
Template Method | Strategy |
|---|---|
Відмінності реалізуються через успадкування. | Відмінності реалізуються через композицію. |
Базовий клас володіє каркасом алгоритму. | Код працює з взаємозамінним об’єктом через спільний контракт. |
Точки розширення визначають окремі кроки алгоритму. | Strategy реалізує потрібну поведінку в окремому об’єкті. |
Добре підходить для стабільного сімейства класів зі спільною структурою процесу. | Добре підходить для алгоритмів, які потрібно взаємозамінно використовувати під час виконання. |
Приклад у ICanUp - | Приклад у ICanUp - |
Що потрібно для нового блока представлення
Створити конкретний опис блока на основі
AbstractPresentationBlockDefinition, якщо він справді відповідає цьому контракту.Додати власні
contentRules()іconfigurationRules().Описати
variants()і налаштування для редактора.Визначити
defaultContent()іdefaultConfiguration().Додати локалізовані назви полів для
contentіconfiguration.Створити DTO через
makeData().Зареєструвати опис блока в Content Block Registry.
При цьому не потрібно щоразу заново реалізовувати спільну структуру початкових даних, додавання префіксів до правил валідації або загальне правило для variant.
Що цей Template Method реально дав проєкту
До абстракції | Після абстракції |
|---|---|
Кожен блок міг по-своєму формувати початкові дані. | Верхньорівневу структуру початкових даних визначає батьківський клас. |
Спільні правила валідації легко було пропустити. | Структурна валідація є частиною |
Логіка додавання префіксів дублювалася б у різних описах блоків. | Додавання префіксів зосереджене в одному приватному методі. |
Правило для | Батьківський клас завжди формує |
Зміна спільного контракту вимагала б редагувати багато реалізацій. | Спільний контракт змінюється в одному базовому класі. |
Ціна Template Method
Template Method прив’язує дочірні класи до спільної ієрархії успадкування. Це сильніший зв’язок, ніж робота через інтерфейс і композицію.
Зміна final-алгоритму одразу впливає на всі дочірні класи, тому базовий клас має залишатися невеликим і стабільним.
Також не варто додавати десятки protected-точок розширення лише тому, що одному блоку потрібен виняток. У такій ситуації композиція часто буде чистішим рішенням.
Висновок
У ICanUp AbstractPresentationBlockDefinition не намагається знати, що таке Hero, Cards або FAQ.
Він знає лише стабільний алгоритм: як сформувати початкові дані, як зібрати правила валідації та як підготувати назви полів.
Конкретні описи блоків визначають лише ті кроки, які можуть відрізнятися. А final-методи не дозволяють їм випадково порушити спільний структурний контракт.
Для мене це і є практичний Template Method: успадкування використовується не заради повторного використання випадкових фрагментів коду, а для фіксації спільного каркаса алгоритму з чітко визначеними точками розширення.



