Decorator і Proxy з каталогу GoF мають однакову будову: клас реалізує той самий інтерфейс, що й справжній обʼєкт, тримає на нього посилання й пересилає виклики. Тому розрізняють їх за тим, навіщо обгортка існує. Декоратор отримує готовий обʼєкт і додає до нього поведінку: кеш, лог, повтори, метрики. Таких шарів може бути кілька, і порядок задає той, хто збирає обʼєкт. Проксі відповідає за доступ: відкладає створення важкого обʼєкта, перевіряє права, пересилає виклик в інший процес. Межа не завжди чітка, і кешувальну обгортку в літературі називають то декоратором, то caching proxy. На співбесіді важливіше показати, що ви розумієте обидва наміри, ніж наполягати на одній назві.
Обидві обгортки є у фреймворках. Декоратори в Laravel вмикають через $this->app->extend() у провайдері, у Symfony через #[AsDecorator] з #[AutowireDecorated], і в обох випадках код, що отримує інтерфейс, нічого не помічає. Проксі найчастіше зустрічаються ліниві: Doctrine підставляє їх замість повʼязаних сутностей, Symfony робить ліниві сервіси з lazy: true або #[Lazy]. З PHP 8.4 мова має для цього власний API, ReflectionClass::newLazyProxy() і newLazyGhost(), і Symfony 7.4 на PHP 8.4 будує на ньому ліниві сервіси з конкретним класом, а згенеровані класи лишає для інтерфейсів.
Кешувальний репозиторій як декоратор виправданий, коли кеш потрібен усім, хто читає вакансії, а не одному контролеру. Найважливіша частина тут не remember(), а інвалідація. Ключ скидає та сама обгортка у своєму save(), щоб жоден запис не пройшов повз кеш. Скидати треба після коміту: якщо ключ зник усередині транзакції, паралельний запит прочитає з бази ще стару версію і покладе її назад на весь TTL. У Laravel для цього є DB::afterCommit(), яке поза транзакцією виконує колбек одразу. Кешувати варто доменний обʼєкт або DTO, а не Eloquent-модель з відношеннями, бо разом з нею в кеш потрапляє весь граф.
Декоратор стає дорогим, коли інтерфейс широкий: обгортка мусить реалізувати кожен метод і переслати далі ті, що її не стосуються. Якщо кеш потрібен лише для читання, а інтерфейс має дванадцять методів запису, це підказка розділити його на читання й запис і загорнути тільки першу частину. Проксі має свої пастки: лінивий обʼєкт може ініціалізуватися там, де ресурсу вже немає (закрите зʼєднання, завершений запит у довгоживучому воркері), а помилка зʼявиться далеко від місця, де обʼєкт створили.
// Decorator: той самий інтерфейс, а зверху додана поведінка (кеш)
final class CachedVacancyRepository implements VacancyRepository
{
public function __construct(
private VacancyRepository $inner,
private CacheRepository $cache,
) {}
public function get(int $id): Vacancy
{
return $this->cache->remember("vacancy:{$id}", 600, fn () => $this->inner->get($id));
}
public function save(Vacancy $vacancy): void
{
$this->inner->save($vacancy);
// Скидаємо кеш після коміту: інакше паралельний запит встигне
// покласти в кеш стару версію, ще видиму в базі до коміту
DB::afterCommit(fn () => $this->cache->forget("vacancy:{$vacancy->id}"));
}
}
// AppServiceProvider::register(): обгортку отримують усі, хто просить інтерфейс
$this->app->extend(
VacancyRepository::class,
fn (VacancyRepository $inner, $app) => new CachedVacancyRepository($inner, $app['cache.store']),
);
// Proxy: той самий тип, але він керує доступом до справжнього обʼєкта.
// PHP 8.4 робить лінивий проксі без кодогенерації:
$report = (new ReflectionClass(SalaryReport::class))
->newLazyProxy(fn () => SalaryReport::buildFromWarehouse());
// Фабрика спрацює при першому доступі до стану обʼєкта, наприклад до властивості
echo count($report->rows);
Почніть з того, що за кодом патерни однакові, і розведіть їх за наміром: декоратор не вирішує, чи існує справжній обʼєкт, а проксі саме цим і займається. Потім одразу перейдіть до інвалідації після коміту: про неї забувають частіше, ніж про сам кеш.