<? phpukraine СПІВБЕСІДИ
⌕Пошук по платформі
АРХІТЕКТУРА · SENIOR

Decorator і Proxy: чим вони відрізняються і як правильно загорнути репозиторій у кеш?

За кодом їх не відрізнити: обидва реалізують той самий інтерфейс і тримають посилання на справжній обʼєкт. Декоратор додає поведінку (кеш, лог, повтори) і складається шарами там, де збирають обʼєкт, а проксі керує доступом до справжнього обʼєкта: відкладає його створення, перевіряє права або пересилає виклик кудись далі. Кешувальна обгортка репозиторію працює, лише якщо інвалідація живе в тій самій обгортці й спрацьовує після коміту.

Ви додали кеш до репозиторію обгорткою. Це декоратор чи проксі?
Після редагування вакансії сайт ще десять хвилин показує стару назву, хоча `save()` скидає кеш. Що могло статися?
Що таке lazy proxy і де ви його зустрічали в Doctrine або Symfony?
Як додати кеш до репозиторію, не змінюючи ні його, ні сервіси, які його викликають?
design patterns Decorator Proxy lazy objects кеш репозиторій PHP 8.4

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);
Що кандидат розрізняє патерни за наміром, а не за структурою: інтерфейс однаковий в обох, змінює інтерфейс адаптер.
Що він знає, де ці обгортки вже є: `$app->extend()` у Laravel, `#[AsDecorator]` з `#[AutowireDecorated]` у Symfony, ліниві сервіси Symfony, проксі асоціацій Doctrine, `ReflectionClass::newLazyProxy()` і `newLazyGhost()` у PHP 8.4.
Що кешувальний декоратор кешує лише читання, будує ключ з ідентифікатора, скидає ключ у власному `save()` і робить це після коміту транзакції.
Що порядок шарів має наслідки: лог зовні кешу бачить кожен виклик, лог усередині бачить лише промахи.
Що широкий інтерфейс робить декоратор дорогим, і це сигнал розділити інтерфейс на читання й запис.
Розрізняти за інтерфейсом: «декоратор змінює інтерфейс, проксі ні». Інтерфейс однаковий в обох, інший інтерфейс дає адаптер.
Скидати кеш усередині транзакції: паралельний запит встигає прочитати з бази ще стару версію й покласти її назад у кеш на весь TTL.
Робити кешування через `extends EloquentVacancyRepository` з перевизначеним `get()`: кеш привʼязаний до однієї реалізації, in-memory репозиторій у тестах його не має, а комбінація «кеш плюс лог» вимагає ще одного підкласу.
Класти в кеш Eloquent-модель із завантаженими відношеннями: серіалізується весь граф, і після зміни повʼязаного запису кеш показує старі дані, хоча сама модель не змінювалась.
Називати проксі лінивим завантаженням у Eloquent: відношення підвантажує сама модель через магічний `__get`, окремого обʼєкта-замісника там немає.
ПОРАДА

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

оновлено 8 жовтня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Структурно обидва патерни реалізують той самий інтерфейс і делегують справжньому обʼєкту, тому відрізняються наміром. Інтерфейс змінює адаптер. Проксі бувають і рукописні, і згенеровані, а декоратор у Symfony контейнер підставляє сам через `#[AsDecorator]`.