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

solid design pattern: עקרונות SOLID עם דוגמה לפני ואחרי לכל עיקרון

הביטוי solid design pattern נשמע לפעמים כאילו SOLID הוא עוד תבנית עיצוב, אבל זו טעות שימושית: SOLID הוא סט עקרונות שמכוון את הדרך שבה מחלקות, ממשקים ותלויות מתוכננים. תבניות GoF כמו Factory, Strategy או Decorator הן פתרונות חוזרים; SOLID הוא מערכת שאלות שעוזרת להבין אם הפתרון נשאר גמיש, קריא ובטוח לשינוי.

במאמר הזה נפרק את חמשת העקרונות: SRP, OCP, LSP, ISP ו-DIP. לכל עיקרון נראה מה הבעיה, איך נראה “לפני”, איך נראה “אחרי”, ומה נוטים לפספס ביישום אמיתי. הדוגמאות ב-Java קצרות ומכוונות להמחשת הרעיון בלבד, לא ללימוד השפה.

אם אתם מגיעים מהכיוון של תבניות עיצוב, כדאי לקרוא גם את המבוא לתבניות עיצוב. SOLID ותבניות GoF לא מחליפים זה את זה; הם עובדים יחד. למשל, הרבה יישומים של Open/Closed Principle משתמשים בפולימורפיזם, ולעיתים גם בתבניות כמו Strategy או Factory.

מה צריך להכין לפני שמיישמים SOLID בקוד קיים

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

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

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

טבלת חמשת עקרונות SOLID בקצרה

עיקרון שאלה מעשית סימן אזהרה נפוץ
SRP האם למחלקה יש סיבה אחת להשתנות? מחלקה שמחשבת, שומרת, מדפיסה ושולחת הודעות
OCP האם אפשר להוסיף התנהגות בלי לערוך קוד קיים? שרשרת if/switch שגדלה עם כל סוג חדש
LSP האם תת-מחלקה באמת יכולה להחליף את מחלקת הבסיס? מתודה שנזרקת עם UnsupportedOperationException
ISP האם לקוחות תלויים רק במה שהם צריכים? ממשק “שמן” שמכריח מחלקות לממש פעולות ריקות
DIP האם קוד ברמה גבוהה תלוי בהפשטות ולא בפרטים? מחלקת שירות שיוצרת בעצמה את כל התלויות שלה

דוגמת Java מלאה: לפני ואחרי לכל עיקרון

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

Java
public class SolidExamples {
    public static void main(String[] args) {
        System.out.println("SOLID examples");

        Order order = new Order(100.0);

        // SRP after
        InvoiceCalculator calculator = new InvoiceCalculator();
        InvoicePrinter printer = new InvoicePrinter();
        printer.print(calculator.total(order));

        // OCP after
        DiscountPolicy policy = new VipDiscount();
        System.out.println(policy.apply(100.0));

        // LSP after
        Bird sparrow = new Sparrow();
        sparrow.eat();
        FlyingBird flying = new Sparrow();
        flying.fly();

        // ISP after
        BasicPrinter simplePrinter = new SimplePrinter();
        simplePrinter.print("Report");

        // DIP after
        Notifier notifier = new EmailNotifier();
        OrderService service = new OrderService(notifier);
        service.place(order);
    }

    static class Order {
        private final double amount;

        Order(double amount) {
            this.amount = amount;
        }

        double amount() {
            return amount;
        }
    }

    // SRP before: one class has calculation, printing, and persistence responsibilities.
    static class BadInvoiceManager {
        double total(Order order) {
            return order.amount() * 1.17;
        }

        void print(Order order) {
            System.out.println("Total: " + total(order));
        }

        void save(Order order) {
            System.out.println("Saving order " + order.amount());
        }
    }

    // SRP after
    static class InvoiceCalculator {
        double total(Order order) {
            return order.amount() * 1.17;
        }
    }

    static class InvoicePrinter {
        void print(double total) {
            System.out.println("Total: " + total);
        }
    }

    // OCP before: every new customer type changes this method.
    static class BadDiscountService {
        double discount(String type, double price) {
            if ("vip".equals(type)) {
                return price * 0.80;
            }
            if ("student".equals(type)) {
                return price * 0.90;
            }
            return price;
        }
    }

    // OCP after
    interface DiscountPolicy {
        double apply(double price);
    }

    static class VipDiscount implements DiscountPolicy {
        public double apply(double price) {
            return price * 0.80;
        }
    }

    static class StudentDiscount implements DiscountPolicy {
        public double apply(double price) {
            return price * 0.90;
        }
    }

    // LSP before: not every bird can fly.
    static class BadBird {
        void fly() {
            System.out.println("Flying");
        }
    }

    static class BadPenguin extends BadBird {
        @Override
        void fly() {
            throw new UnsupportedOperationException("Penguins do not fly");
        }
    }

    // LSP after
    interface Bird {
        void eat();
    }

    interface FlyingBird extends Bird {
        void fly();
    }

    static class Sparrow implements FlyingBird {
        public void eat() {
            System.out.println("Sparrow eats");
        }

        public void fly() {
            System.out.println("Sparrow flies");
        }
    }

    static class Penguin implements Bird {
        public void eat() {
            System.out.println("Penguin eats");
        }
    }

    // ISP before: simple printers are forced to support fax and scan.
    interface BadMachine {
        void print(String text);
        void scan(String text);
        void fax(String text);
    }

    static class BadSimplePrinter implements BadMachine {
        public void print(String text) {
            System.out.println(text);
        }

        public void scan(String text) {
            throw new UnsupportedOperationException();
        }

        public void fax(String text) {
            throw new UnsupportedOperationException();
        }
    }

    // ISP after
    interface BasicPrinter {
        void print(String text);
    }

    interface ScannerDevice {
        void scan(String text);
    }

    static class SimplePrinter implements BasicPrinter {
        public void print(String text) {
            System.out.println(text);
        }
    }

    // DIP before: high-level service creates a concrete dependency.
    static class BadOrderService {
        void place(Order order) {
            EmailNotifier email = new EmailNotifier();
            email.send("Order placed: " + order.amount());
        }
    }

    // DIP after
    interface Notifier {
        void send(String message);
    }

    static class EmailNotifier implements Notifier {
        public void send(String message) {
            System.out.println("Email: " + message);
        }
    }

    static class OrderService {
        private final Notifier notifier;

        OrderService(Notifier notifier) {
            this.notifier = notifier;
        }

        void place(Order order) {
            notifier.send("Order placed: " + order.amount());
        }
    }
}

בדוגמה הזו InvoiceCalculator ו-InvoicePrinter מפרידות אחריות לפי SRP; DiscountPolicy מאפשרת הרחבה לפי OCP; ההפרדה בין Bird לבין FlyingBird מתקנת הפרה של LSP; הממשקים BasicPrinter ו-ScannerDevice מדגימים ISP; ו-OrderService תלויה ב-Notifier במקום ב-EmailNotifier, בהתאם ל-DIP.

SRP: עיקרון האחריות היחידה

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

ב”אחרי” האחריות מתפצלת: InvoiceCalculator מחשבת, InvoicePrinter מציגה. אם מחר צריך להדפיס HTML במקום טקסט, לא נוגעים בחישוב. אם שיעור המס משתנה, לא נוגעים בהדפסה.

איפה נתקעים עם SRP?

  • פיצול מוקדם מדי: מחלקה קטנה אינה בהכרח טובה. אם אין סיבת שינוי נפרדת, אולי אין צורך להפריד.
  • שמות כלליים: מחלקות בשם Manager, Helper או Utils מסתירות אחריות. שם מדויק חושף בעיות תכנון.
  • ערבוב שכבות: חישוב עסקי שמכיר פורמט JSON, מסד נתונים או הדפסה הוא סימן טוב לשבירת SRP.

OCP: פתוח להרחבה, סגור לשינוי

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

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

solid design pattern ו-OCP: למה לא כל if הוא בעיה

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

LSP: עיקרון ההחלפה של ליסקוב

Liskov Substitution Principle שואל שאלה פשוטה: אם קוד מצפה למחלקת בסיס, האם אפשר לתת לו תת-מחלקה בלי לשבור את ההנחות שלו? בדוגמת ה”לפני”, BadPenguin יורש מ-BadBird, אבל אינו באמת יכול לעוף. לכן קריאה ל-fly על ציפור עלולה להיכשל בזמן ריצה.

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

טעויות נפוצות ב-LSP

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

ISP: ממשקים קטנים ומדויקים

Interface Segregation Principle אומר שלקוחות לא צריכים להיות תלויים במתודות שאינן רלוונטיות להם. בדוגמת ה”לפני”, BadMachine דורש הדפסה, סריקה ופקס. מדפסת פשוטה נאלצת לממש פעולות שאינה תומכת בהן.

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

איך יודעים שממשק גדול מדי?

  • מחלקות רבות מממשות מתודות ריקות.
  • שינוי במתודה אחת מחייב בנייה מחדש או בדיקות של לקוחות שאינם משתמשים בה.
  • שם הממשק כללי מדי, למשל SystemService או PlatformClient.

DIP: תלות בהפשטות, לא בפרטים

Dependency Inversion Principle הוא אחד העקרונות שהכי משפיעים על בדיקות, החלפת ספקים ותכנון שכבות. בדוגמת ה”לפני”, BadOrderService יוצר בעצמו EmailNotifier. לכן הוא קשור לדוא״ל, גם אם מחר נרצה לשלוח הודעה דרך מערכת אחרת.

ב”אחרי”, OrderService מקבל Notifier. המחלקה ברמה הגבוהה יודעת שהיא שולחת הודעה, אבל לא יודעת איך. זה מאפשר להחליף מימוש בלי לשנות את תהליך ההזמנה. במערכות גדולות יותר, אותו רעיון עומד מאחורי הזרקת תלויות, גבולות בין מודולים, ולעיתים גם גבולות בין שירותים בארכיטקטורת microservices.

תהליך עבודה מומלץ לריפקטורינג לפי SOLID

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

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

Trade-offs: המחיר של SOLID

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

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

מתי לא להשתמש ב-SOLID באופן מלא

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

שאלות שקוראים באמת שואלים

האם SOLID הוא חלק מתבניות GoF?

לא. הספר Design Patterns, Gamma et al., 1994 עוסק בתבניות עיצוב כמו Factory Method, Observer ו-Decorator. SOLID הוא סט עקרונות עיצוב אובייקטי. הם משלימים: תבנית יכולה לעזור ליישם עיקרון, אבל היא לא מחליפה שיקול דעת.

האם צריך UML כדי להבין SOLID?

לא חובה, אבל UML class diagram קטן יכול לעזור לראות ירושה, ממשקים ותלויות. אם תרשים מגלה שמחלקה אחת מחוברת לכל דבר, כנראה שכדאי לבדוק את SRP או DIP.

איפה תבניות יצירה נכנסות לתמונה?

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

סיכום מעשי

SOLID הוא לא רשימת חוקים שמפעילים אוטומטית, אלא כלי אבחון. כאשר מחלקה משתנה בגלל כמה סיבות, בדקו SRP. כאשר כל סוג חדש פותח קוד קיים, בדקו OCP. כאשר ירושה דורשת חריגות והסתייגויות, בדקו LSP. כאשר ממשק מכריח לקוחות לדעת יותר מדי, בדקו ISP. וכאשר שירות עסקי קשור למימושים קונקרטיים, בדקו DIP.

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

נכתב על ידי

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

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

להמשך קריאה

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

עקרונות SOLID

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

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

9 דק׳ קריאה

עקרונות SOLID

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

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

8 דק׳ קריאה

עקרונות SOLID

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

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

10 דק׳ קריאה