Integrujesz starą bibliotekę, zewnętrzne API albo kilka usług o podobnym przeznaczeniu, ale każda z nich oczekuje innego formatu danych i innych nazw metod? Właśnie w takich sytuacjach przydaje się wzorzec Adapter, który pozwala połączyć niezgodne interfejsy bez przebudowy całej aplikacji. Pokażę, jak działa, z czego się składa, kiedy warto go zastosować i jak napisać go w praktyce.
Najważniejsze informacje o wzorcu Adapter w jednym miejscu
- Cel: połączenie klas lub usług, których interfejsy do siebie nie pasują.
- Mechanizm: adapter tłumaczy wywołania i dane między kodem klienta a istniejącą klasą.
- Najczęstsze zastosowanie: integracja API, kodu legacy i bibliotek zewnętrznych.
- Preferowana odmiana: adapter obiektowy oparty na kompozycji, a nie dziedziczeniu.
- Główne ryzyko: niepotrzebne zwiększenie liczby klas i ukrycie problemu z niejasnym kontraktem.

Po co w ogóle potrzebny jest adapter
Po angielsku spotkasz nazwę adapter pattern, natomiast w polskich materiałach najczęściej mówi się po prostu o wzorcu Adapter. Jego zadanie jest proste: istniejący obiekt ma działać w miejscu, w którym oczekiwany jest inny interfejs.
Dobrym przykładem jest aplikacja sklepu internetowego. Kod zamówień może oczekiwać metody pay(amount), podczas gdy zewnętrzny operator płatności udostępnia metodę createTransaction(valueInCents, currencyCode). Obie operacje prowadzą do podobnego celu, ale różnią się nazwami, parametrami i sposobem zwracania wyniku.
Zamiast zmieniać kod zamówień albo modyfikować bibliotekę operatora, dodaję klasę pośrednią. Ona przyjmuje wywołanie zgodne z interfejsem aplikacji, przelicza dane i deleguje pracę do usługi zewnętrznej.
Największa korzyść polega na odizolowaniu różnic w jednym miejscu. Gdy dostawca zmieni format odpowiedzi, poprawiam adapter, a nie wszystkie kontrolery, serwisy i testy, które korzystają z płatności.
Adapter nie zmienia źródłowej klasy
Wzorzec dobrze sprawdza się wtedy, gdy nie mam dostępu do kodu źródłowego, nie chcę naruszać stabilnej biblioteki albo jej zmiana mogłaby zepsuć inne fragmenty systemu. W praktyce często dotyczy to starego kodu, SDK dostawcy i systemów zewnętrznych.
Adapter nie musi wykonywać skomplikowanej logiki biznesowej. Jego rolą jest głównie tłumaczenie kontraktu, nazw, typów danych, jednostek i kolejności argumentów. Jeśli zaczyna podejmować decyzje biznesowe, prawdopodobnie trafia do niego zbyt dużo odpowiedzialności.
Jak działa ten wzorzec krok po kroku
W typowej implementacji występują cztery elementy. Klient korzysta wyłącznie z interfejsu docelowego, adapter implementuje ten interfejs, a opakowana usługa udostępnia własny, niepasujący kontrakt.
| Element | Rola |
|---|---|
| Klient | Kod aplikacji, który chce wykonać operację. |
| Target | Interfejs oczekiwany przez klienta. |
| Adapter | Obiekt tłumaczący wywołania i dane. |
| Adaptee | Istniejąca usługa lub klasa z innym interfejsem. |
Przepływ wygląda następująco: klient wywołuje metodę interfejsu docelowego, adapter odbiera żądanie, konwertuje argumenty i przekazuje je do opakowanej klasy. Następnie tłumaczy odpowiedź na format, który rozumie klient.
To ważne rozdzielenie sprawia, że kod klienta nie musi wiedzieć, czy pod spodem działa biblioteka JavaScript, usługa REST, stary moduł PHP czy lokalna klasa. Z jego perspektywy istnieje tylko jeden stabilny kontrakt.
Przykład w TypeScript
Poniższy przykład pokazuje integrację aplikacji z dostawcą płatności, którego API używa innych nazw i jednostek niż reszta systemu.
type PaymentResult = {
transactionId: string;
accepted: boolean;
};
interface PaymentGateway {
pay(amountInPln: number): Promise;
}
class ExternalPaymentClient {
async createTransaction(
valueInGrosze: number,
currency: string
): Promise<{ id: string; status: string }> {
return {
id: "tx-123",
status: "accepted"
};
}
}
class ExternalPaymentAdapter implements PaymentGateway {
constructor(private client: ExternalPaymentClient) {}
async pay(amountInPln: number): Promise {
const response = await this.client.createTransaction(
Math.round(amountInPln * 100),
"PLN"
);
return {
transactionId: response.id,
accepted: response.status === "accepted"
};
}
}
const gateway: PaymentGateway =
new ExternalPaymentAdapter(new ExternalPaymentClient());
await gateway.pay(149.99);
Adapter wykonuje tu trzy konkretne czynności. Przelicza złote na grosze, ustawia kod waluty i mapuje odpowiedź dostawcy na typ używany przez aplikację. Dzięki temu reszta systemu nie musi znać specyfiki zewnętrznego klienta.
W praktyce właśnie takie drobne konwersje często są najbardziej wartościowe. Nie wyglądają efektownie, ale chronią kod domenowy przed przeciekaniem szczegółów integracji do wielu miejsc.
Dwie odmiany i rozsądny wybór między nimi
Wzorzec można zrealizować przez kompozycję albo dziedziczenie. W nowoczesnych aplikacjach webowych zazwyczaj wybieram pierwszą możliwość, ponieważ daje większą elastyczność i nie wymaga wielokrotnego dziedziczenia.
| Rodzaj | Jak działa | Kiedy ma sens | Ograniczenie |
|---|---|---|---|
| Adapter obiektowy | Przechowuje instancję opakowywanej klasy i deleguje do niej wywołania. | Integracje z bibliotekami, API i kodem legacy. | Dodaje dodatkową warstwę delegowania. |
| Adapter klasowy | Wykorzystuje dziedziczenie adaptera po interfejsie docelowym i klasie źródłowej. | Wybrane języki i proste hierarchie klas. | Wymaga możliwości wielokrotnego dziedziczenia albo podobnego mechanizmu. |
Adapter obiektowy jest zwykle bezpieczniejszy, bo pozwala podmienić opakowywaną usługę w czasie działania lub w testach. Mogę przekazać atrapę, innego dostawcę albo nową wersję biblioteki bez zmiany dziedziczenia.
Adapter klasowy bywa krótszy, ale mocniej wiąże rozwiązanie z konkretną hierarchią. W TypeScript, Javie i C# częściej stosuje się kompozycję, ponieważ klasy nie mogą dziedziczyć po wielu klasach jednocześnie albo takie dziedziczenie szybko komplikuje projekt.
Gdzie Adapter sprawdza się najlepiej
Najczęściej używam go na granicach systemu, czyli tam, gdzie aplikacja styka się z cudzym kodem. To naturalne miejsce na izolowanie zmian, ponieważ zewnętrzne kontrakty nie zawsze są zaprojektowane pod potrzeby naszej domeny.
Integracja kilku dostawców
Sklep może obsługiwać płatności kartą, szybki przelew i płatność odroczoną. Każdy operator zwraca inne statusy, ale aplikacja może potrzebować tylko trzech stanów: zaakceptowana, odrzucona i oczekująca. Osobny adapter dla każdego dostawcy ujednolica te różnice.
Modernizacja kodu legacy
Stary moduł może udostępniać metodę get_customer_data(), podczas gdy nowa część aplikacji używa findCustomer(). Adapter pozwala rozpocząć migrację etapami, bez przepisywania całego systemu w jednym dużym wydaniu.
Zmiana formatu danych
Adapter może konwertować XML na obiekt domenowy, jednostki imperialne na metryczne albo odpowiedź z REST API na model używany przez frontend. Nie chodzi wyłącznie o nazwy metod. Równie ważne są typy, jednostki, wartości domyślne i obsługa błędów.
Przeczytaj również: Pub/Sub - Jak działa, kiedy używać i unikać błędów?
Testowanie i wymiana implementacji
Jeśli kod zależy od interfejsu, w testach mogę podstawić prostą implementację zamiast prawdziwego klienta zewnętrznego. To ogranicza liczbę testów wymagających sieci i sprawia, że testy jednostkowe są szybsze oraz bardziej przewidywalne.
Nie tworzyłbym jednak adaptera dla każdej pojedynczej funkcji. Gdy różnica jest banalna, zwykła funkcja mapująca może być czytelniejsza. Wzorzec zaczyna przynosić realną wartość wtedy, gdy integracja ma własny kontrakt, kilka operacji lub prawdopodobnie będzie się zmieniać.
Adapter a fasada, dekorator i strategia
Te wzorce bywają mylone, bo wszystkie mogą opakowywać inne obiekty. Różni je jednak cel, a ten cel najlepiej traktować jako główne kryterium wyboru.
| Wzorzec | Główne pytanie | Co robi |
|---|---|---|
| Adapter | Jak dopasować niezgodny interfejs? | Tłumaczy jeden kontrakt na drugi. |
| Fasada | Jak uprościć skomplikowany moduł? | Udostępnia prostszy punkt wejścia do wielu operacji. |
| Dekorator | Jak dodać zachowanie bez zmiany klasy? | Rozszerza funkcję, zachowując ten sam interfejs. |
| Strategia | Jak wymieniać sposób wykonania algorytmu? | Ukrywa kilka wariantów zachowania za wspólnym kontraktem. |
Jeżeli interfejs już pasuje, ale chcesz dodać logowanie, cache albo pomiar czasu, prawdopodobnie potrzebujesz dekoratora. Jeżeli chcesz ukryć kilka usług za jedną metodą, lepsza będzie fasada. Adapter wybieram wtedy, gdy podstawowy problem brzmi: „te dwa elementy nie potrafią się ze sobą bezpośrednio porozumieć”.
Jak wdrożyć go bez tworzenia zbędnej warstwy
Zaczynam od opisania interfejsu, którego naprawdę potrzebuje kod aplikacji. Nie kopiuję bezrefleksyjnie całego API dostawcy. Im węższy i bardziej domenowy kontrakt, tym łatwiej wymienić implementację.
- Zidentyfikuj klasę lub usługę, której nie możesz albo nie chcesz zmieniać.
- Spisz operacje potrzebne klientowi, bez szczegółów konkretnego dostawcy.
- Zdefiniuj interfejs docelowy.
- Utwórz adapter, który przechowuje zależność do opakowywanej usługi.
- Dodaj mapowanie argumentów, wyników i wyjątków.
- Podłącz klienta do interfejsu, a nie do konkretnej klasy adaptera.
- Przetestuj poprawne dane, błędy i nietypowe przypadki.
Szczególną uwagę poświęcam błędom. Gdy dostawca zwraca cztery rodzaje odrzucenia, a aplikacja rozpoznaje tylko jeden, adapter nie powinien po cichu gubić informacji. Lepiej jawnie ustalić mapowanie i zachować istotny kontekst diagnostyczny.
Drugim częstym problemem jest zbyt szeroki adapter. Jeśli klasa zaczyna zarządzać zamówieniami, autoryzacją, retry i logiką rabatową, przestaje być zwykłym tłumaczem. Wtedy rozdzielam integrację od logiki biznesowej i zostawiam adapterowi tylko to, co dotyczy zgodności interfejsów.
Trzeba też uważać na ukryte konwersje. Przeliczanie groszy na złote jest proste, ale zmiana stref czasowych, zaokrągleń lub semantyki statusów może powodować trudne błędy. Takie reguły powinny mieć osobne testy, nawet jeśli sam adapter wydaje się niewielki.
Gdy adapter rozwiązuje problem, a nie go maskuje
Najlepszy moment na użycie tego wzorca pojawia się wtedy, gdy różnica między interfejsami jest lokalna, zrozumiała i możliwa do przetestowania. Wtedy adapter ogranicza sprzężenie, ułatwia wymianę dostawcy i chroni kod aplikacji przed zmianami na zewnątrz.
Nie traktuję go jednak jako obowiązkowej ceremonii architektonicznej. Przy jednej prostej funkcji wystarczy mapper, a przy projektowaniu dwóch nowych modułów od zera lepiej od razu ustalić wspólny kontrakt niż później go naprawiać.
Najprostsza reguła brzmi więc tak: jeśli istniejąca klasa jest użyteczna, ale mówi językiem, którego reszta systemu nie rozumie, dodaj warstwę tłumaczącą. Dobry adapter jest mały, jawny i łatwy do usunięcia, gdy zewnętrzna zależność przestanie być potrzebna.