Що таке патерн Adapter?
Патерн Adapter (адаптер) перетворює інтерфейс одного класу на той, що очікує клієнт, щоб несумісні класи могли працювати разом без зміни коду.
Теорія
TL;DR
- Аналогія: перехідник для розетки. Вилка не змінюється, перехідник вирішує невідповідність форми і напруги.
- Патерн огортає існуючий клас і делегує виклики з перекладом, замість того щоб переписувати старий код.
- Головний тригер: інтерфейс не підходить і змінити жодну зі сторін не можна.
- Правило вибору: якщо не можна змінити старий клас і не можна змінити нову систему, то Adapter.
- Adapter стоїть на стику двох систем, які ніколи не були розраховані на спільну роботу.
Швидкий приклад
// Старий принтер знає тільки 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. Неповний інтерфейс
// Неправильно: є тільки pay(), немає refund()
class PartialAdapter {
pay() { /* ... */ }
}
adapter.refund(); // TypeError: adapter.refund is not a functionРеалізуй увесь цільовий інтерфейс. Якщо метод не має відповідника в старому класі, кидай явну помилку замість того щоб залишати undefined.
2. Наслідування замість композиції
// Неправильно: extends прив'язує до ієрархії OldGateway
class BadAdapter extends OldGateway {
pay() { super.processPayment(); }
}Використовуй композицію: new Adapter(new OldGateway()). Наслідування створює тісний зв'язок: якщо OldGateway змінить свою ієрархію, адаптер зламається.
3. Відсутність перекладу помилок
// Неправильно: стара помилка просочується до клієнта
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, у формат, який очікують внутрішні сервіси, і обробляє версіонування без змін у самих сервісах.
Приклади
Базовий: обгортка для застарілого принтера
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.
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 між версіями: оновити довелося тільки адаптер.
Просунутий: платіжний шлюз з повним перекладом помилок
Приклад показує, як виглядає готовий до продакшену адаптер: повний інтерфейс і переклад помилок.
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Обидва методи реалізовані, часткового інтерфейсу немає. Помилки перекладаються у відомий тип. Адаптер покриває повний контракт цільового інтерфейсу.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.