Коли ICanUp почав автоматично повідомляти пошукові системи про нові та змінені публічні URL, найпростішим рішенням було б викликати Laravel Http безпосередньо з job або сервісу.
Технічно це працювало б. Але тоді прикладний код почав би знати деталі зовнішнього протоколу IndexNow: endpoint, host, ключ перевірки, адресу цього ключа, структуру JSON-запиту та правила обробки HTTP-помилок.
Насправді прикладному шару потрібна значно простіша операція: передати ці URL на індексацію.
Тому взаємодія із зовнішнім HTTP API у мене прихована за невеликим контрактом - IndexNowClientInterface.
Проблема не в самому HTTP-запиті, а в напрямку залежності
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-взаємодії
interface IndexNowClientInterface{ /** * @param array<int, string> $urls */ public function submit( array $urls, ): void;}У цьому контракті немає згадок про Http, endpoint, заголовки, JSON або API key.
Код, який його викликає, передає лише масив URL. Це вся інформація, яку прикладний шар повинен знати для виконання цієї операції.
Такий інтерфейс зберігає стабільну внутрішню мову системи, навіть якщо зовнішній протокол значно складніший.
Завдання черги залежить від порту, а не від конкретної HTTP-реалізації
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
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 - це перекладач між двома контрактами
Внутрішня сторона | Зовнішня сторона |
|---|---|
| HTTP POST-запит |
Публічні URL |
|
Код, що викликає клієнт, не знає про API key |
|
Код, що викликає клієнт, не знає про host |
|
Код, що викликає клієнт, не знає про транспорт | Laravel HTTP client |
Adapter корисний не тому, що HTTP сам по собі складний. Його цінність у тому, що зовнішній контракт не повинен ставати внутрішнім контрактом застосунку.
Laravel container з’єднує port та adapter
$this->app->bind( IndexNowClientInterface::class, IndexNowHttpClient::class,);Прикладний код залежить від інтерфейсу, а контейнер Laravel підставляє робочу реалізацію.
Завдяки цьому напрямок залежності спрямований усередину: прикладний шар визначає потрібний контракт, а інфраструктурний шар його реалізує.
Це водночас добре демонструє принцип Dependency Inversion, але Adapter і DIP тут не одне й те саме. DIP визначає напрямок залежності, а Adapter відповідає за перетворення одного контракту на інший.
Тест прикладного шару не потребує справжнього HTTP Adapter
$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 тестується окремо як межа інфраструктурного шару
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 нічого не знає про правила публікації
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 |
|
Інша структура запиту IndexNow |
|
Інший HTTP-клієнт у Laravel/PHP | Інфраструктурний Adapter. |
Інший механізм роботи з API key | Інфраструктурний Adapter або конфігурація. |
Інший зовнішній провайдер із тим самим внутрішнім наміром | Нова реалізація |
Нові правила публікації | Доменний або прикладний шар, але не Adapter. |
Adapter не те саме, що Strategy
Strategy | Adapter |
|---|---|
Обирає один із кількох взаємозамінних алгоритмів. | Перетворює один контракт на інший. |
Реалізації виконують одну й ту саму бізнес-роль. | Реалізація працює на межі із зовнішньою інтеграцією. |
|
|
Відмінності є частиною поведінки самого застосунку. | Відмінності виникають через вимоги зовнішньої інфраструктури. |
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 визначати структуру внутрішнього прикладного коду.



