דלג לתוכן הראשי
שכבת הפשטה ארכיטקטורת תוכנה בעברית
עקרונות SOLID

עקרון פתוח/סגור ב־SOLID: להרחיב קוד בלי לשבור אותו — ומתי לעצור לפני הנדסת־יתר

עקרון פתוח/סגור הוא אחד הרעיונות הכי שימושיים ב־SOLID, אבל גם אחד הקלים ביותר לעיוות. הניסוח הקצר שלו אומר: רכיבי תוכנה צריכים להיות פתוחים להרחבה אך סגורים לשינוי. בפועל, זה לא אומר שאסור לערוך קוד לעולם. זה אומר שקוד יציב, שכבר נבדק ומשמש חלקים אחרים במערכת, לא צריך להיפתח מחדש בכל פעם שמתווסף מקרה עסקי חדש.

ב־object-oriented design העיקרון הזה מופיע שוב ושוב: דרך interface, פולימורפיזם, קומפוזיציה, Strategy, Decorator, Factory Method ועוד. הוא חשוב גם בארכיטקטורת microservices וב־API design, כי שינוי חוזה קיים עלול להשפיע על צרכנים רבים. אבל אם מיישמים אותו מוקדם מדי, לפני שיש שונות אמיתית במערכת, מקבלים שכבות מיותרות, שמות מופשטים מדי, וקוד שקשה יותר להבין מהבעיה שהוא בא לפתור.

מה באמת אומר עקרון פתוח/סגור

המשפט “פתוח להרחבה, סגור לשינוי” נשמע נקי, אבל צריך לקרוא אותו בזהירות. “סגור לשינוי” לא מתייחס לכל שורת קוד. הוא מתייחס בעיקר לאזורים שבהם שינוי חוזר גורם לסיכון: תנאים מתנפחים, switch שמקבל עוד ועוד ענפים, מחלקה מרכזית שמכירה את כל סוגי הלקוחות, כל סוגי המסים או כל סוגי ההנחות.

“פתוח להרחבה” אומר שאפשר להוסיף התנהגות חדשה באמצעות הוספת רכיב חדש: מחלקה, מימוש של interface, אובייקט מדיניות, handler בשרשרת, או decorator סביב אובייקט קיים. החלק היציב במערכת עובד מול חוזה מופשט, ולא מול רשימת מקרים קשיחה.

הבדל חשוב: עקרון פתוח/סגור אינו דורש לבנות מערכת תוספים מלאה. לפעמים הרחבה היא פשוט עוד מחלקה שמממשת DiscountPolicy. לפעמים היא מודול נפרד. לפעמים היא endpoint חדש ב־API. הרמה תלויה במחיר השינוי ובתדירות שלו.

הסימן הראשון לבעיה: תנאי שגדל בכל שינוי

נניח שיש מנגנון חישוב מחיר. בהתחלה יש לקוח רגיל ולקוח VIP. אחר כך מבצע סוף עונה. אחר כך לקוח עסקי. אחר כך שילוב עם קופון. אם בכל תוספת פותחים את אותה מתודה ומוסיפים עוד if, נוצרת נקודת ריכוז מסוכנת.

הבעיה אינה רק אסתטית. בכל שינוי כזה נוגעים בקוד שעבד קודם. צריך להריץ שוב בדיקות רגרסיה. יש סיכוי לשבור מקרה קיים בגלל סדר תנאים, טיפול שגוי ב־null, או הנחה עסקית שנכונה לענף אחד ולא לאחר.

זה המקום שבו עקרון פתוח/סגור מועיל: במקום שמחלקת החישוב תכיר את כל סוגי ההנחות, היא מכירה חוזה אחד. ההנחות עצמן הופכות לאובייקטים משתפי פעולה.

דוגמה מלאה ב־Java: הרחבת חישוב מחיר בלי לשנות את הליבה

הדוגמה הבאה מציגה מנגנון חישוב מחיר שבו PriceCalculator אינו יודע אילו הנחות קיימות. הוא יודע לקבל קוד מדיניות, למצוא אובייקט שמממש את DiscountPolicy, ולהפעיל אותו. הוספת הנחה חדשה דורשת מחלקה חדשה ורישום שלה באזור ההרכבה של היישום, לא שינוי בלוגיקת החישוב.

Java
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.LinkedHashMap;
import java.util.Map;

public class Main {
    public static void main(String[] args) {
        Map<String, DiscountPolicy> policies = new LinkedHashMap<>();
        policies.put("regular", new NoDiscount());
        policies.put("vip", new VipDiscount());
        policies.put("clearance", new ClearanceDiscount());

        PriceCalculator calculator = new PriceCalculator(policies);

        System.out.println(calculator.total("regular", new BigDecimal("100.00")));
        System.out.println(calculator.total("vip", new BigDecimal("100.00")));
        System.out.println(calculator.total("clearance", new BigDecimal("100.00")));
    }
}

interface DiscountPolicy {
    String code();

    BigDecimal apply(BigDecimal amount);
}

final class NoDiscount implements DiscountPolicy {
    public String code() {
        return "regular";
    }

    public BigDecimal apply(BigDecimal amount) {
        return amount;
    }
}

final class VipDiscount implements DiscountPolicy {
    public String code() {
        return "vip";
    }

    public BigDecimal apply(BigDecimal amount) {
        return amount.multiply(new BigDecimal("0.90"));
    }
}

final class ClearanceDiscount implements DiscountPolicy {
    public String code() {
        return "clearance";
    }

    public BigDecimal apply(BigDecimal amount) {
        return amount.multiply(new BigDecimal("0.75"));
    }
}

final class PriceCalculator {
    private final Map<String, DiscountPolicy> policies;

    PriceCalculator(Map<String, DiscountPolicy> policies) {
        this.policies = new LinkedHashMap<>(policies);
    }

    BigDecimal total(String policyCode, BigDecimal amount) {
        DiscountPolicy policy = policies.get(policyCode);

        if (policy == null) {
            throw new IllegalArgumentException("Unknown discount policy: " + policyCode);
        }

        return policy.apply(amount).setScale(2, RoundingMode.HALF_UP);
    }
}

בדוגמה הזו DiscountPolicy הוא החוזה היציב. NoDiscount, VipDiscount ו־ClearanceDiscount הם הרחבות. PriceCalculator הוא הלקוח של החוזה, ולכן הוא סגור יחסית לשינויים עסקיים בהנחות. המחלקה Main משמשת כאן כאזור הרכבה פשוט: המקום שבו מחברים מימושים קונקרטיים למערכת.

זו דוגמה קטנה, אבל היא ממחישה שיתוף פעולה בין אובייקטים: מחלקה אחת לא “יודעת הכול”, אלא מפעילה משתף פעולה דרך חוזה צר. זה הבסיס להרבה תבניות עיצוב, במיוחד Strategy. להרחבה על היחס בין עקרונות ותבניות כדאי לקרוא את המבוא לתבניות עיצוב.

מבנה האחריות בדוגמה

רכיב אחריות משתנה כשמוסיפים הנחה חדשה?
DiscountPolicy מגדיר חוזה לחישוב הנחה בדרך כלל לא
PriceCalculator מפעיל מדיניות לפי קוד ומחשב סכום סופי לא, כל עוד החוזה מספיק
VipDiscount מימוש עסקי של הנחת VIP לא, אלא אם כלל ה־VIP משתנה
ClearanceDiscount מימוש עסקי של מבצע חיסול לא, אלא אם המבצע משתנה
אזור ההרכבה מחבר קודים למימושים כן, כאשר מוסיפים מימוש למערכת

הטבלה מראה נקודה חשובה: עקרון פתוח/סגור לא מעלים שינוי. הוא מזיז אותו למקום מתאים יותר. במקום לשנות לוגיקה יציבה, מוסיפים רכיב חדש ומחברים אותו. אם החיבור נעשה בקובץ קונפיגורציה, במודול DI או בקוד אתחול, זה עדיין שינוי — אבל שינוי פחות מסוכן.

איך מיישמים את העיקרון שלב אחר שלב

1. מצאו את ציר השינוי

לא מתחילים מ־interface. מתחילים משאלה פשוטה: מה צפוי להשתנות שוב? סוגי הנחות? ספקי תשלום? פורמטים לייצוא? ערוצי התראה? כללי הרשאה?

אם אין ציר שינוי ברור, אין עדיין הצדקה להפשטה. מחלקה אחת פשוטה עדיפה על היררכיה ריקה. סימן טוב לציר שינוי הוא היסטוריה: אותו קובץ נפתח שוב ושוב בגלל סיבות דומות.

2. הפרידו בין החלק היציב לחלק המשתנה

בדוגמה, החלק היציב הוא “כדי לחשב מחיר צריך להפעיל מדיניות הנחה”. החלק המשתנה הוא “איזו מדיניות בדיוק”. לכן החוזה צריך להיות קטן: apply מקבלת סכום ומחזירה סכום.

חוזה רחב מדי הוא ריח רע. אם DiscountPolicy היה מקבל גם לקוח, עגלה, כתובת, מטבע, זמן, וקונטקסט מלא של מערכת — סביר שהוא היה הופך לממשק כללי מדי. ממשק כזה קשה ליישום, קשה לבדיקה, וקשה לשינוי.

3. העדיפו קומפוזיציה על ירושה עמוקה

ירושה יכולה לתמוך בעקרון פתוח/סגור, אבל היא גם יוצרת צימוד חזק. ברוב המקרים עדיף שמחלקה תחזיק אובייקט משתף פעולה ותפעיל אותו. כך אפשר להחליף התנהגות בלי לבנות עץ ירושה מסובך.

במונחי UML, תעדיפו לעיתים קרובות קשר “has-a” על פני “is-a”. ב־UML class diagram זה יתבטא בקומפוזיציה או association בין המחלקה היציבה לבין הממשק, ולא בהכרח בירושה ממחלקת בסיס כבדה.

4. הוסיפו בדיקות סביב החוזה

עקרון פתוח/סגור עובד היטב כאשר החוזה ברור. בדיקות צריכות לכסות גם את המחלקה היציבה וגם מימושים מרכזיים. כשמוסיפים מימוש חדש, בודקים את המימוש החדש בלי לפתוח מחדש את כל הליבה.

אם כל מימוש דורש סט גדול של הכנות, ייתכן שהחוזה אינו ממוקד. זה סימן לחזור לעיצוב ולשאול מה באמת נדרש למימוש ההתנהגות.

הקשר לתבניות עיצוב של GoF

הרבה תבניות GoF הן למעשה דרכים קונקרטיות ליישם פתוח/סגור:

  • Strategy מפרידה אלגוריתם ממשתמש האלגוריתם.
  • Decorator מוסיפה יכולת סביב אובייקט קיים בלי לשנות אותו.
  • Observer מאפשרת להוסיף מאזינים בלי לשנות את המקור שמפרסם אירועים.
  • Factory Method ו־Abstract Factory מפרידות יצירת אובייקטים מהשימוש בהם.
  • Chain of Responsibility מאפשרת להוסיף handler נוסף בשרשרת בלי לרכז את כל התנאים במקום אחד.

כאשר הבעיה המרכזית היא יצירת אובייקטים ולא רק בחירת התנהגות, כדאי להסתכל גם על תבניות יצירה. שם השאלה היא לא רק “איך נרחיב התנהגות”, אלא “מי אחראי להחליט איזה אובייקט נוצר ומתי”.

איפה נתקעים בדרך כלל

יותר מדי ממשקים מוקדם מדי

טעות נפוצה היא ליצור interface לכל מחלקה “ליתר ביטחון”. התוצאה היא כפילות: UserService ו־UserServiceImpl, בלי שונות אמיתית ובלי תרחיש החלפה. זה לא פתוח/סגור; זו רעשנות.

כלל אצבע: אם יש מימוש אחד בלבד ואין סיבה קרובה להוסיף עוד אחד, אפשר לחכות. קל יותר לחלץ ממשק מקוד ברור מאשר למחוק הפשטות שלא הוכיחו את עצמן.

חוזה שמדליף פרטים פנימיים

אם הממשק כולל פרטים של מסד נתונים, פורמט JSON פנימי או שדות שלא קשורים לפעולה, כל שינוי קטן שובר את כל המימושים. חוזה טוב מתאר כוונה, לא את מבנה המימוש.

למשל, DiscountPolicy לא צריך לדעת מאיזה טבלה הגיעה ההנחה. הוא צריך לדעת להחיל כלל עסקי על סכום או על אובייקט מצומצם שמייצג את מה שנדרש לחישוב.

רישום הרחבות שהופך למרכז חדש של כאב

גם אחרי שמוציאים את התנאים למחלקות נפרדות, עדיין צריך לרשום אותן. אם אזור ההרכבה מתנפח מאוד, אפשר לשקול מנגנון גילוי, קונפיגורציה או factory. אבל גם כאן צריך להיזהר: מנגנון אוטומטי מדי עלול להסתיר זרימה ולהקשות על דיבוג.

עקרון פתוח/סגור בארכיטקטורת microservices וב־API

במערכות microservices העיקרון מופיע בגבולות שירותים. שירות צריך לאפשר הרחבה בלי לשבור צרכנים קיימים. לדוגמה, הוספת שדה אופציונלי לתגובה היא לרוב הרחבה בטוחה יותר משינוי שם שדה קיים. הוספת endpoint חדש בטוחה יותר משינוי משמעות קיימת של endpoint ישן.

אבל גם כאן יש גבול. אם כל שינוי קטן מוביל לגרסה חדשה של API, שכבת תאימות, מתאמים, אירועים כפולים ומפות המרה — ייתכן שהמערכת משלמת מחיר גבוה מדי. עקרון פתוח/סגור אינו תחליף לעיצוב חוזה פשוט וברור.

ב־microservices architecture חשוב להבחין בין הרחבה פנימית להרחבה חיצונית. בתוך שירות אפשר לשנות מימוש בחופשיות יחסית. בגבול החיצוני, מול צרכנים אחרים, צריך להיות שמרניים יותר. שם “סגור לשינוי” חשוב במיוחד.

מתי לא להשתמש בעקרון פתוח/סגור בצורה אגרסיבית

יש מקרים שבהם הפשטה מוקדמת מזיקה יותר משהיא עוזרת:

  • קוד חד־פעמי או זמני באמת — אם הוא לא צפוי להתרחב, אין טעם לבנות מנגנון הרחבות.
  • דומיין שעדיין לא מובן — הפשטה מוקדמת תקבע שמות וחוזים שכנראה ישתנו.
  • שני מקרים בלבד שאין ביניהם דפוס יציב — ייתכן ש־if פשוט ברור יותר.
  • צוות שמתקשה לקרוא את המבנה — עיצוב טוב צריך לשרת תחזוקה, לא להרשים בתרשים UML.
  • ביצועים או פשטות תפעולית חשובים במיוחד — לא כל נקודת הרחבה מצדיקה עוד שכבה.

הנדסת־יתר מתחילה כאשר הקוד מתכונן לעתיד שלא הגיע. אם צריך לפתוח חמישה קבצים כדי להבין פעולה עסקית פשוטה, ואם שמות כמו AbstractPolicyProviderFactoryResolver מופיעים לפני שיש צורך אמיתי, כדאי לעצור.

איך מזהים איזון בריא

איזון בריא נראה כך: הקוד הנוכחי פשוט לקריאה, אבל נקודות השינוי הידועות מבודדות. יש מעט ממשקים, והם מייצגים שונות אמיתית. מחלקות לא יודעות יותר מדי על מימושים אחרים. הוספת מקרה חדש דורשת בעיקר קוד חדש, לא עריכה של מנגנון מרכזי רגיש.

אפשר לשאול שלוש שאלות לפני שמוסיפים הפשטה:

  1. מה בדיוק צפוי להשתנות? אם אין תשובה, חכו.
  2. כמה פעמים זה כבר השתנה? פעם אחת אינה תמיד דפוס.
  3. האם ההפשטה מקטינה סיכון או רק מוסיפה טקס? אם היא לא מקטינה סיכון, היא כנראה מוקדמת.

כאשר התשובות ברורות, עקרון פתוח/סגור נותן דרך מעשית לשמור על קוד יציב. כאשר התשובות מעורפלות, עדיף לכתוב קוד ישיר, עם שמות טובים ובדיקות, ולהמתין עד שהשונות תתגלה.

סיכום: להרחיב בזהירות, לא להפשטה לשם הפשטה

עקרון פתוח/סגור הוא כלי מרכזי ב־SOLID ובתכנון מונחה עצמים. הוא עוזר להפחית רגרסיות, לבודד שינוי, ולבנות שיתופי פעולה נקיים בין אובייקטים. הוא גם עומד מאחורי רבות מתבניות העיצוב הקלאסיות שתוארו ב־Design Patterns, Gamma et al., 1994.

אבל העיקרון אינו הוראה להפוך כל תנאי לתבנית וכל מחלקה לממשק. היישום הטוב ביותר שלו מתחיל מהכאב האמיתי: קובץ שנפתח שוב ושוב, תנאי שמתנפח, חוזה API שנשבר, או אזור עסקי שמתרחב בכיוונים צפויים. שם כדאי להשקיע בהרחבה בלי שינוי. בשאר המקומות, פשטות היא עדיין עיצוב טוב.

נכתב על ידי

ליאת שגיא : כותב

כותבת על SOLID, תכנון מונחה עצמים, UML, מיקרו־שירותים ותכנון API. לא ארכיטקטית מוסמכת ולא נציגה של חברה או קורס — מסבירה החלטות תכנון עם יתרונות, חסרונות ודוגמה לפני ואחרי.

להמשך קריאה

לכל המאמרים בנושא

עקרונות SOLID

היפוך תלויות והזרקת תלויות: ההבדל החשוב בין העיקרון, הטכניקה והקונטיינר

היפוך תלויות והזרקת תלויות נזרקים לא פעם לאותו משפט, כאילו מדובר באותו דבר. בפועל אלה שלוש שכבות שונות של אותה שיחה: עיקרון תכנוני, טכניקה למימוש, ומנגנון תשתיתי שמנהל יצירת אובייקטים…

9 דק׳ קריאה

עקרונות SOLID

עקרון האחריות היחידה ב-SOLID: איך מזהים מחלקה שעושה יותר מדי לפני שהיא שוברת את המערכת

עקרון האחריות היחידה, או Single Responsibility Principle, הוא אחד הרעיונות הפשוטים ביותר להסבר בעיצוב מונחה עצמים — ואחד הקשים ביותר ליישום עקבי. המשפט המוכר “למחלקה צריכה להיות סיבה אחת להשתנות”…

10 דק׳ קריאה