<? phpukraine PHP-ПЛАТФОРМА
⌕Пошук по платформі
ПАТЕРН ЗАСТОСУНКУ

РепозиторійRepository

Колекція доменних обʼєктів, за якою ховається база даних. Код бере й зберігає обʼєкти через інтерфейс і не знає, що там SQL.

Symfony складність 2 з 3 6 хв читання
ГЛІФ
01 · ПРОБЛЕМА

Запити до бази розкидані по всьому коду

Сайт вакансій показує відкриті вакансії в контролері, шле дайджест у Telegram з команди й будує sitemap. У кожному місці свій ланцюжок where(), і правило «вакансію видно» скопійоване тричі з різними умовами. Щоб перевірити хоч одне правило, тест мусить піднімати базу.

app/Http/Controllers/JobController.php
1final class JobController
2{
3 public function index(Request $request): View
4 {
5 $jobs = Job::query()
1Контролер говорить мовою таблиць і стовпців, тож без бази цей код не перевірити.
6 ->where('status', 'published')
7 ->where('expires_at', '>', now())
2У дайджесті для Telegram цей where() забули, і туди потрапляють прострочені вакансії.
8 ->where('city', $request->query('city'))
9 ->latest('published_at')
10 ->paginate(20);
11
12 return view('jobs.index', ['jobs' => $jobs]);
13 }
14
15 public function close(Job $job): RedirectResponse
16 {
17 $job->update(['status' => 'closed', 'closed_at' => now()]);
3Закрити вакансію означає змінити два стовпці. Кожне місце, де закривають, мусить памʼятати обидва.
18 Cache::forget('jobs:count');
19
20 return back();
21 }
22}
02 · ІДЕЯ РІШЕННЯ

Домен просить, інфраструктура дістає

ДІАГРАМА · УСЯ СХЕМА
Кроки: 4
get()повертаєSQLCloseJob__invoke(id)«interface»JobRepositoryget() · openIn() · save():JobінфраструктураEloquentJobRepositoryтестиInMemoryJobRepositoryPostgreSQL:array
CloseJob__invoke(id)«interface»JobRepositoryget() · openIn() · save():JobінфраструктураEloquentJobRepositoryтестиInMemoryJobRepositoryPostgreSQL:array
Уся схема. Натисніть → або виберіть крок нижче, щоб пройти по ній частинами.
Абстракція Ключовий учасник Клієнт реалізує · - - ▷
  • CloseJob використовує JobRepository (get()).
  • JobRepository використовує Job (повертає).
  • EloquentJobRepository реалізує JobRepository.
  • InMemoryJobRepository реалізує JobRepository.
  • EloquentJobRepository використовує PostgreSQL (SQL).
  • InMemoryJobRepository використовує array.
03 · КОД

Запустіть і подивіться

PHP 8.5
1final class JobController
2{
3 public function index(Request $request): View
4 {
5 $jobs = Job::query()
6 ->where('status', 'published')
7 ->where('expires_at', '>', now())
8 ->where('city', $request->query('city'))
9 ->latest('published_at')
10 ->paginate(20);
11
12 return view('jobs.index', ['jobs' => $jobs]);
13 }
14
15 public function close(Job $job): RedirectResponse
16 {
17 $job->update(['status' => 'closed', 'closed_at' => now()]);
18 Cache::forget('jobs:count');
19
20 return back();
21 }
22}
1<?php
2
3final class Job
4{
5 public function __construct(
6 public readonly int $id,
7 public readonly string $title,
8 public readonly string $city,
9 private bool $open = true,
10 ) {}
11
12 public function isOpen(): bool
13 {
14 return $this->open;
15 }
16
17 public function close(): void
18 {
19 $this->open = false;
20 }
21}
22
231interface JobRepository
24{
25 public function get(int $id): Job;
26
27 /** @return list<Job> */
28 public function openIn(string $city): array;
29
30 public function save(Job $job): void;
31}
32
332final class InMemoryJobRepository implements JobRepository
34{
35 /** @var array<int, Job> */
36 private array $jobs = [];
37
38 public function get(int $id): Job
39 {
40 return $this->jobs[$id] ?? throw new DomainException("Вакансії #{$id} немає");
41 }
42
43 public function openIn(string $city): array
44 {
45 return array_values(array_filter(
46 $this->jobs,
47 fn (Job $job): bool => $job->isOpen() && $job->city === $city,
48 ));
49 }
50
51 public function save(Job $job): void
52 {
53 $this->jobs[$job->id] = $job;
54 }
55}
56
573final class CloseJob
58{
59 public function __construct(private readonly JobRepository $jobs) {}
60
61 public function __invoke(int $id): void
62 {
63 $job = $this->jobs->get($id);
64 $job->close();
65 $this->jobs->save($job);
66 }
67}
68
694$jobs = new InMemoryJobRepository();
70$jobs->save(new Job(1, 'Senior Laravel Developer', 'Київ'));
71$jobs->save(new Job(2, 'Symfony Developer', 'Львів'));
72$jobs->save(new Job(3, 'PHP Team Lead', 'Київ'));
73
74(new CloseJob($jobs))(3);
75
76foreach (['Київ', 'Львів'] as $city) {
77 $titles = array_map(fn (Job $job): string => $job->title, $jobs->openIn($city));
78 echo $city, ': ', implode(', ', $titles), "\n";
79}
80
81try {
82 (new CloseJob($jobs))(42);
83} catch (DomainException $e) {
84 echo 'Помилка: ', $e->getMessage(), "\n";
85}
Перший запуск завантажить рушій PHP 8.5, приблизно 3,8 МБ Відкрити в пісочниці ↗
ВИВІД
Завантажуємо рушій PHP 8.5
Лише перший раз. Далі код запускається миттєво, рушій лишається в кеші браузера.
$ php main.php ▍
  1. 1 рядки 23–31

    Інтерфейс живе в домені поруч із Job. Методи названі так, як про вакансії говорить бізнес: openIn(), а не where().

  2. 2 рядки 33–55

    Реалізація на масиві. У продакшні поруч стоїть EloquentJobRepository з тим самим інтерфейсом. На PHP Ukraine так і зроблено: інтерфейс у src/Hiring/Domain, Eloquent у src/Hiring/Infrastructure/Persistence, фейк у tests/Support.

  3. 3 рядки 57–67

    Сервіс отримує репозиторій у конструкторі й робить три кроки: взяти, змінити, зберегти. Про базу він не знає.

  4. 4 рядки 69–74

    Яку реалізацію підставити, вирішує той, хто збирає обʼєкти: тут скрипт, у Laravel контейнер.

  5. Наведіть на пояснення, щоб підсвітити рядки.Торкніться пояснення, щоб підсвітити рядки.
04 · У ВАШОМУ ФРЕЙМВОРКУ

Ви вже цим користуєтесь

LARAVEL

Eloquent і власний репозиторій

Eloquent побудований на Active Record: модель сама і дані, і доступ до бази, тож Job::where() вже нагадує репозиторій. Окремого класу репозиторію Laravel не дає. Його пишуть самі, коли домен має жити без Eloquent, і привʼязують до інтерфейсу в сервіс-провайдері.

// AppServiceProvider::register()
$this->app->bind(JobRepository::class, EloquentJobRepository::class);
 
// будь-який сервіс
public function __construct(private JobRepository $jobs) {}

Ви вже користуєтесь цим, коли інжектуєте інтерфейс у конструктор, а контейнер підставляє реалізацію з bind().

SYMFONY

Doctrine EntityRepository

Doctrine дає репозиторій з коробки. Сутність називає свій клас у #[ORM\Entity(repositoryClass: ...)], а базовий ServiceEntityRepository уже вміє find(), findBy() і findOneBy(). Власні вибірки пишуть поруч, через QueryBuilder або DQL.

#[ORM\Entity(repositoryClass: JobRepository::class)]
class Job { /* ... */ }
 
class JobRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Job::class);
    }
 
    public function openIn(string $city): array
    {
        return $this->findBy(['city' => $city, 'open' => true]);
    }
}

Ви вже користуєтесь цим, коли отримуєте JobRepository в сервіс через автовайринг або викликаєте $entityManager->getRepository(Job::class).

✓ ЯК ЦЕ ТЕСТУВАТИ

Сервіси тестуйте з in-memory репозиторієм: тест іде за мілісекунди й не потребує бази. Eloquent-реалізацію перевіряйте окремо, інтеграційним тестом на справжній базі.

it('closes the job', function () {
    $jobs = new InMemoryJobRepository();
    $jobs->save(new Job(3, 'PHP Team Lead', 'Київ'));
 
    (new CloseJob($jobs))(3);
 
    expect($jobs->openIn('Київ'))->toBe([]);
});
05 · КОЛИ НЕ ТРЕБА

CRUD без правил не потребує прошарку

Якщо модель збігається з таблицею, а правил немає, як в адмінці довідників, репозиторій лише переказує виклики Eloquent. Пишіть запит прямо в контролері або в query scope.

// репозиторій, який нічого не ховає
public function find(int $id): ?City
{
    return City::find($id);
}
06 · СУСІДИ

Схожі на вигляд, різні за змістом

07 · ПЕРЕВІР СЕБЕ

Питання зі співбесід

Усі питання з архітектури →

Модель Eloquent це Active Record: обʼєкт сам себе зберігає й знає про свою таблицю. Репозиторій окремий обʼєкт, який дістає й зберігає доменні обʼєкти, а самі вони про базу нічого не знають.

Інтерфейс у домені, бо саме домен вирішує, які вибірки йому потрібні. Реалізація в інфраструктурі, бо знає про Eloquent чи Doctrine. Так домен не залежить від бази, і архітектурний тест може це перевірити.

Агрегат зберігається цілком, тому репозиторій приймає й повертає корінь з усім вмістом. Окремий репозиторій для внутрішнього обʼєкта дозволив би змінити його в обхід кореня та його інваріантів.