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

Що таке порти й адаптери і як це виглядає в Laravel чи Symfony?

Порт - це інтерфейс, який оголошує саме ядро застосунку під свою потребу, адаптер - реалізація цього інтерфейсу в інфраструктурі поверх конкретної технології. Домен і Application залежать лише від портів, зв'язування адаптерів робить service provider у Laravel або services.yaml у Symfony, а в тестах ті самі порти закриваються in-memory адаптерами.

Ви кажете «гексагональна архітектура». Намалюйте словами, де в ній Laravel.
У вас у use case тип-хінт на інтерфейс `NotifiesCandidate`. Хто вирішує, що туди прилетить Mailgun?
Чим порт відрізняється від звичайного інтерфейсу сервісу? Ви ж просто витягли `interface` і все.
Як протестувати сценарій, який шле лист і ходить у платіжку, без мережі й без моків на кожен виклик?
гексагональна архітектура порти й адаптери dependency inversion DI-контейнер тестування

Гексагон Кокбурна тримається на одній думці: у центрі лежить логіка застосунку, і вона не знає, звідки її покликали й чим саме зберігаються дані. Назовні вона говорить через порти, тобто інтерфейси, оголошені самим ядром. Реалізації цих інтерфейсів називають адаптерами, і живуть вони зовні. Сторін дві. Driving, або primary: HTTP-контролер, консольна команда, обробник черги, webhook - усе це адаптери, які викликають ваш use case. Driven, або secondary: база, пошта, платіжка, S3, чуже REST API - їх ядро викликає саме через свої порти. Відрізняє ці сторони те, хто ініціює виклик; фреймворк в обох випадках лишається зовнішньою деталлю.

Розташування файлу вирішує тут більше, ніж здається. Інтерфейс NotifiesCandidate має лежати в Application (або в Domain, якщо він оперує суто доменними поняттями), поруч із кодом, який ним користується; реалізація на Mailgun - у Infrastructure. Покладете інтерфейс поруч з адаптером - отримаєте зайвий файл і нуль інверсії: Application як залежав від інфраструктурного неймспейсу, так і залежить. Друга половина цього ж правила стосується сигнатур. Порт описується словником задачі: interviewInvited(CandidateId $to, InterviewSlot $slot). Щойно в контракті з'являється Psr\Http\Message\ResponseInterface, масив параметрів вендора або його виняток, ядро прив'язане до бібліотеки через тип, і написати другу реалізацію ви фізично не зможете. Виняток адаптера теж треба перекладати: Stripe\Exception\ApiErrorException ловиться в адаптері й віддається назовні як PaymentDeclined вашого домену.

Композиція живе в одному місці і в обох фреймворках займає рядок. Laravel: $this->app->bind(NotifiesCandidate::class, MailgunCandidateNotifier::class) у register() сервіс-провайдера, singleton() для адаптерів зі станом, контекстна прив'язка when()->needs()->give(), коли одному порту потрібні дві реалізації в різних місцях. Symfony автовайрить за тип-хінтом, але інтерфейс сервісом не є, тому потрібен явний alias у config/services.yaml або атрибут #[AsAlias] на адаптері, а при кількох реалізаціях - #[Autowire(service: ...)] у точці інжекту. Вибір драйвера за конфігом («у dev пишемо в лог, у проді шлемо») робиться там само, у провайдері чи в конфігу контейнера, і ніколи гілкою if (app()->environment()) усередині use case.

Тести і є та друга реалізація, заради якої більшість портів з'являється. RecordingCandidateNotifier з коду вище тримає надіслане в масиві, підставляється через $this->app->instance(...), і тест use case перевіряє результат: створено співбесіду, кандидату пішло рівно одне запрошення на потрібний слот. Таких тестів можна мати сотні, вони йдуть мілісекунди й не залежать від SMTP. Пастка відома: fake поступово розходиться з бойовим адаптером, тести зелені, прод падає. Рятує не ускладнення fake, а контрактний набір тестів проти інтерфейсу, який проганяється на обох реалізаціях, плюс окремий інтеграційний прогін бойового адаптера проти справжньої бази або sandbox-акаунта. Те, що взагалі не відтворюється локально, закривають записаними відповідями через Http::fake() чи MockHttpClient.

Ціну патерну задає кількість портів, а не сама ідея. Кожен порт це інтерфейс, адаптер, рядок у провайдері, тестовий двійник і, якщо за портом сховище, ще й мапінг між доменним об'єктом і persistence-моделлю. На CRUD-адмінці, де сценарій зводиться до валідації й запису в одну таблицю, цей набір нічого не повертає. Тому в реальних проєктах гексагон застосовують точково: у контекст із нетривіальними правилами й кількома зовнішніми інтеграціями, а решту модулів лишають на звичайному Laravel-коді. Остання дірка, через яку патерн тихо протікає в Laravel: фасади. Cache::get() посеред доменного сервісу це адаптер, якого ніхто не оголошував, він не видимий у конструкторі й не підміняється в тестах через контейнер. Ловиться архітектурним тестом, який забороняє Illuminate в Domain і Application.

// src/Hiring/Application/Port/NotifiesCandidate.php
// Порт оголошує ядро під свою потребу: мова застосунку, жодного SDK у сигнатурі
interface NotifiesCandidate
{
    public function interviewInvited(CandidateId $to, InterviewSlot $slot): void;
}

// src/Hiring/Application/InviteToInterview.php
final readonly class InviteToInterview
{
    public function __construct(
        private Interviews $interviews,           // порт до сховища
        private NotifiesCandidate $notifications, // порт до зовнішнього світу
    ) {}

    public function __invoke(InviteCommand $command): InterviewId
    {
        $interview = Interview::schedule($command->candidateId, $command->slot);
        $this->interviews->save($interview);
        $this->notifications->interviewInvited($command->candidateId, $interview->slot());

        return $interview->id();
    }
}

// src/Hiring/Infrastructure/Notification/MailgunCandidateNotifier.php
final readonly class MailgunCandidateNotifier implements NotifiesCandidate
{
    public function __construct(private Mailer $mailer, private CandidateEmails $emails) {}

    public function interviewInvited(CandidateId $to, InterviewSlot $slot): void
    {
        // Illuminate, шаблон листа і формат дати живуть тільки тут
        $this->mailer->to($this->emails->of($to))
            ->send(new InterviewInvitation($slot->startsAt()->format('d.m.Y H:i')));
    }
}

// app/Providers/AppServiceProvider.php: єдине місце, де порт зустрічає адаптер
$this->app->bind(NotifiesCandidate::class, MailgunCandidateNotifier::class);

// tests/Support/RecordingCandidateNotifier.php: in-memory адаптер замість мока
final class RecordingCandidateNotifier implements NotifiesCandidate
{
    /** @var list<array{CandidateId, InterviewSlot}> */
    public array $sent = [];

    public function interviewInvited(CandidateId $to, InterviewSlot $slot): void
    {
        $this->sent[] = [$to, $slot];
    }
}
Що порт оголошує ядро під свою потребу, тому інтерфейс лежить у Domain або Application, а реалізація в Infrastructure; інтерфейс поруч із класом-адаптером нічого не інвертує.
Розрізнення driving (primary) і driven (secondary) сторін: HTTP-контролер, консольна команда й queue-обробник - це три адаптери до одного use case, а база, пошта й платіжка - адаптери, які use case викликає через порти.
Що сигнатура порту описана мовою застосунку, а не SDK: жодних `ResponseInterface`, `Model`, масивів опцій вендора й вендорських винятків у контракті.
Конкретику зв'язування: `bind`/`singleton` у `AppServiceProvider::register()`, контекстні прив'язки для двох реалізацій одного порту; у Symfony - autowiring за тип-хінтом плюс явний alias у `services.yaml` або `#[AsAlias]`.
Що in-memory адаптер дає швидкі тести use case, але його треба тримати чесним: спільний набір контрактних тестів проганяється і на ньому, і на бойовій реалізації.
Класти інтерфейс у `Infrastructure` поруч із реалізацією: залежність лишилась тією ж, просто через додатковий файл.
Протягувати в порт типи бібліотеки: `public function charge(array $stripeParams): \Stripe\Charge` - ядро знову прибите до вендора, і in-memory адаптер написати нема як.
Робити порт на кожен клас, включно з `Str::slug()` чи власним хелпером форматування: порт має сенс там, де за межею процес, мережа, диск або недетермінізм.
Викликати всередині доменного коду фасади (`Mail::`, `Cache::`, `Storage::`): формально порти є, фактично поруч живе прихований адаптер, який архітектурний тест на `use Illuminate` не завжди ловить.
Замість in-memory адаптера писати мок з `shouldReceive('send')->once()`: тест починає перевіряти послідовність викликів замість результату й ламається на будь-якому рефакторингу.
ПОРАДА

Дайте перевірний критерій замість визначення: якщо з композера прибрати laravel/framework, код у Domain і Application має лишитись таким, що компілюється, а зламатись мають тільки адаптери. Далі назвіть три речі: де лежить інтерфейс, у якому рядку провайдера він зв'язується з реалізацією і як виглядає in-memory адаптер у тесті.

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

Суть патерну в напрямку залежності: контракт належить ядру й описаний його мовою, а конкретна технологія лишається зовні, за реалізацією. Тестовий двійник при цьому є повноцінною другою реалізацією, і саме заради нього порт часто й з'являється.