Skip to main content

Що таке патерн Adapter?

Патерн Adapter (адаптер) перетворює інтерфейс одного класу на той, що очікує клієнт, щоб несумісні класи могли працювати разом без зміни коду.

Теорія

TL;DR

  • Аналогія: перехідник для розетки. Вилка не змінюється, перехідник вирішує невідповідність форми і напруги.
  • Патерн огортає існуючий клас і делегує виклики з перекладом, замість того щоб переписувати старий код.
  • Головний тригер: інтерфейс не підходить і змінити жодну зі сторін не можна.
  • Правило вибору: якщо не можна змінити старий клас і не можна змінити нову систему, то Adapter.
  • Adapter стоїть на стику двох систем, які ніколи не були розраховані на спільну роботу.

Швидкий приклад

javascript
// Старий принтер знає тільки printRaw(data) class OldPrinter { printRaw(data) { console.log(`Raw: ${data}`); } } // Adapter огортає старий принтер, надає метод printFormatted(text) class PrinterAdapter { constructor(oldPrinter) { this.old = oldPrinter; } printFormatted(text) { const formatted = text.toUpperCase(); // переклад this.old.printRaw(formatted); // делегування } } const adapter = new PrinterAdapter(new OldPrinter()); adapter.printFormatted('hello'); // Виведе: Raw: HELLO

Тут відбуваються дві речі: переклад (перетворення у верхній регістр) і делегування (виклик старого методу). Жоден клас не змінюється.

Ключова різниця від переписування

Можна було б просто перейменувати printRaw на printFormatted. Але що якщо це стороння бібліотека? Або 50 місць у коді досі викликають printRaw? Adapter стоїть між двома системами. Старий код працює далі. Новий код отримує потрібний інтерфейс. Обидві сторони залишаються незайманими.

Коли використовувати

  • Старий API не відповідає тому, що очікує новий код, і змінити жодну зі сторін не виходить.
  • Стороння бібліотека зі своїм інтерфейсом: огортаємо її, не форкаємо.
  • Кілька сервісів з різними сигнатурами: по одному адаптеру на сервіс, єдиний інтерфейс для решти коду.
  • Тестові моки замість реальних залежностей: підміняємо через спільний інтерфейс, логіка не змінюється.

Як це працює всередині

Клієнт викликає метод адаптера. Адаптер перетворює параметри на те, що чекає adaptee, викликає його, потім перекладає результат назад. У JavaScript структурна типізація робить це природно: якщо адаптер має потрібну форму, клієнтський код приймає його. V8 компілює обгортку як тонкий шар без відчутних накладних витрат для типових випадків.

У TypeScript явно додавай implements IPayment або відповідний інтерфейс. Без цього компілятор не спіймає невідповідність інтерфейсів під час збірки.

Типові помилки

1. Неповний інтерфейс

javascript
// Неправильно: є тільки pay(), немає refund() class PartialAdapter { pay() { /* ... */ } } adapter.refund(); // TypeError: adapter.refund is not a function

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

2. Наслідування замість композиції

javascript
// Неправильно: extends прив'язує до ієрархії OldGateway class BadAdapter extends OldGateway { pay() { super.processPayment(); } }

Використовуй композицію: new Adapter(new OldGateway()). Наслідування створює тісний зв'язок: якщо OldGateway змінить свою ієрархію, адаптер зламається.

3. Відсутність перекладу помилок

javascript
// Неправильно: стара помилка просочується до клієнта pay(amount) { this.old.process(amount); } // Правильно pay(amount) { try { this.old.process(amount); } catch (e) { throw new PaymentError(e.message); // перекладаємо у відомий тип } }

Старий код кидає OldAuthError. Новий код чекає UnauthorizedError. Адаптер обробляє це відображення. Інакше обробка помилок на стороні клієнта ламається.

4. Покладання лише на структурну типізацію в TypeScript

JavaScript гнучкий щодо форм об'єктів, але TypeScript зі strict mode хоче явного implements IPayment. Без цього помилки типів не виявляються під час збірки в складних кодових базах.

Реальне використання

  • express-jwt адаптує бібліотеку jsonwebtoken до сигнатури Express middleware.
  • react-query огортає Axios і Fetch за єдиним query function.
  • Міграційні утиліти AWS SDK з версії 2 до 3 побудовані на адаптерах.
  • Redux-saga адаптує між thunk-стилем і ефектами saga.

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

Q: Яка різниця між Adapter і Decorator?
A: Decorator додає поведінку, зберігаючи той самий інтерфейс. Adapter змінює інтерфейс під те, що очікує клієнт. Структура схожа, мета різна.

Q: Яка різниця між Adapter і Bridge?
A: Adapter вирішує проблему вже існуючих класів, які не були розраховані на спільну роботу. Bridge розділяє абстракцію від реалізації на етапі проектування, до виникнення проблеми.

Q: Чи можна зробити двосторонній адаптер?
A: Так. Кожен напрямок має власний переклад. ORM-и роблять саме це: SQL у бік бази даних, об'єкти у бік застосунку.

Q: Коли Adapter може додавати затримку?
A: У глибоких ланцюгах викликів або при дорогому перекладі, наприклад конвертації великих структур даних. У таких випадках варто профілювати глибину делегування.

Q: (Senior) Як Adapter вписується в API Gateway у мікросервісах?
A: Gateway сам по собі є системним адаптером. Він перекладає зовнішні протоколи, REST або gRPC, у формат, який очікують внутрішні сервіси, і обробляє версіонування без змін у самих сервісах.

Приклади

Базовий: обгортка для застарілого принтера

javascript
class OldPrinter { printRaw(data) { console.log(`Raw: ${data}`); } } class PrinterAdapter { constructor(printer) { this.printer = printer; } printFormatted(text) { this.printer.printRaw(text.toUpperCase()); } } const adapter = new PrinterAdapter(new OldPrinter()); adapter.printFormatted('hello'); // Raw: HELLO

Старий клас не змінюється. Нові виклики використовують printFormatted. Адаптер бере на себе переклад між двома інтерфейсами.

Середній рівень: адаптація legacy-автентифікації до Express middleware

Реальний сценарій: команда отримує бібліотеку авторизації з callback-інтерфейсом. Нові маршрути Express чекають req.user, встановленого в middleware.

javascript
class OldAuthLib { authenticate(userId, callback) { // імітація асинхронного запиту до БД callback(null, { id: userId, name: 'User' }); } } class AuthMiddlewareAdapter { constructor(auth) { this.auth = auth; } middleware(req, res, next) { this.auth.authenticate(req.body.userId, (err, user) => { if (err) return next(err); req.user = user; // відповідає конвенції Express next(); }); } } const authAdapter = new AuthMiddlewareAdapter(new OldAuthLib()); app.post('/login', (req, res, next) => authAdapter.middleware(req, res, next), (req, res) => res.json(req.user) // { id: 123, name: 'User' } );

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

Просунутий: платіжний шлюз з повним перекладом помилок

Приклад показує, як виглядає готовий до продакшену адаптер: повний інтерфейс і переклад помилок.

javascript
class OldPaymentGateway { processPayment(amount) { console.log(`Processing $${amount}`); } reverseTransaction(id) { console.log(`Reversing ${id}`); } } class PaymentError extends Error {} class PaymentAdapter { constructor(legacyGateway) { this.gateway = legacyGateway; } pay(amount, currency) { try { const converted = currency === 'EUR' ? amount * 1.1 : amount; this.gateway.processPayment(converted); } catch (e) { throw new PaymentError(`Payment failed: ${e.message}`); } } refund(transactionId) { try { this.gateway.reverseTransaction(transactionId); } catch (e) { throw new PaymentError(`Refund failed: ${e.message}`); } } } const adapter = new PaymentAdapter(new OldPaymentGateway()); adapter.pay(100, 'EUR'); // Processing $110 adapter.refund('tx-001'); // Reversing tx-001

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

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?