0% прочитано

Adapter Pattern у Laravel: як IndexNowClientInterface ізолює зовнішній HTTP API

Application layer ICanUp повинен лише відправити список public URLs, а не знати endpoint, API key, HTTP payload та Laravel Http facade. Показую, як IndexNowClientInterface став port, а IndexNowHttpClient адаптує цей простий contract до зовнішнього IndexNow API.

29 вересня 2026 р. 10 хв читанняЛаравель

Коли ICanUp почав автоматично повідомляти пошукові системи про нові та змінені публічні URL, найпростішим рішенням було б викликати Laravel Http безпосередньо з job або сервісу.

Технічно це працювало б. Але тоді прикладний код почав би знати деталі зовнішнього протоколу IndexNow: endpoint, host, ключ перевірки, адресу цього ключа, структуру JSON-запиту та правила обробки HTTP-помилок.

Насправді прикладному шару потрібна значно простіша операція: передати ці URL на індексацію.

Тому взаємодія із зовнішнім HTTP API у мене прихована за невеликим контрактом - IndexNowClientInterface.

Тут важливо правильно назвати ролі. `IndexNowClientInterface` сам по собі не є Adapter. Це порт або прикладний контракт, через який внутрішній код звертається до зовнішньої можливості. Adapter у цьому випадку - `IndexNowHttpClient`, тому що саме він перетворює внутрішній контракт на конкретну взаємодію із зовнішнім HTTP-протоколом IndexNow.

Проблема не в самому HTTP-запиті, а в напрямку залежності

PHP - Application job coupled to external HTTP details
final class SubmitIndexNowUrlsJob{    public function handle(): void    {        Http::post(            config('indexnow.endpoint'),            [                'host' =>                    parse_url(                        config('app.url'),                        PHP_URL_HOST,                    ),                 'key' =>                    config('indexnow.key'), 

У такому варіанті job одночасно відповідає і за координацію прикладного сценарію, і за деталі зовнішнього протоколу.

Якщо зміняться endpoint, структура запиту, спосіб автентифікації або навіть HTTP-клієнт, доведеться змінювати й прикладний код.

Тест для job також буде змушений підміняти HTTP-взаємодію, хоча бізнес-питання тут значно простіше: чи були правильні URL передані клієнту для індексації.

Прикладний контракт описує намір, а не деталі HTTP-взаємодії

PHP - Application-facing IndexNow port
interface IndexNowClientInterface{    /**     * @param array<int, string> $urls     */    public function submit(        array $urls,    ): void;}

У цьому контракті немає згадок про Http, endpoint, заголовки, JSON або API key.

Код, який його викликає, передає лише масив URL. Це вся інформація, яку прикладний шар повинен знати для виконання цієї операції.

Такий інтерфейс зберігає стабільну внутрішню мову системи, навіть якщо зовнішній протокол значно складніший.

Завдання черги залежить від порту, а не від конкретної HTTP-реалізації

PHP - Application flow uses only the client contract
public function handle(    IndexNowUrlService $indexNowUrlService,    IndexNowClientInterface $client,): void {    if (        ! (bool) config(            'indexnow.enabled'        )    ) {        return;    }     $post = Post::query()        ->find(            $this->postId        );

Job вирішує прикладні питання: чи ввімкнена функціональність, чи сутність ще існує, чи доступна вона публічно і які URL потрібно передати на індексацію.

Після цього межа відповідальності стає дуже чіткою: $client->submit($urls).

Job не знає, як ці URL будуть перетворені на HTTP-запит і на який саме endpoint вони будуть відправлені.

IndexNowHttpClient адаптує внутрішній контракт до зовнішнього протоколу IndexNow

PHP - Infrastructure adapter translates URLs into IndexNow HTTP
final readonly class IndexNowHttpClient    implements IndexNowClientInterface{    public function submit(        array $urls,    ): void {        $urls = array_filter(            $urls,            static fn (                mixed $url            ): bool =>                is_string($url)                && $url !== '',        )            |> array_unique(...)            |> array_values(...);

Саме тут зосереджені всі деталі зовнішнього протоколу.

Adapter очищає вхідні дані, прибирає дублікати URL, відновлює послідовну нумерацію елементів і формує пакетний запит IndexNow із host, key, keyLocation та urlList.

Laravel HTTP client при цьому теж залишається деталлю інфраструктурного шару.

Adapter - це перекладач між двома контрактами

Внутрішня сторона

Зовнішня сторона

submit(array $urls)

HTTP POST-запит

Публічні URL

urlList

Код, що викликає клієнт, не знає про API key

key береться з конфігурації

Код, що викликає клієнт, не знає про host

host формується з URL застосунку

Код, що викликає клієнт, не знає про транспорт

Laravel HTTP client

Adapter корисний не тому, що HTTP сам по собі складний. Його цінність у тому, що зовнішній контракт не повинен ставати внутрішнім контрактом застосунку.

Laravel container з’єднує port та adapter

PHP - Bind the application port to the infrastructure adapter
$this->app->bind(    IndexNowClientInterface::class,    IndexNowHttpClient::class,);

Прикладний код залежить від інтерфейсу, а контейнер Laravel підставляє робочу реалізацію.

Завдяки цьому напрямок залежності спрямований усередину: прикладний шар визначає потрібний контракт, а інфраструктурний шар його реалізує.

Це водночас добре демонструє принцип Dependency Inversion, але Adapter і DIP тут не одне й те саме. DIP визначає напрямок залежності, а Adapter відповідає за перетворення одного контракту на інший.

Тест прикладного шару не потребує справжнього HTTP Adapter

PHP - Replace the adapter with a test implementation
$client = new class ()    implements IndexNowClientInterface{    /**     * @var array<int, string>     */    public array $submittedUrls = [];     public function submit(        array $urls,    ): void {        $this->submittedUrls =            $urls;    }}; 

Для тесту прикладного шару мені не потрібно перевіряти JSON, endpoint або використовувати Laravel Http::fake().

Я можу передати просту тестову реалізацію інтерфейсу й перевірити, які саме URL отримав клієнт.

Так тест перевіряє поведінку прикладного коду, а не дублює перевірки, які належать до інфраструктурного шару.

HTTP Adapter тестується окремо як межа інфраструктурного шару

PHP - Test the infrastructure adapter at its own boundary
it(    'binds indexnow client contract '    .'to http implementation',    function (): void {        expect(            app(                IndexNowClientInterface::class            )        )->toBeInstanceOf(            IndexNowHttpClient::class        );    }); it(    'does not send request '

Окремий тест інфраструктурного шару вже може знати про IndexNowHttpClient, Laravel Http facade та структуру зовнішнього запиту.

У ICanUp цей тест також перевіряє прив’язку реалізації в контейнері й те, що порожній список URL не створює HTTP-запиту.

У результаті є два окремі рівні тестування з різними зонами відповідальності й різними причинами можливого падіння.

Adapter нічого не знає про правила публікації

PHP - Publication policy remains outside the adapter
if (    $post->published_at === null    || $post->published_at        ->getTimestamp()        !== $this            ->expectedPublishedAtTimestamp) {    return;} if (    ! $post->isPubliclyVisible()) {    return;} 

IndexNowHttpClient не перевіряє, чи Post є чернеткою, запланованою публікацією або вже опублікованим матеріалом. Він також не формує canonical URL.

Ці рішення належать прикладному та доменному рівням. Job перевіряє актуальний стан публікації, IndexNowUrlService формує потрібні URL, і лише після цього Adapter отримує вже готовий список.

Завдяки цьому інфраструктурний Adapter залишається вузьким за відповідальністю: він перетворює внутрішній запит на зовнішню HTTP-взаємодію, але не містить бізнес-правил.

Помилка зовнішнього сервісу теж залишається за межами прикладного шару

HTTP-запит може завершитися помилкою через проблеми з мережею, тайм-аут або помилкову відповідь зовнішнього сервісу. Це вже особливості інфраструктурного рівня, і прикладний код не повинен дублювати HTTP-специфічну обробку в усіх місцях виклику.

Завдання в черзі можуть мати власну політику повторних спроб, а Adapter відповідає за конкретну HTTP-взаємодію.

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

Що станеться, якщо зміниться зовнішня інтеграція

Зміна

Де вона має залишитися

Інший HTTP endpoint

IndexNowHttpClient або конфігурація.

Інша структура запиту IndexNow

IndexNowHttpClient.

Інший HTTP-клієнт у Laravel/PHP

Інфраструктурний Adapter.

Інший механізм роботи з API key

Інфраструктурний Adapter або конфігурація.

Інший зовнішній провайдер із тим самим внутрішнім наміром

Нова реалізація IndexNowClientInterface.

Нові правила публікації

Доменний або прикладний шар, але не Adapter.

Adapter не те саме, що Strategy

Strategy

Adapter

Обирає один із кількох взаємозамінних алгоритмів.

Перетворює один контракт на інший.

Реалізації виконують одну й ту саму бізнес-роль.

Реалізація працює на межі із зовнішньою інтеграцією.

SearchProvider виконує конкретний алгоритм пошуку.

IndexNowHttpClient перетворює внутрішній submit($urls) на HTTP-взаємодію з IndexNow.

Відмінності є частиною поведінки самого застосунку.

Відмінності виникають через вимоги зовнішньої інфраструктури.

Adapter не те саме, що Factory

У попередній статті ThreadResolverFactory вирішував, який resolver потрібно повернути коду, що його викликає.

Тут IndexNowHttpClient нічого не вибирає. Його завдання - зробити зовнішній API сумісним із внутрішнім контрактом IndexNowClientInterface.

Factory відповідає на запитання «який об’єкт використати?». Adapter - «як поєднати два несумісні контракти?».

Adapter і Dependency Inversion працюють разом

Цей приклад також легко сплутати з принципом Dependency Inversion.

DIP: прикладний код залежить від IndexNowClientInterface, а не від конкретної HTTP-реалізації.

Adapter: IndexNowHttpClient реалізує цей внутрішній контракт і перетворює його на взаємодію з HTTP-протоколом IndexNow.

Одна архітектурна побудова може одночасно демонструвати кілька принципів і патернів, але важливо чітко розуміти роль кожного з них.

Коли я б використовувала такий Adapter

  • Зовнішній API має власний протокол взаємодії або структуру запиту.

  • Прикладному шару потрібна простіша операція, сформульована мовою самої системи.

  • Зовнішня інтеграція може змінюватися незалежно від прикладного сценарію.

  • Тести прикладного шару не повинні залежати від HTTP.

  • Ключі доступу та адреси зовнішніх сервісів не повинні проникати в бізнес- або прикладні сервіси.

  • Помилки конкретного зовнішнього провайдера потрібно чітко відокремлювати від доменних рішень.

Коли Adapter був би зайвим

Не кожен HTTP-запит потребує окремого інтерфейсу й класу Adapter.

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

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

Хороший Adapter не приховує сам факт інтеграції. Він приховує її технічну мову від коду, якому ці деталі не потрібні.

Висновок

У ICanUp прикладний шар не відправляє HTTP-запит до IndexNow напряму. Він звертається до IndexNowClientInterface і викликає submit($urls).

IndexNowHttpClient працює на межі інфраструктурного шару й адаптує цю просту внутрішню операцію до зовнішнього протоколу: нормалізує список URL, формує структуру запиту та виконує HTTP-запит.

Job зберігає логіку публікації. IndexNowUrlService відповідає за правила формування URL для індексації. Adapter містить деталі HTTP-інтеграції.

Саме так Adapter Pattern допомагає мені не дозволяти зовнішньому API визначати структуру внутрішнього прикладного коду.