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

Репозиторій поверх Eloquent або Doctrine: коли він потрібен, а коли шкодить?

Репозиторій виправданий, коли він виглядає як колекція агрегатів у мові домену й тримає доменний шар без Illuminate чи Doctrine; він шкодить, коли перетворюється на generic CRUD-обгортку, що повертає той самий Builder або модель і лише додає шар.

Навіщо репозиторій, якщо Eloquent-модель уже вміє where() і find()?
Ви пишете репозиторії, щоб потім замінити MySQL на MongoDB?
Doctrine вже дає EntityRepository — навіщо ще один шар поверх нього?
Чому у вашому репозиторії зʼявився метод findActiveByCityPaginatedSortedBySalary()?
репозиторій Eloquent Doctrine Active Record Data Mapper

Питання зводиться до того, що саме патерн обіцяє. Репозиторій — це колекціє-подібний інтерфейс над агрегатами: ти кладеш туди обʼєкт і дістаєш обʼєкт, не знаючи, чи за ним таблиця, чи HTTP-API. Ключове слово — «не знаючи»: якщо метод повертає Illuminate\Database\Eloquent\Builder або колекцію моделей, знання про ORM просто переїхало на рівень вище, і жодної інверсії залежності не сталося. Тому першою ознакою корисного репозиторію є його інтерфейс: ofId(), publishedIn(), save() читаються без згадки про ORM, а findAllWhere(array $criteria) — ні.

Друга половина відповіді — різниця в тому, поверх чого ви будуєте шар. Eloquent реалізує Active Record: модель сама вміє себе зберігати, save() летить у базу негайно, а $model->relation тягне ленивий запит. Doctrine реалізує Data Mapper: сутність — звичайний PHP-обʼєкт, зміни накопичує Unit of Work, а в базу вони йдуть на EntityManagerInterface::flush(). Через це в Doctrine репозиторій уже вбудований (EntityRepository, у Symfony — ServiceEntityRepository з doctrine/doctrine-bundle) і відповідає лише за читання: писати другу обгортку поверх нього безглуздо, достатньо, щоб цей клас реалізовував доменний інтерфейс. А ось для Eloquent репозиторій — це справжня робота: треба мапити модель у доменний обʼєкт і назад, бо інакше ви віддаєте назовні persistence-обʼєкт і абстракція існує лише на папері.

Коли шар справді потрібен. Перше — коли в проєкті є доменний і аплікаційний шари, яким заборонено бачити фреймворк (у цьому репозиторії це прямо перевіряють архітектурні тести). Друге — коли складна логіка вибірки має назву в мові бізнесу і повторюється в кількох місцях. Третє — тести: in-memory реалізація того самого інтерфейсу дає швидкі юніт-тести use case без бази, і це часто головна практична вигода. А от аргумент «підмінимо базу» на співбесіді працює проти вас: зміна MySQL на PostgreSQL чи документну базу міняє запити, типи й гарантії транзакцій, і одним новим класом не обійдеться.

Коли шкодить. Generic BaseRepository з find/all/create/update/delete для кожної моделі — це гірша копія Eloquent: ви втрачаєте скоупи, with(), chunk(), cursor(), paginate(), а натомість не отримуєте нічого. Так само шкодить репозиторій, що обростає методами під кожен екран: findActiveByCityPaginatedSortedBySalary — сигнал, що читання для UI треба винести в окремий query-сервіс, який віддає DTO або пагінатор проєкцій повз домен. У Laravel-застосунку без виділеного домену дешевша й чесніша альтернатива — тонкі скоупи на моделі (scopeActive() або атрибут #[Scope] у Laravel 12) плюс query-обʼєкти для важких вибірок.

Межа проходить по транзакціях і межах агрегату. Репозиторій не керує транзакцією: її відкриває use case через DB::transaction() або wrapInTransaction(), а в Doctrine flush() викликається один раз наприкінці сценарію, інакше два агрегати перестають комітитись атомарно. І репозиторій існує на агрегат, а не на таблицю: рядки vacancy_skills зберігаються разом із вакансією, а не мають власний VacancySkillRepository. Якщо в проєкті немає агрегатів і немає доменного шару — швидше за все, немає й потреби в репозиторії.

// Domain: інтерфейс живе поруч з агрегатом і не знає ані про Illuminate, ані про Doctrine
interface Vacancies
{
    public function ofId(VacancyId $id): ?Vacancy;          // один агрегат
    public function publishedIn(City $city): VacancyList;    // назва з мови бізнесу, не з SQL
    public function save(Vacancy $vacancy): void;            // без flush і без транзакції
}

// Infrastructure: єдине місце, де є ORM і мапінг у доменні обʼєкти
final class EloquentVacancies implements Vacancies
{
    public function ofId(VacancyId $id): ?Vacancy
    {
        $row = VacancyModel::query()->find($id->toString());

        return $row === null ? null : $this->toDomain($row);
    }

    public function publishedIn(City $city): VacancyList
    {
        // eager loading — деталь інфраструктури, назовні Builder не тече
        $rows = VacancyModel::query()
            ->with('company')
            ->where('status', 'published')
            ->where('city', $city->value)
            ->get();

        return new VacancyList($rows->map($this->toDomain(...))->all());
    }

    public function save(Vacancy $vacancy): void
    {
        // Eloquent пише одразу; межу транзакції відкриває use case, а не репозиторій
        VacancyModel::query()->updateOrCreate(
            ['id' => $vacancy->id()->toString()],
            ['title' => $vacancy->title(), 'salary_from' => $vacancy->salary()->from()],
        );
    }

    private function toDomain(VacancyModel $row): Vacancy
    {
        return Vacancy::restore(new VacancyId($row->id), $row->title, new City($row->city));
    }
}
Що репозиторій — це колекціє-подібна абстракція над агрегатами, а не обгортка над таблицею: назовні йдуть доменні обʼєкти, а не Builder і не Eloquent Collection.
Що мотивація «замінимо базу» майже ніколи не реалізується; реальні мотиви — тримати Domain/Application без залежності від ORM, називати запити мовою бізнесу й підставляти in-memory реалізацію в тестах.
Різницю Active Record і Data Mapper: Eloquent-модель сама себе зберігає (`save()`), Doctrine розділяє сутність і `EntityManagerInterface` з `persist()`/`flush()` та Unit of Work.
Що інтерфейс належить доменному шару, а реалізація — інфраструктурному; інакше залежність нікуди не інвертувалась.
Що читання для UI (списки з фільтрами, сортуванням і пагінацією) розумно виносити в окремий query-сервіс повз репозиторій, а не роздувати його методами під кожен екран.
Що межа транзакції — це application service (`DB::transaction()`, `EntityManagerInterface::wrapInTransaction()`), а не метод репозиторію.
Обґрунтовувати репозиторій тим, що «завтра підмінимо MySQL на Mongo»: різні бази означають різні запити й різні гарантії, підміна реалізації однаково не буде безболісною.
Повертати з репозиторію `Builder` або колекцію Eloquent-моделей: абстракція протікає, бо клієнтський код усе одно дописує `->where()` і `->with()`.
Писати `BaseRepository` з generic `find/all/create/update/delete` для всіх моделей — це другий Eloquent, тільки бідніший: без скоупів, eager loading, `chunk()` і `cursor()`.
Плодити методи під кожен екран (`findActiveByCityPaginatedSortedBySalary`): 30 методів в інтерфейсі — сигнал, що потрібні критерії або окремий read-модель.
Класти інтерфейс поруч із реалізацією в Infrastructure: формально інтерфейс є, а домен усе одно тягне ORM.
У Doctrine викликати `flush()` усередині `save()` репозиторію: це ламає Unit of Work і робить неможливою одну транзакцію на кілька агрегатів.
ПОРАДА

Скажіть коротко: «репозиторій виправданий тоді, коли його інтерфейс можна прочитати не знаючи, що там ORM». Якщо в сигнатурах зʼявляються Builder, масиви `['where' => ...]` чи пагінатор — це вже не репозиторій, а обгортка, і вона шкодить.

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

Цінність не в шарі як такому, а в інверсії залежності й доменному словнику; обгортка, що віддає той самий Builder, нічого не інвертує.