Гексагон Кокбурна тримається на одній думці: у центрі лежить логіка застосунку, і вона не знає, звідки її покликали й чим саме зберігаються дані. Назовні вона говорить через порти, тобто інтерфейси, оголошені самим ядром. Реалізації цих інтерфейсів називають адаптерами, і живуть вони зовні. Сторін дві. 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];
}
}
Дайте перевірний критерій замість визначення: якщо з композера прибрати laravel/framework, код у Domain і Application має лишитись таким, що компілюється, а зламатись мають тільки адаптери. Далі назвіть три речі: де лежить інтерфейс, у якому рядку провайдера він зв'язується з реалізацією і як виглядає in-memory адаптер у тесті.