adapter design pattern: תבנית Adapter שמחברת בין ממשקים שלא נועדו להתאים
תבנית Adapter, או בשמה הנפוץ באנגלית adapter design pattern, פותרת בעיה יומיומית בתכנון מונחה־עצמים: יש לנו קוד שמצפה לממשק מסוים, אבל הרכיב שאותו אנחנו רוצים לשלב מספק ממשק אחר. במקום לשנות את הקוד הקיים, או לעטוף את כל המערכת בתנאים ותרגומים נקודתיים, מוסיפים שכבת התאמה קטנה וברורה.
זו תבנית מבנה קלאסית מתוך משפחת GoF. היא לא מנסה ליצור אובייקטים חדשים בצורה מתוחכמת, כמו תבניות יצירה, ולא מגדירה אלגוריתם חדש. היא עוסקת בשאלה פשוטה יותר: איך גורמים לשני צדדים לדבר זה עם זה בלי לשנות את שניהם. לכן היא מופיעה הרבה באינטגרציות, במעבר בין גרסאות API, בעבודה מול ספריות חיצוניות, ובמערכות שבהן יש קוד ותיק לצד קוד חדש.
אם זו הפעם הראשונה שבה אתם ממפים תבניות לפי כוונה, כדאי לקרוא גם את מבוא לתבניות עיצוב. Adapter יושבת בצד המבני של המפה: היא משנה את צורת החיבור בין אובייקטים, לא את מהות הפעולה העסקית.
מה הבעיה שתבנית Adapter פותרת?
נניח שיש במערכת שירות שמחשב עלות משלוח. הקוד החדש מצפה לממשק בשם ShippingService, עם מתודה costInShekels. אבל ספק חיצוני, או מודול ישן, מספק מחלקה בשם LegacyShippingCalculator, עם מתודה אחרת: calculateUsdShipping. גם שם המתודה שונה, גם המטבע שונה, וגם מבנה הפרמטרים לא בהכרח תואם.
אפשר לפתור את זה בכמה דרכים לא טובות:
- לשנות את כל הקוד החדש כך שיכיר את המחלקה הישנה.
- לשנות את המחלקה הישנה, גם אם היא שייכת לספרייה חיצונית או למודול שלא רוצים לגעת בו.
- להוסיף המרות ידניות בכל מקום שבו מחשבים משלוח.
- להשתמש ב־
ifו־instanceofכדי לזהות סוגי ספקים שונים.
Adapter מציעה פתרון נקי יותר: יוצרים מחלקה שמממשת את הממשק שהלקוח מצפה לו, ובפנים היא מתרגמת את הקריאה אל הרכיב הקיים. הלקוח לא יודע שיש מאחורי הקלעים רכיב ישן, ספק חיצוני או API לא תואם. מבחינתו הוא עובד מול החוזה שהוגדר לו.
ההגדרה הקלאסית של Adapter לפי GoF
בספר Design Patterns, Gamma et al., 1994, Adapter מוגדרת כך: התבנית ממירה את הממשק של מחלקה לממשק אחר שהלקוחות מצפים לו. Adapter מאפשרת למחלקות לעבוד יחד אף שהממשקים שלהן אינם תואמים.
ההגדרה הזאת חשובה כי היא מציבה גבול ברור: Adapter לא אמורה להוסיף התנהגות עסקית חדשה, לא אמורה להסתיר מודל דומיין שלם, ולא אמורה להפוך למרכז לוגיקה. תפקידה הוא תרגום ממשקים.
המשתתפים בתבנית
| משתתף | תפקיד | דוגמה נפוצה |
|---|---|---|
Target |
הממשק שהלקוח מצפה לעבוד מולו | ShippingService |
Client |
הקוד שמשתמש ב־Target |
Checkout |
Adaptee |
המחלקה הקיימת עם ממשק לא תואם | LegacyShippingCalculator |
Adapter |
מחלקה שמתרגמת בין Target לבין Adaptee |
ObjectShippingAdapter |
המבנה הזה נראה קטן, אבל הוא מייצר הפרדה חשובה: הלקוח תלוי בממשק שמתאים לשפה של המערכת, בעוד שהאדפטר מכיר את הפרטים הלא נוחים של המערכת החיצונית או הישנה.
adapter design pattern בפועל: מתאם אובייקט מול מתאם מחלקה
יש שתי וריאציות מוכרות של התבנית: מתאם אובייקט ו־מתאם מחלקה. שתיהן מממשות את אותה כוונה, אבל הן משתמשות במנגנון אחר.
מתאם אובייקט: הרכבה במקום ירושה
במתאם אובייקט, ה־Adapter מחזיק מופע של ה־Adaptee. כלומר, הוא משתמש ב־composition. זו הגישה הנפוצה ביותר בשפות כמו Java, C# ו־Python, והיא לרוב גם הגמישה ביותר.
היתרונות ברורים:
- אפשר להחליף את האובייקט המותאם בזמן ריצה.
- אין תלות בירושה ממחלקה מסוימת.
- אפשר לעטוף אובייקטים שכבר נוצרו במקום אחר.
- קל יותר לבדוק את האדפטר באמצעות אובייקט מדומה או מימוש חלופי.
החיסרון העיקרי הוא שיש עוד אובייקט במערכת, ועוד שכבת קריאה. ברוב המקרים זה מחיר זניח, אבל במערכות עם רגישות גבוהה מאוד לביצועים צריך למדוד ולא להניח.
מתאם מחלקה: ירושה לצורך התאמה
במתאם מחלקה, ה־Adapter יורש מה־Adaptee ומממש את ה־Target. בשפות עם ירושה מרובה של מחלקות זה יכול להיות מבנה ישיר יותר. ב־Java אין ירושה מרובה של מחלקות, לכן מתאם מחלקה אפשרי רק אם ה־Target הוא ממשק וה־Adapter יורש ממחלקה אחת קיימת.
זו גישה פחות גמישה: ה־Adapter קשור למחלקה ספציפית בזמן קומפילציה, וקשה יותר להחליף את ה־Adaptee. מצד שני, לפעמים היא קצרה וברורה, במיוחד כשהמחלקה הישנה יציבה ואין צורך בהרכבה דינמית.
דוגמת Java מלאה: שני סוגי Adapter
הדוגמה הבאה מציגה לקוח שמכיר רק את ShippingService. יש לנו מחלקה ותיקה שמחשבת מחיר בדולרים לפי קוד מדינה ומשקל בפאונדים. האדפטרים מתרגמים את הקריאה לממשק החדש, שבו המחיר מוחזר בשקלים והמשקל נמסר בקילוגרמים.
interface ShippingService {
double costInShekels(String country, double weightKg);
}
class LegacyShippingCalculator {
public double calculateUsdShipping(String countryCode, double weightPounds) {
double base = countryCode.equals("IL") ? 8.0 : 18.0;
return base + weightPounds * 1.5;
}
}
class ObjectShippingAdapter implements ShippingService {
private final LegacyShippingCalculator legacy;
private final double usdToIlsRate;
public ObjectShippingAdapter(LegacyShippingCalculator legacy, double usdToIlsRate) {
this.legacy = legacy;
this.usdToIlsRate = usdToIlsRate;
}
@Override
public double costInShekels(String country, double weightKg) {
String countryCode = toCountryCode(country);
double weightPounds = weightKg * 2.20462;
double costUsd = legacy.calculateUsdShipping(countryCode, weightPounds);
return costUsd * usdToIlsRate;
}
private String toCountryCode(String country) {
if (country.equalsIgnoreCase("Israel")) {
return "IL";
}
return "INTL";
}
}
class ClassShippingAdapter extends LegacyShippingCalculator implements ShippingService {
private final double usdToIlsRate;
public ClassShippingAdapter(double usdToIlsRate) {
this.usdToIlsRate = usdToIlsRate;
}
@Override
public double costInShekels(String country, double weightKg) {
String countryCode = country.equalsIgnoreCase("Israel") ? "IL" : "INTL";
double weightPounds = weightKg * 2.20462;
double costUsd = calculateUsdShipping(countryCode, weightPounds);
return costUsd * usdToIlsRate;
}
}
class Checkout {
private final ShippingService shippingService;
public Checkout(ShippingService shippingService) {
this.shippingService = shippingService;
}
public void printShipping(String country, double weightKg) {
double cost = shippingService.costInShekels(country, weightKg);
System.out.println("Shipping cost: " + Math.round(cost) + " ILS");
}
}
public class Main {
public static void main(String[] args) {
LegacyShippingCalculator legacy = new LegacyShippingCalculator();
ShippingService objectAdapter = new ObjectShippingAdapter(legacy, 3.7);
Checkout checkoutWithObjectAdapter = new Checkout(objectAdapter);
checkoutWithObjectAdapter.printShipping("Israel", 2.0);
ShippingService classAdapter = new ClassShippingAdapter(3.7);
Checkout checkoutWithClassAdapter = new Checkout(classAdapter);
checkoutWithClassAdapter.printShipping("Germany", 2.0);
}
}בדוגמה, ShippingService הוא ה־Target. המחלקה Checkout היא ה־Client, כי היא צורכת שירות משלוחים בלי להכיר את פרטי החישוב. LegacyShippingCalculator היא ה־Adaptee. המחלקות ObjectShippingAdapter ו־ClassShippingAdapter הן שני סוגי Adapter.
שימו לב לנקודה העיקרית: הלקוח לא יודע אם מאחוריו יש מתאם אובייקט או מתאם מחלקה. הוא תלוי רק ב־ShippingService. זו בדיוק הסיבה שהתבנית משתלבת היטב עם עקרון היפוך התלות מתוך SOLID.
איך מיישמים Adapter שלב אחר שלב?
כדי לא להפוך את האדפטר למחלקה עמוסה מדי, כדאי לעבוד בסדר קבוע.
1. מגדירים את הממשק שהלקוח באמת צריך
אל תתחילו מהמחלקה הישנה. התחילו מהקוד החדש: מה הלקוח צריך לדעת? אילו פרמטרים טבעיים עבורו? איזה ערך הוא אמור לקבל?
אם המערכת שלכם מדברת במונחים של שקלים, אין סיבה שכל הלקוחות יכירו דולר. אם היא מדברת בקילוגרמים, אין סיבה לחשוף פאונדים. ה־Target צריך לדבר בשפת הדומיין של המערכת, לא בשפה של הספק החיצוני.
2. מזהים את הפערים מול ה־Adaptee
רשמו בפשטות מה לא תואם:
- שמות מתודות שונים.
- סדר פרמטרים שונה.
- יחידות מידה שונות.
- חריגות מסוג אחר.
- ערכי החזרה שונים, למשל
nullמול אובייקט ריק. - קריאה סינכרונית מול API שמחזיר תוצאה בצורה אחרת.
השלב הזה חוסך הרבה תקלות. רוב הבאגים באדפטרים לא מגיעים ממבנה התבנית אלא מהמרה לא מדויקת.
3. בוחרים מתאם אובייקט כברירת מחדל
ברוב המקרים כדאי להתחיל ממתאם אובייקט. הוא פחות מחייב, קל יותר לבדיקה, ומתאים יותר למצבים שבהם יש כמה ספקים או כמה מימושים אפשריים.
בחרו מתאם מחלקה רק אם:
- המחלקה הישנה יציבה מאוד.
- אין צורך להחליף את ה־Adaptee בזמן ריצה.
- הירושה לא תיצור תלות לא רצויה במחלקת בסיס.
- השפה והמודל מאפשרים זאת בלי עיקום מיותר.
4. שומרים את האדפטר דק
אדפטר טוב עושה המרה, לא ניהול תהליך. אם מצאתם את עצמכם מוסיפים בו הרשאות, ולידציה עסקית, שמירת נתונים, שליחת אירועים וחישובי מחיר מורכבים — כנראה חסרה לכם שכבה אחרת.
כלל שימושי: אם אפשר להסביר את האדפטר במשפט אחד, הוא כנראה בגודל נכון. למשל: “מתרגם קריאת משלוח פנימית לקריאה של מחשבון המשלוחים הישן”.
מה צריך להכין מראש?
לפני שמוסיפים Adapter, כדאי לוודא שיש שלושה דברים:
- חוזה ברור ללקוח: ממשק קטן ויציב, בלי תלות בפרטי ספק.
- הבנה של המערכת המותאמת: אילו יחידות, חריגות, הנחות וערכי קצה קיימים ב־Adaptee.
- בדיקות סביב מקרי גבול: לא רק “משקל רגיל לישראל”, אלא גם משקל אפס, מדינה לא מוכרת, תוצאה חריגה, ושגיאה מהמערכת הישנה.
אם אתם נמצאים בשלב שבו עדיין לא ברור מי יוצר את האובייקטים, אפשר להשלים את התמונה בעזרת תבניות יצירה. למשל, Factory יכולה לבחור איזה Adapter להחזיר לפי קונפיגורציה, אבל היא לא מחליפה את Adapter עצמו.
איפה נפוץ להיתקע?
האדפטר מתחיל להכיר יותר מדי
בעיה נפוצה היא Adapter שמכיר את כל העולם: קונפיגורציה, מסדי נתונים, חוקי מחיר, לוגיקת retry, הרשאות ועוד. זה סימן שהמחלקה חורגת מהאחריות שלה.
מה עושים? מפרידים. האדפטר יתרגם קריאה. שירות אחר ינהל תהליך עסקי. רכיב תשתיתי אחר יטפל בניסיונות חוזרים אם צריך.
הממשק החדש מועתק מהממשק הישן
לפעמים מגדירים את ה־Target כמעט זהה ל־Adaptee, רק עם שמות מעט שונים. זו החמצה. המטרה היא לא להסתיר שם מחלקה ישנה, אלא להגן על הקוד החדש מפני מודל שלא מתאים לו.
אם ה־Target עדיין מדבר במונחים של הספק החיצוני, הלקוחות שלכם עדיין תלויים בספק. רק העברתם את התלות מקום.
המרות שקטות מדי
המרה בין מטבעות, אזורי זמן או יחידות מידה עלולה לגרום לבאגים קשים לזיהוי. לא תמיד נכון “לבלוע” ערכים לא חוקיים ולהחזיר ברירת מחדל.
כדאי להחליט במפורש:
- מה קורה אם מתקבלת מדינה לא מוכרת?
- מה קורה אם שער ההמרה חסר?
- האם שגיאה מהמערכת הישנה הופכת לחריגה פנימית?
- האם מותר לעגל תוצאה, ואם כן באיזו שכבה?
Adapter, Facade ו־Decorator: לא אותה תבנית
קל לבלבל בין Adapter לבין תבניות מבנה אחרות.
| תבנית | המטרה העיקרית | השאלה שהיא עונה עליה |
|---|---|---|
| Adapter | התאמת ממשק לא תואם | איך גורמים לקוד קיים לעבוד עם ממשק שהלקוח מצפה לו? |
| Facade | פישוט גישה לתת־מערכת מורכבת | איך מספקים כניסה פשוטה למערכת עם הרבה רכיבים? |
| Decorator | הוספת התנהגות תוך שמירה על אותו ממשק | איך מוסיפים יכולת לאובייקט בלי לשנות אותו? |
אם הממשק לא תואם — חשבו Adapter. אם הממשק מסורבל אבל לא בהכרח לא תואם — אולי Facade. אם הממשק כבר מתאים ורק צריך להוסיף התנהגות — אולי Decorator.
הקשר ל־SOLID ולעיצוב מונחה־עצמים
Adapter מתחברת היטב לכמה עקרונות בסיסיים בעיצוב מונחה־עצמים.
- Single Responsibility Principle: האדפטר אחראי להתאמה בין ממשקים, לא לכל התהליך העסקי.
- Open/Closed Principle: אפשר לשלב רכיב לא תואם בלי לשנות את הלקוח הקיים.
- Dependency Inversion Principle: הלקוח תלוי בממשק
Target, לא במחלקה קונקרטית חיצונית.
החיבור הזה לא הופך Adapter לפתרון קסם. הוא כן מסביר למה התבנית שורדת הרבה שנים: היא מאפשרת להכניס שינוי למערכת בלי לגרור אחריו שרשרת שינויים בכל הלקוחות.
שימושים נפוצים במיקרו־שירותים וב־API Design
במערכות מבוססות microservices, Adapter מופיעה לעיתים קרובות בגבולות השירות. שירות אחד מחזיק מודל פנימי נקי, אבל צריך לדבר עם שירות אחר שמחזיר JSON במבנה שונה, שמות שדות אחרים או קודי סטטוס לא אחידים.
ב־API Design, Adapter יכולה לשבת בין:
- מודל דומיין פנימי לבין DTO חיצוני.
- גרסת API ישנה לבין גרסה חדשה.
- שירות פנימי לבין ספק צד שלישי.
- פרוטוקול ישן לבין ממשק חדש יותר.
עם זאת, לא כדאי לקרוא לכל המרה “Adapter”. אם מדובר במיפוי נתונים פשוט בין שתי מחלקות דומות, ייתכן שמספיק Mapper. Adapter נהיית מעניינת כאשר יש לקוח שמצפה לממשק אחד ורכיב קיים שמספק ממשק אחר.
איך Adapter נראית ב־UML?
ב־UML class diagram בסיסי, נראה את Client תלוי ב־Target. ה־Adapter מממש את Target. במתאם אובייקט, ה־Adapter מחזיק שדה שמצביע אל Adaptee. במתאם מחלקה, ה־Adapter יורש מה־Adaptee ומממש את Target.
במילים:
Client→ משתמש ב־Target.Adapter→ מממש אתTarget.Object Adapter→ מכילAdaptee.Class Adapter→ יורש מ־Adaptee.
התרשים עוזר במיוחד כשיש כמה אדפטרים לאותו ממשק. למשל, ספק משלוחים מקומי, ספק בינלאומי ומימוש בדיקות — כולם מממשים את אותו Target, אבל כל אחד מתרגם את הקריאה אחרת.
יתרונות וחסרונות
יתרונות
- בידוד תלות חיצונית: הקוד העסקי לא נצמד ל־API חיצוני.
- שימוש חוזר בקוד קיים: אפשר לשלב מחלקות ותיקות בלי לשכתב אותן.
- בדיקות קלות יותר: הלקוח נבדק מול ממשק יציב.
- החלפה הדרגתית: אפשר לעבור ממערכת ישנה לחדשה בלי שינוי חד בכל הלקוחות.
חסרונות
- עוד שכבה בקוד: יותר מחלקות ויותר ניווט.
- סיכון להסתרת מורכבות: אם ה־Adaptee בעייתי, האדפטר לא מעלים את הבעיות אלא רק עוטף אותן.
- המרות שגויות: יחידות מידה, מטבעות ושדות אופציונליים דורשים תשומת לב.
- פיתוי להכניס לוגיקה: Adapter גדול מדי הופך לשירות עסקי לא מתוכנן.
מתי לא להשתמש ב־Adapter?
לא צריך Adapter בכל פעם שיש שתי מחלקות עם שמות מתודות שונים. לפעמים שינוי שם, מיצוי ממשק קטן או ריפקטורינג פשוט יהיו ברורים יותר.
הימנעו מהתבנית כאשר:
- אפשר לשנות בבטחה את המחלקה הקיימת כך שתממש את הממשק הרצוי.
- אין לקוח יציב שמרוויח מההפרדה.
- הפער בין הממשקים הוא בעצם פער עסקי עמוק שדורש מודל חדש, לא מתאם.
- האדפטר נועד רק להסתיר עיצוב מבולגן במקום לתקן אותו.
- מדובר בהמרת נתונים חד־פעמית ופשוטה שאין לה התנהגות.
במילים אחרות, Adapter מתאים כשיש גבול ברור בין שני עולמות. אם אין גבול כזה, התבנית עלולה להוסיף טקסיות בלי ערך.
שאלות שקוראים באמת שואלים
האם Adapter חייב להיות מחלקה נפרדת?
לא תמיד, אבל לרוב כן כדאי. אפשר לממש התאמה קטנה בעזרת פונקציה או lambda, במיוחד בשפות שתומכות בכך היטב. אבל כאשר יש חוזה ברור, בדיקות, המרות ותחזוקה עתידית, מחלקה נפרדת נותנת שם ואחריות ברורים.
האם Adapter הוא אותו דבר כמו Wrapper?
Wrapper הוא מונח רחב יותר. Adapter הוא סוג מסוים של Wrapper שמטרתו להתאים ממשק לא תואם. כל Adapter עוטף משהו במידה מסוימת, אבל לא כל Wrapper הוא Adapter.
האם כדאי ליצור Adapter לכל ספרייה חיצונית?
לא באופן אוטומטי. אם הספרייה מופיעה רק במקום אחד, והממשק שלה פשוט ויציב, ייתכן שאין צורך. אבל אם היא מחלחלת לשכבות רבות, או אם תרצו להחליף אותה בעתיד, Adapter יכול לצמצם תלות מוקדם יחסית.
סיכום
Adapter היא תבנית קטנה עם השפעה גדולה: היא מאפשרת לשלב רכיבים שלא תוכננו לעבוד יחד, בלי לכופף את כל המערכת סביב ממשק זר. ההבחנה בין מתאם אובייקט למתאם מחלקה חשובה בעיקר בבחירת רמת הגמישות: מתאם אובייקט מתאים לרוב המצבים, ומתאם מחלקה מתאים למקרים צרים יותר שבהם ירושה היא בחירה סבירה.
הדרך הנכונה להשתמש בתבנית היא להתחיל מהלקוח ומהממשק שהוא צריך, לזהות את הפער מול הקוד הקיים, ולכתוב Adapter דק שמתרגם בין הצדדים. כששומרים על הגבול הזה, התבנית תורמת לעיצוב נקי, לבדיקות פשוטות יותר וליכולת להחליף רכיבים בלי לשבור את שאר המערכת.


