مبادئ الـ SOLID لجعل كودك صلب كالحديد

السلام عليكم ورحمة الله وبركاته

وقت القراءة: ≈ 25 دقيقة (بمعدل فنجان واحد من الشاي 😊)

المقدمة

هذه ستكون مقالة بسيطة سنشرح فيها مبادئ الـ SOLID التي تساعدنا على كتابة الكود بشكل منظم وسلس
وبالطبع أنا سأفترض أنك تعرف وتفهم مبادئ الـ OOP لأننا سنطبق الـ SOLID على الـ OOP

الـ SOLID هي 5 مبادئ أساسية تهدف لجعل الكود سهل التعديل عليه على قدر المستطاع
وكل مبدأ يركز على فكرة معينة تساعد على جعل الكود أكثر قابلية لتغير وتعديل وسهل القراءة وكل تلك الأمور التي يتغنى بها الجميع

الـ SOLID مجرد أفكار فقط لا أكثر تتعرف عليها وتحاول توظيفها وليس تطبيقات ثابتة تتعلمها مثل الـ Design Patterns
يمكنك أن نقول أن الـ SOLID هي أفكار ومبادئ والـ Design Patterns هي تطبيقات حقيقية لحل مشاكل كبيرة واقعية

تطبيق مبادئ الـ SOLID ليس بالأمر السهل ولكنه ليس بالأمر الصعب أيضًا
قد تضطر لتعديل الكثير من الأشياء وتغير الكثير من الأفكار والتصورات التي كنت تعتقد أنها صحيحة
لتصل إلى أقرب شكل يناسبك ويناسب الفريق الذي تعمل معه

سأحاول بالطبع شرح كل مبدأ والفكرة العامة الذي يركز عليها مع بعض الأمثلة العملية البسيطة لكود يناقض المبدأ وكود آخر يوافق المبدأ

مبدأ الـ Single Responsibility

ونبدأ مع أولهم وهو الـ Single Responsibility

هو مبدأ يهتم بجمع مجموعة من الأكواد التي تتشارك في نفس الوظيفة ثم توحيدها تحت مظلة واحدة وتكون مسؤولة عن تلك الوظيفة
لكي تسهل اعادة استخدامها مجددًا في أماكن مختلفة

والمبدأ ينص على أن الكلاس الواحد يجب أن يكون مسؤول عن شيء ووظيفة واحدة
بالتالي كل الدوال التي بداخل هذا الكلاس يجب أن تخدم هذه الوظيفة التي يقوم بها هذا الكلاس
وطالما أن الكلاس يكون مسؤول عن وظيفة واحدة فقط فأنت عندما تريد اضافة شيء ما او تعدل شيء ما في الموضوع الفلاني فستذهب إلى الكلاس المسؤول عن ها الموضوع المعين

بمعنى إذا كان لديك كلاس يدعى EmailService ما الذي يخطر على بالك عندما تقرأ اسم الكلاس ؟

ما الذي يجعلك متأكدًا أن كلاس EmailService لا يقوم بتلك الأمور
بسبب أنه من الواضح من اسمه أنه يقوم بعمل بعض الأمور المتعلقة بالبريد الإلكتروني
أمور مثل إرسال رسائل عبر البريد الإلكتروني إلى المستخدمين مثل:

فعندما تريد تعديل طريقة ارسالك للرسائل عبر البريد الالكتروني فأنت تلقائيًا تدرك أن الدالة المسؤولة عن هذا الأمر موجودة في كلاس الـ EmailService
أو عندما تريد عمل unit test للكلاس EmailService فأنت ستركز على شيء واحد فقط وهو الوظيفة الاساسية لكل دالة التي ستحوم حول فكرة ووظيفة كلاس الـ EmailService

ماذا عن كلاس يدعى ProductService ؟

أظن أن الفكرة وصلت

مثال يناقض المبدأ

سنفترض أنك لديك كلاس يدعى UserService وسأعيد عليك السؤال عندما تقرأ اسم الكلاس ما الذي يخطر على بالك ؟
سيخطر على بالك دوال مثل getAllUsers, getOneUser, updateOneUser ... وإلى آخره من الدوال التي تخدم فكرة الـ UserService

class UserService {
  getAllUsers() {
    // get all users
    // ...
  }

  getOneUser(id: string) {
    // get one user
    // ...
  }

  signUp(email: string, password: string) {
    // check email and password
    // sign up the user
    // ...
  }

  login(email: string, password: string) {
    // check email and password
    // log in the user
    // ...
  }

  getHTMLTemplate(type: string) {
    // get html template
    // ...
  }

  sendOTP(email: string, otp: string) {
    // get html template
    // send otp to email
    // ...
  }
}

لماذا هذا الكلاس يخالف المبدأ ؟
أريدك أن تحلل ما وظيفة الكلاس الأساسية وأريدك أن تحلل الدوال التي به هل هي تخدم الوظيفة الأساسية الذي يتبناها الكلاس ؟

ستجد أن الإجابة بالطبع لا، والسبب بكال وضوح أنه الكلاس مسؤول عن وظائف مختلفة

ستجد أن الكلاس البسيط UserService ربنا فتح عليه وأصبح مسؤولة عن وظائف متعددة في آن واحد
فتراه مسؤول عن كل ما يتعلق بالمستخدم
وتراه مسؤول عن الأمور الصلاحيات وتسجيل الأمان
وتراه مسؤول عن إرسال رسائل عبر البريد الإلكتروني

وهذا بالطبع يخالف مبدأ الـ Single Responsibility وهو أن كل كلاس يجب أن يكون مسؤول عن وظيفة واحدة

مثال يوافق المبدأ

الحل بكل بساطة أننا ننشيء كلاسات متعددة وكل كلاس يكون مسؤول عن وظيفة واحدة

class UserService {
  getAllUsers() {
    // get all users
    // ...
  }

  getOneUser(id: string) {
    // get one user
    // ...
  }

  // updateOneUser, deleteOneUser, getUserProfile ... etc.
}
class AuthService {
  signUp(email: string, password: string) {
    // check email and password
    // sign up the user
    // ...
  }

  login(email: string, password: string) {
    // check email and password
    // log in the user
    // ...
  }

  // resetPassword, verifyAccount, forgotPassword ... etc.
}
class EmailService {
  getHTMLTemplate(type: string) {
    // get html template
    // ...
  }

  sendOTP(email: string, otp: string) {
    // get html template
    // send otp to email
    // ...
  }

  sendEmailMessage(email: string, message: string) {
    // get html template
    // send email message
    // ...
  }

  // ... etc.
}

فوائد المبدأ

هكذا كل كلاس يكون مسؤول عن وظيفة واحدة ومن يريد شيء يمكننه استدعاءه من الكلاس المسؤول عن هذا الشيء
فيمكن لكلاس الـ AuthService أن ينشيء object من EmailService ليستخدم أي دالة يريدها متى يشاء كـ sendOTP أو sendEmailMessage أو ... إلخ

class AuthService {
  private emailService: EmailService;

  constructor() {
    const emailService = new EmailService();
  }

  signUp(email: string, password: string) {
    // check email and password
    // sign up the user
    // ...

    this.emailService.sendOTP(email, '123456');
  }

  verifyAccount(email: string) {
    // check email
    // verifying the account ...
    // ...

    this.emailService.sendEmailMessage(
      email,
      'Verified the account successfully'
    );
  }

  // ... etc.
}

لاحظ أن الـ AuthService يقوم فقط بالتركيز على وظائفه الأساسية وإن أراد شيء ما من الـ EmailService يقوم باستدعاء فقط دون أن يهتم بالتفاصيل الخاصة بالـ EmailService والدوال التي به
وأي كلاس يحتاج شيء ما من الـ EmailService سيقوم فقط باستدعاء الدالة التي يريدها منه

فمثلا هنا دالة signUp كانت تريد أن ترسل OTP فبكل بساطة قامت باستدعاء الكلاس المسؤول عن ارسال الرسائل EmailService وقامت بتنفيذ الدالة التي ارادتها sendOTP
ودالة أخرى verifyAccount قامت بتنفيذ الدالة sendEmailMessage لإرسال رسالة معينة

فهنا الـ AuthService لم يقم بإنشاء دالة sendOTP أو sendEmailMessage عنده بل قام باستدعائها من الكلاس المسؤول عنها وهو الـ EmailService

هكذا نحن وحدنا وظائف كل كلاس وكل كلاس يحتوي على الدوال التي تخدم وظيفته وأي كلاس يحتاج شيء ما من كلاس آخر معين سيقوم فقط باستدعاء الكلاس الآخر المسؤول عن هذا الشيء

وإن حدثت مشكلة ما في الـ EmailService أو تريد تعديل إحدى دواله فأنت ستقوم بالتعديل في مكان واحد فقط وهو الكلاس EmailService دون أن تأثر أو تعدل شيء في باقي الكلاسات الأخرى كـ AuthService وغيره
فأنت تعدل فقط في الكلاس المسؤول عن هذا الشيء

الأمور هكذا أصبحت منظمة أكثر وتسهل علينا أمور عديدة في كتابة الكود وتبسيطه وتعديله وكل تلك الأمور الجميلة التي يتغنى بها الجميع

مبدأ الـ Open/Closed

اسم Open/Closed هو اختصار لـ Open for extension and closed for modification ومعناه أن الكلاس قابل ليتم وراثته وممنوع التعديل عليه

فمبدأ الـ Open/Closed يركز على أننا لدينا كلاس أساسي يقوم بتنفيذ وظيفته على أكمل وجب
المبدأ يقول لك ألا تقوم بالتعديل على هذا الكلاس الأساسي طالما هو يقوم بتنفيذ وظيفته
وإذا أردت إضافة وظائف جديدة للكلاس كدوال جديدة أو مميزات جديدة قم بوراثته في كلاس آخر جديد وقم بعمل ما تريده فيه

المبدأ يشجع على استخدام الـ Interface أو Abstract Class لتعريف الدوال الأساسية والأمور الأساسية التي ستكون عامة عند الجميع
ولو أراد كلاس معين اضافة شيء جديد خاص به أو تعديل شيء ما، يقوم بعمل implement للـ Interface أو extend للـ Abstract Class ويقوم بإضافة وتعديل ما يريده

ستقول لي أن هذا اشبه بالأمور التي تعلمناها في مقالة الـ Inheritance والـ Polymorphism هذا صحيح لأنه كما قلنا أن الـ SOLID هي أفكار ومبادئ لا أكثر وأنت تستخدمها وتطبقها على قدر المستطاع بالطريقة التي تناسبك
والـ Inheritance والـ Polymorphism هما أحد الأفكار التي تساعدك على تطبيق مبدأ الـ Open/Closed

مثال يناقض المبدأ

فتخيل معي كلاس يدعى ProductService وهو كلاس يهتم بالمنتجات بشكل عام
تخيل معي أن هذا الكلاس لديه دالة تدعى calculateProductPrice تقوم بحساب المنتجات وتطبيق بعض الخصومات بحسب نوع المنتج
فلو كان من منتجات الألبان يتم خصم مبلغ معين ولو كان منتج أجهزة كهربائية يتم تطبيق خصم مختلف

الأمر سيشبه شيء كهذا

class ProductService {
  calculateProductPrice(product: Product) {
    if (product.type === 'food') {
      return product.price - 10;
    } else if (product.type === 'electric') {
      return product.price - 20;
    } else if (product.type === 'clothes') {
      return product.price - 30;
    }
    // else if (...) { }
    // else if (...) { }
    // ... etc.
  }

  // ...
}

الآن كلاس الـ ProductService قد يكون كلاس جميل ويطبق مبدأ الـ Single Responsibility بشكل جيد
لكن به مشكله صغيرة وهي أنه يحتوي على دالة calculateProductPrice التي تقوم بتطبيق الخصومات على المنتجات بحسب نوعه
وكما تلاحظ فالدالة تحتوي على if و else if وهذا يعني أنه يمكن أن يكون هناك تعديلات كثيرة على الدالة وسنضع if و else if كل مرة

بالتالي فالكلاس في هذه الدالة لا يطبق مبدأ الـ Open/Closed لأنه سيتم تعديل هذه الدالة على مدار الساعة بشكل دائم
والمبدأ ينص على أننا يجب أن نجعل الأشياء قابلة لتوسع لكن في نفس الوقت لا يتم تعديل الدالة مجددًا
لكن كيف نجعل الدالة calculateProductPrice قابلة للتوسع وفي نفس اللحظة ا نقوم بتعديلها ؟

مثال يوافق المبدأ

الحل بكل بساطة هو أن نقوم بعمل Interface أو Abstract Class يكون به الدوال العامة لكل منتج
وكل نوع منتجات معينة سيقوم بعمل implement لهذا الـ Interface أو extend للـ Abstract Class ويقوم بتعديل وإضافة ما يريده

فمثلا سنقوم بعمل Interface يدعى IProduct يحتوي على دالة calculatePrice
وسنقوم بعمل implement لهذا الـ Interface في كلاسات المنتجات المختلفة مثل FoodProduct, ElectricProduct, ClothesProduct وغيرها
وكل كلاس سيقوم بعمل implement للدالة calculatePrice بالطريقة التي يريدها

interface IProduct {
  calculatePrice(): number;
}

class FoodProduct implements IProduct {
  calculatePrice() {
    return this.price - 10;
  }
}

class ElectricProduct implements IProduct {
  calculatePrice() {
    return this.price - 20;
  }
}

class ClothesProduct implements IProduct {
  calculatePrice() {
    return this.price - 30;
  }
}

الآن نستطيع أن نذهب للدالة calculateProductPrice التي كان بها مشكلة الـ if و else if

class ProductService {
  calculateProductPrice(product: Product) {
    if (product.type === 'food') {
      return product.price - 10;
    } else if (product.type === 'electric') {
      return product.price - 20;
    } else if (product.type === 'clothes') {
      return product.price - 30;
    }
    // else if (...) { }
    // else if (...) { }
    // ... etc.
  }

  // ...
}

ونقوم بتعديلها لتقوم بالتعامل مع الـ IProduct وتستدعي دالة calculatePrice منه

class ProductService {
  calculateProductPrice(product: IProduct) {
    return product.calculatePrice();
  }

  // ...
}

هكذا الآن الدالة calculateProductPrice أصبحت تقوم بتطبيق الخصومات على المنتجات بحسب نوعها بدون الحاجة للـ if و else if
وأيضًا أصبحت تطبق مبدأ الـ Open/Closed بامتياز
الآن الدالة تعتمد على الـ IProduct وهو الـ Interface الذي يحتوي على دالة calculatePrice
وأيًا الـ object الذي يتم إرساله للدالة سيقوم بتطبيق الدالة calculatePrice بالطريقة التي يريدها هذا الـ object
سواء كان هذا الـ object من نوع FoodProduct أو ElectricProduct أو ClothesProduct أو غيرها

وإن أردت اضافة نوع منتجات جديدة بخصومات مختلفة فقط قم بعمل implement للـ IProduct وقم بتعديل الدالة calculatePrice بالطريقة التي تريدها
دون أن تقوم بتعديل أي شيء في الدالة calculateProductPrice في الـ ProductService

الدالة أصبحت كما ينص المبدأ دالة مفتوحة دائمًا للتوسع واستقبال أي نوع مهما كان وأصبحت مغلقة تمامًا بحيث لن يتم تعديلها مجددًا

يمكنك أن تتطبق فكرة المبدأ بطرق مختلفة سواء على مستوى الدوال أول على مستوى الكلاسات
لأن المبدأ مجرد فكرة وأنت توظفها بالشكل الذي يناسبك
يمكن أن يكون لديك كلاس يدعى EmployeeService به دوال متعددة تخدم وظيفته وأنت اختبرتها وتأكدت منها والكلاس يقوم بواجبه على أكمل شكل ممكن
وأنت الآن مضطر لإضافة بعض المميزات الجديدة فمثلا تريد أن تضيف إلى الـ EmployeeService خانة لتخزين عدد الساعات الاضافية التي يعملها بعض الموظفين خارج اطار ساعات عمله الطبيعة
وتريد اضافة دوال جديدة تحسب وتخدم على فكرة الساعات الجديدة وحساب راتبه وتلك الأمور
فبدلًا من أن تقوم بتعديل الكلاس الأساسي EmployeeService يمكنك أن ترثه في كلاس جديد يدعى SlaveEmployeeService وتقوم بتعديل وإضافة ما تريده فيه

هكذا حققت المبدأ بطريقة مختلفة على مستوى الكلاسات بحيث أنك ثبت الكلاس الأساسي EmployeeService ولم تقم بالتعديل عليه وقمت بعمل كلاس جديد SlaveEmployeeService وقمت بتعديل وإضافة ما تريده فيه
هكذا ينص المبدأ لكن طبقناه بشكل آخر Open to extension and closed to modification

مبدأ الـ Liskov Substitution

هذا المبدأ يقوم بتعريف وتحسين كيف ومتى نقوم بعملية الوراثة بشكل صحيح
أو كيف نقوم ببناء والتعامل مع أي الـ interface بطريقة صحيحة

بمعنى أن مبدأ الـ Liskov جاء ليعلمنا كيف نستعمل الوراثة في الكلاسات والـ interface بشكل صحيح

ينص مبدأ الـ Liskov Substitution على أنه إذا كان لديك كلاس أساسي وكلاسات ترث منه أو تقوم ببناءه، فيجب أن يكون الـ object من الكلاس الأساسي قابل للتبديل مع أي object من الكلاسات التي ترثه أو تبنيه دون نقصان

قد تقول لي الآن بأن هذا ما يحدث عندما يرث كلاس ما كلاس آخر فأنه سوف يرثه دون نقصان
هذا ما نراه في مفهوم الـ Inheritance بالفعل أو عندما نقوم بعمل implement لـ interface، إذًا أين المشكلة ؟

المشكلة أنه أحيانًا قد تجد كلاس يرث من كلاس معين لكن لا يستطيع تنفيذ دالة موجودة في الكلاس الأساسي
أو لا يستطيع عمل override لها لانه لا يمتلك الوظيفة التي تقدمها هذه الدالة

فمثلا لو كان لديك Interface يدعى IBird يمثل الطيور وبه دوال عديدة ومنها دالة fly
ثم قمنا بإنشاء كلاس يدعى Penguin أي بطريق وجعلناه يقوم بعمل implement للـ IBird
هنا ستجد أن الـ Penguin لا يستطيع تنفيذ دالة fly التي يقدمها الـ IBird لأن البطريق لا يستطيع الطيران

إذا كان الـ object لا يقوم بعمل implementation لدالة واحدة من الـ Interface فهذا يدل أن نوع هذا الـ object لا يستحق أن يكون تابع لهذا الـ Interface

إذا فتلك العلاقة ما بين الـ Penguin والـ IBird لا تتبع مبدأ الـ Liskov Substitution لأن الـ Penguin لا يستطيع تنفيذ دالة fly التي يقدمها الـ IBird
أن ننشيء interface مخصص للطيور التي تطير IFlyableBird و interface آخر للطيور التي لا تسطيع الطيران IUnFlyableBird

الـ Liskov يعطيك شرط معين لتقيم العلاقة بين كلاس وكلاس آخر أو كلاس و Interface
وتقيس هل هذه العلاقة صحيحة أم لا أو هل سيتم تطبيقها بشكل صحيح أم لا

وهكذا تبدأ في تنظيم الكلاسات والـ interface التي لديك بشكل منطقي وصحيح

مثال يناقض المبدأ

لنفترض أننا نمتلك كلاس عادي جدًا جميل يدعى Product يحتوى على بعض المتغيرات والدوال المتعلقة بالمنتجات بشكل عام
مثل عنوان المنتج تصنيفه وتاريخ الصلاحية

أنا جعلته كلاس عادي للتبسيط لكن يمكنك تخيل نفس المثال على Abstract Class أو Interface

class Product {
  constructor(
    private title: string,
    private price: number,
    private expiredDate: Date
  ) {}

  public getTitle(): string {
    return this.title;
  }

  public getPrice(): number {
    return this.price;
  }

  public getExpiredDate(): Date {
    return this.expiredDate;
  }
}

ثم لنتخيل أننا نملك دالة تدعى printProductInfo تستقبل object من نوع الـ Product وتقوم بطباعة بعض المعلومات عنه

function printProductInfo(product: Product) {
  console.log({
    title: product.getTitle(),
    price: product.getPrice(),
    expiredDate: product.getExpiredDate(),
  });
}

هذه الدالة تستقبل object من نوع الـ Product ثم تستدعي الدوال التي تتوقعها من الـ Product مثل getTitle و getPrice و getExpiredDate
وركز على كلمة التي تتوقعها لأن هذا هو مربط الفرس في الموضوع

لنقل أننا لدينا أنواع من المنتجات مثل ألبان ولحوم وملابس وأجهزة كهربائية وغيرها

class MilkProduct extends Product {
  constructor(title: string, price: number) {
    // set expired date to 7 days from now
    let expirationDate = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7);

    super(title, price, expirationDate);
  }
}

class MeatProduct extends Product {
  constructor(title: string, price: number) {
    // set expired date to 30 days from now
    let expirationDate = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30);

    super(title, price, expirationDate);
  }
}

let milkProduct = new MilkProduct('حليب جهينة', 12);
let meatProduct = new MeatProduct('لحم بقري', 350);

لا يوجد أي مشاكل حتى الآن، فقط قمنا بعمل كلاسات جديدة ترث من الـ Product وهما MilkProduct و MeatProduct ووضعنا في كل منهما تاريخ صلاحية مختلف

ثم أنشأنا منتجات منهما مثل milkProduct و meatProduct

الآن دعونا نستخدم الدالة printProductInfo لطباعة بعض المعلومات عن المنتجات

printProductInfo(milkProduct);
/*
{
  title: 'حليب جهينة',
  price: 12,
  expiredDate: 2024-01-08T00:00:00.000Z
}
*/

printProductInfo(meatProduct);
/*
{
  title: 'لحم بقري',
  price: 350,
  expiredDate: 2024-01-30T00:00:00.000Z
}
*/

الأمور جيدة ولا تحتوي على أي مشاكل كلا الـ MilkProduct والـ MeatProduct يقومان بوراثة Product دون مشاكل أو نقصان

لكن أنظر إلى ماذا سيحدث عندما ننشيء كلاس خاص بالمنتجات الملابس أو الأجهزة الكهربائية

class ClothesProduct extends Product {
  constructor(title: string, price: number) {
    // clothes do not have an expiration date
    super(title, price, null);
  }

  // clothes can't use getExpiredDate method
  // so we need to override it and throw an error
  public getExpiredDate(): Date {
    throw new Error('Clothes does not have an expired date');
  }
}

let clothesProduct = new ClothesProduct('قميص رجالي', 150);

printProductInfo(clothesProduct); // Error: Clothes does not have an expired date

هنا ستظهر مشكلة كلاس الـ ClothesProduct يقوم بعمل بوراثة الـ Product لكن الملابس لا تمتلك تاريخ صلاحية
بالتالي لن يستطيع وراثة أو تنفيذ الدالة getExpiredDate بشكل صحيح

والدالة printProductInfo تتوقع أن تكون الدالة getExpiredDate موجودة في أي object ينتمي إلى الـ Product وتستطيع استدعائها بدون مشاكل وتنفذ ما هو متوقع منها
لكنها استقبلت clothesProduct وهو من نوع ClothesProduct والذي هو بالفعل من نوع Product لكنه لا يستطيع تنفيذ الدالة getExpiredDate

بالتالي عندما حاولت الدالة printProductInfo استدعاء الدالة getExpiredDate وجدت تستقبل خطأ لم تتوقع ولم يتم ارجاع التاريخ الصلاحية كما كان متوقعًا

وهذا يعني أن العلاقة بين الـ ClothesProduct والـ Product لا تتبع مبدأ الـ Liskov Substitution لأن الـ ClothesProduct لا يستطيع تنفيذ دالة getExpiredDate التي يقدمها الـ Product

سترى نفس المشكلة عندما تنشيء كلاس للأجهزة الكهربائية وغيرها

ملحوظة: قد يكون كلاس يرث كلاس آخر وينفذ كل شيء فيه لكن ينفذها بطريقة تتعارض مع الفكرة الأساسية للكلاس الأساسي
فمثلًا دالة موجودة في الكلاس الأساسي ترجع أرقام موجبة فقط ثم يأتي كلاس يرث منه ويجعل الدالة ترجع أرقام سالبة فقط، كهذا هو خالف الشرط الأساسي لاننا نتوقع من الكلاس الأساسي أرقام موجبة
لكن لو كانت الدالة في الكلاس الأساسي ترجع أرقام موجبة وسالبة ثم أتى كلاس آخر ورث منه وجعل الدالة ترجع أرقام موجبة فقط فلا يوجد مشاكل
لأننا في المثال الثاني نتوقع في الكلاس الأساسي أرقام بشكل عام سواء موجبة وسالبة ولو حصلنا على أرقام سالبة فقط من الكلاس الذي يرثه فهذا ضمن توقعنا لذا فهو يوافق مبدأ الـ Liskov
أما في المثال الأول فأننا نتوقع أرقام موجبة فقط في الكلاس الأساسي فعندما نحصل على أرقام سالبة من الكلاس الذي يرثه فهذا خارج عن توقعنا ويعارض مبدأ الـ Liskov

مثال يوافق المبدأ

الحل قد يكون في تغير جزري للكود سواء بتغير الـ Product أو بإنشاء interface مختلفة لكل نوع من المنتجات
مثلا IFoodProduct و IClothesProduct و IElectricProduct وغيرها

نحن سنجعل الأمور بسيطة أولًا يمكننا إبقاء Product وجعله Abstract Class ليمثل الشكل العام للمنتجات والمتغيرات العامة التي تحتويها

abstract class Product {
  constructor(private title: string, private price: number) {}

  public getTitle(): string {
    return this.title;
  }

  public getPrice(): number {
    return this.price;
  }

  public abstract printInfo(): void;
}

قمنا بعمل تغيرات بسيطة على الـ Product وتخلصنا من الـ expirationDate لأنها اتضح لنا ان ليس كل المنتجات لها تاريخ صلاحية
ثم قمنا بإنشاء دالة printInfo داخل الـ Product وجعلناها abstract لكي يتم تنفيذها في كل كلاس يرث من الـ Product

قد تقوم أنت بتغيرات جزرية وهذا وارد لكل تصل لشكل أفضل وتنظيم مقبول للكود الخاص بلك

الآن لنعرف الكلاسات التي ستمثل المنتجات المختلفة

class MilkProduct extends Product {
  private expiredDate: Date;

  constructor(title: string, price: number) {
    super(title, price);

    // set expired date to 7 days from now
    this.expirationDate = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7);
  }

  public printInfo(): void {
    console.log({
      title: this.getTitle(),
      price: this.getPrice(),
      expiredDate: this.expiredDate,
    });
  }
}

let milkProduct = new MilkProduct('حليب جهينة', 12);
milkProduct.printInfo();
/*
{
  title: 'حليب جهينة',
  price: 12,
  expiredDate: 2024-01-08T00:00:00.000Z
}
*/

لاحظ كيف أنشأنا المتغير expirationDate داخل كلاس الـ MilkProduct فقط
ثم قمنا بتعديل الدالة printInfo لتطبع المعلومات الخاصة بالـ MilkProduct بشكل صحيح

وهكذا يمكنك أن تنشئ كلاسات أخرى للمنتجات الأخرى مثل اللحوم والملابس والأجهزة الكهربائية وغيرها ونعرف

class ClothesProduct extends Product {
  private brand: string;
  constructor(title: string, price: number, brand: string) {
    super(title, price);
    this.brand = brand;
  }

  public printInfo(): void {
    console.log({
      title: this.getTitle(),
      price: this.getPrice(),
      brand: this.brand,
    });
  }
}

let clothesProduct = new ClothesProduct('قميص رجالي', 150, 'Sutra');
clothesProduct.printInfo();
/*
{
  title: 'قميص رجالي',
  price: 150,
  brand: 'Sutra'
}
*/

لاحظ أن كلاس منتجات الملابس ClothesProduct لم يعد به أي مشاكل لأنه لم يعد مجبرًا على استعمال أو تنفيذ getExpiredDate كما في السابق لانه لم تعد ضمن الـ Product
وأصبح لديه دالة printInfo الخاصة به والتي تقوم بطباعة المعلومات الخاصة بالملابس

وهكذا يمكنك أن تنشئ كلاسات أخرى للمنتجات الأخرى بدون أي مشاكل

مبدأ الـ Interface Segregation

هذا المبدأ يقول لك أنه يجب أن تقوم بتقسيم الـ interface الخاص بك إلى عدة interface صغيرة ومتعددة بدلاً من interface كبير وواحد

لأن مشكلة الـ interface أنه يجبرك على استخدام متغيرات وتنفيذ دوال قد لا تريدها
وهذا وارد في الـ interface الكبير الذي يضم العديد من الدوال والمتغيرات فستجد نفسك مجبور على تنفيذ واستخدام أشياء لا تريدها

لذا فالمبدأ يركز على تقسيم الـ interface الكبير إلى عدة interface صغيرة وكل واحدة تركز على شيء معين

تخيل معي أنك قمت بعمل interface يدعى Person وقمت بتعريف بعض المتغيرات والدوال التي يجب أن يحتويها كل object ينتمي لعائلة interface الـ Person
ووضعت فيه متغيرات ودوال مثل name, workplace, salary, getName, getWorkplace, getSalary وغيرها

تخيل أنك تريد استخدام هذا الـ interface في كلاسات مختلفة مثل Employee, Customer, Admin, Student وغيرها
وتجد نفسك مجبرًا على تنفيذ واستخدام الدوال التي لا تحتاجها في كل كلاس
مثل workplace و salary لا تحتاجها في الكلاسات Customer و Student لانهم ليس لديهم مكان عمل او راتب
ولكنك مجبر على تنفيذها لأنها موجودة في الـ interface الـ Person

تلك المشكلة ظهرت معنا في مبدأ الـ Liskov Substitution عندما كان كلاس ClothesProduct مجبر على تنفيذ دالة getExpiredDate برغم أنه لا يحتاجها

لذا فالمبدأ يهتم بتلك المشكلة لكن من ناحية الـ interface بتقسيمه إلى عدة interface صغيرة ليمنع تلك المشكلة ولا يتم اجبار الكلاسات على تنفيذ واستخدام الدوال التي لا تحتاجها

يمكننا أن نقول أن تحقيق مبدأ الـ Interface Segregation يساعد على تحقيق مبدأ الـ Liskov Substitution وتفادي مشاكله إلى حد ما
لكن مبدأ Interface Segregation يركز على تقسيم الـ interface بشكل عام وتجنب الإجبار على استخدام الدوال التي لا تحتاجها

مثال يناقض المبدأ

تخيل معين أننا لدينا interface يدعى IMailData يحتوي على العديد من الأمور التي قد نستخدمها في إرسال أي شيء عبر البريد الإلكتروني

interface IMailData {
  from: string;
  to: string;
  senderEmail: string;
  receiverEmail: string;
  subject: string;
  message: string;
  otp: string;
  otpExpiredAt: Date;
  attachments: string[];

  // ... etc.
}

ثم لنفترض أننا لدينا دوال تستقبل هذا الـ interface مثل sendReport, sendInfo, sendOTP

class EmailService {
  public sendInfo(mailData: IMailData) {
    // I don't need otp or attachments
  }

  public sendReport(mailData: IMailData) {
    // I don't need otp
  }

  public sendOTP(mailData: IMailData) {
    // I don't need attachments
  }

  // ... etc.
}

لاحظ أن كل دالة تستقبل نفس الـ interface وهو يحتوي على العديد من الأمور التي قد لا تحتاجها كل الدوال
لكن بسبب interface الكبير فأنت مجبر على ارسال كل البيانات التي يريدها حتى لو لم تستخدمها داخل الدالة

فسينتهي بك الأمر بكود يبدو كهذا

let emailService = new EmailService();

emailService.sendInfo({
  from: 'Ahmed El-Tabarani';
  to: 'إلى من يهمه الأمر';
  senderEmail: 'eltabaraniahmed@gmail';
  receiverEmail: '[email protected]';
  subject: 'مقالة جديدة';
  message: 'مقالة عن مبادئ يُقال أنها مهمة';
  attachments: null; // i don't need it
  otp: null; // I don't need it
  otpExpiredAt: null; // I don't need it
  // ... etc.
})

لاحظ أننا قمنا بإرسال كامل البيانات التي يطلبها من الـ interface بشكل اجباري
لذا احيانًا قد تجد ترسل null للبيانات التي لا تحتاجها

مثال يوافق المبدأ

الحل بسيط وهو تقسيم الـ interface الكبير إلى عدة interface صغيرة وكل واحدة تركز على شيء معين

interface IBasicMailData {
  from: string;
  to: string;
  senderEmail: string;
  receiverEmail: string;
  subject: string;
  message: string;
}

interface IOTPData extends IBasicMailData {
  otp: string;
  otpExpiredAt: Date;
}

interface IReportMailData extends IBasicMailData {
  attachments: string[];
}

ثم يمكنك استخدام الـ interface التي تحتاجها فقط داخل كل دالة

class EmailService {
  public sendInfo(infoData: IBasicMailData) {
    // now i have all the data i need in IBasicMailData
  }

  public sendReport(reportData: IReportMailData) {
    // now i have all the data i need in IReportMailData
  }

  public sendOTP(otpData: IOTPData) {
    // now i have all the data i need in IOTPData
  }

  // ... etc.
}

let emailService = new EmailService();
emailService.sendInfo({
  from: 'Ahmed El-Tabarani';
  to: 'إلى من يهمه الأمر';
  senderEmail: 'eltabaraniahmed@gmail';
  receiverEmail: '[email protected]';
  subject: 'مقالة جديدة';
  message: 'مقالة عن مبادئ يُقال أنها مهمة';
})

هكذا أصبح لديك القدرة على إرسال البيانات التي تحتاجها فقط داخل كل دالة دون الحاجة لإرسال البيانات الأخرى التي لا تحتاجها
وهذا يعني أنك تقوم بتطبيق مبدأ الـ Interface Segregation بشكل صحيح
وهو كما ترى مبدأ بسيط وجميل يساعدك على إنشاء interface صغيرة تخدمك في جزئية معينة دون أن يتم اجبارنا على استخدام البيانات التي لا نحتاجها

يمكنك أن تتخيل مبدأ الـ Interface Segregation على أنه مبدأ الـ Single Responsibility على مستوى الـ interface

مبدأ الـ Dependency Inversion

هذا المبدأ يركز على منع الاعتماد المباشر بين الكلاسات
بل يجب أن تعتمد على الـ Interface أو الـ Abstract Class

بمعنى أنه يجب أن تعتمد على الـ Interface بدلاً من الكلاسات المحددة بذاتها
فإن كان هناك كلاس يدعى NotificationService ويعتمد على كلاس EmailService و SMSService بشكل مباشر فهذا خطأ
لأنه فرضًا أننا تخلينا عن الـ EmailService أو أضفنا أنواع جديدة من الخدمات مثل PushNotificationService أو WhatsAppService هل ستذهب وتعدل الـ NotificationService ليعتمد على الخدمات الجديدة؟ ماذا لو اضفنا 10 خدمات هل ستنشيء object لكل خدمة داخل الـ NotificationService؟

بالطبع لا، لذا يجب أن تجعل الـ NotificationService يعتمد على الـ interface أو abstract class بدلاً من الكلاسات المحددة بذاتها
هذا interface قد يدعى INotifiableProvider ويحتوي على دالة أساسية مثل notify
ثم تجعل الكلاس NotificationService يعتمد على object من هذا الـ interface بدلاً من الكلاسات المحددة بذاتها
ثم تقوم بإنشاء كلاسات تبني هذا الـ interface الـ INotifiableProvider مثل EmailService و SMSService و PushNotificationService و WhatsAppService وغيرها

هكذا يمكنك أن تضيف أي خدمة جديدة دون الحاجة لتعديل الـ NotificationService
الأمر مشابه لمبدأ الـ Open/Closed بعض الشيء لكنه يركز على الاعتماد المباشر بين الكلاسات وفصلها

مثال يناقض المبدأ

تخيل أن لديك كلاس يدعى OrderService مسؤول عن طلبات المنتجات
وهذا الكلاس يعتمد على كلاسات محددة بذاتها مثل FoodProductService و ClothesProductService و ElectricProductService

class OrderService {
  public foodProductService: FoodProductService;
  public clothesProductService: ClothesProductService;
  public electricProductService: ElectricProductService;
  constructor() {
    foodProductService = new FoodProductService();
    clothesProductService = new ClothesProductService();
    electricProductService = new ElectricProductService();
  }

  public orderFoodProduct() {
    foodProductService.order();
  }

  public orderClothesProduct() {
    clothesProductService.order();
  }

  public orderElectricProduct() {
    electricProductService.order();
  }
}

let orderService = new OrderService();
// it will create three objects
// FoodProductService, ClothesProductService, ElectricProductService

// but we need only need FoodProductService here
orderService.orderFoodProduct();

لاحظ أن الـ OrderService يعتمد على كلاسات محددة بذاتها مثل FoodProductService و ClothesProductService و ElectricProductService
فهو يقوم بإنشاء object من كل كلاس داخل الـ constructor ويعتمد عليها
ويقوم بعمل دالة مخصصة للتعامل مع كل منتج بشكل منفصل

أظنك لاحظت العديد من المشاكل هنا منها

هنا العلاقة بين الـ OrderService وبين الـ FoodProductService والـ ClothesProductService والـ ElectricProductService لا تتبع مبدأ الـ Dependency Inversion لأن الـ OrderService يعتمد على هذه الكلاسات اعتماد كلي وبشكل مباشر

مثال يوافق المبدأ

الحل بسيط وهو جعل الـ OrderService يعتمد على interface بدلاً من الكلاسات المحددة بذاتها
يمكن للـ OrderService أن يستقبل object من الـ interface من خلال الـ constructor ويعتمد عليه
ويمكنك أن تنشئ كلاسات تنفذ هذا الـ interface ثم عندما تستدعي الـ OrderService تقوم بإرسال object من كلاس المنتجات التي تريده فقط

لنقم بإنشاء interface يدعى IProductService ويحتوي على دالة order

interface IProductService {
  order(): void;
  // ... etc.
}

الآن يمكنك أن تنشئ كلاسات تنفذ هذا الـ interface مثل FoodProductService و ClothesProductService و ElectricProductService وغيرها

class FoodProductService implements IProductService {
  public order(): void {
    console.log('Ordering food product');
  }
}

class ClothesProductService implements IProductService {
  public order(): void {
    console.log('Ordering clothes product');
  }
}

class ElectricProductService implements IProductService {
  public order(): void {
    console.log('Ordering electric product');
  }
}

الآن يمكننا تعديل الـ OrderService ليعتمد على الـ IProductService الـ interface بدلاً من الكلاسات المحددة بذاتها

class OrderService {
  public productService: IProductService;
  constructor(productService: IProductService) {
    this.productService = productService;
  }

  public orderProduct() {
    this.productService.order();
  }
}

الآن يمكنك أن تنشئ object من الـ OrderService وترسل أي object من ينتمي إلى عائلة الـ IProductService

let foodProductService = new FoodProductService();
let orderService = new OrderService(foodProductService);
orderService.orderProduct(); // Ordering food product

///////////////////////////////////////

let clothesProductService = new ClothesProductService();
let orderService = new OrderService(clothesProductService);
orderService.orderProduct(); // Ordering clothes product

///////////////////////////////////////

let electricProductService = new ElectricProductService();
let orderService = new OrderService(electricProductService);
orderService.orderProduct(); // Ordering electric product

لاحظ كيف أننا عندما ننشيء object من الـ OrderService نقوم بإرسال فقط object من الكلاس المنتج الذي نريده
ويكون الـ OrderService يعتمد على الـ IProductService بغض النظر عن نوعه، اعتمادية مجردة من أي تفاصيل

هكذا يمكنك أن تضيف أي خدمة جديدة في كالاس جديد دون الحاجة لتعديل أي شيء في الـ OrderService هذا يجعل الكود أكثر مرونة وسهولة في الصيانة

خاتمة وملخص مع أمثلة مختلفة

تذكر أن مبادئ الـ SOLID ليست قوانين صارمة يجب عليك اتباعها بشكل مطلق
بل هي مجرد مجرد أفكار تساعدك على كتابة الكود بشكل منظم وسلس
وكل مبدأ يركز على فكرة معينة ويهدف لجعل الكود سهل التعديل عليه على قدر المستطاع
وجعله أكثر قابلية لتغير وتعديل واختباره وايجاد الأخطاء وسهل القراءة

وأفكار التي يركز عليها الـ SOLID من وجه نظري هي تمهيد قوي لتعلم وفهم الـ Design Patterns بشكل أفضل وأسهل

أهم النقاط التي عليك معرفتها عن كل مبدأ

Single Responsibility

يركز على تقسيم الكلاسات إلى وحدات وظيفية مستقلة أي أن كل كلاس يجب أن يخدم على وظيفة واحدة فقط
مثل كلاس الـ UserService يجب أن يكون مسؤول عن كل شيء يتعلق بالمستخدم فقط
ولا يجب أن يكون مسؤول عن أي شيء آخر مثل الـ OrderService أو الـ ProductService أو غيرها

كلاس الـ NotificationService يجب أن يكون مسؤول عن إرسال الإشعارات فقط وهكذا ... إلخ
كل الدوال التي في الكلاس يجب أن تكون متعلقة بالوظيفة الرئيسية للكلاس التي تنتمي له
بمعنى لا تقوم بعمل دالة تدعى sendOTP في كلاس الـ AuthService لأنه ليس مسؤول عن ذلك
لاكن يمكنك أن تنشيء object من EmailService داخل الـ AuthService وتستدعي الدالة sendOTP من خلال هذا الـ object هكذا:

class AuthService {
  constructor(private emailService: EmailService) {}

  public signup(email: string, password: string) {
    // signup logic ...

    this.emailService.sendOTP(email);
  }
}

Open/Closed

يركز على تقليل التعديل على الكلاسات والدوال على قدر المستطاع عن طريق جعله مفتوحة للتوسيع ومغلقة للتعديل
بمعنى أنك يجب أن تكون قادرًا على إضافة ميزات جديدة دون الحاجة لتعديل الكود الحالية، طالما الكود يعمل فلا تعدل عليه

يمكنك أن تطبقه على أكثر من شكل وحالة سواء على مستوى الدوال أو على مستوى الكلاس ككل
على مستوى الدالة إذا كانت تعتمد على أكثر من نوع مختلفة من شيء معين مثل نوع المنتج او رتبة المستخدم
وتجد نفسك تقوم بعمل كود مختلف ليناسب كل نوع وتقوم بعمل if else لكل نوع
هنا إذا كنت تستطيع جعل الدالة تستقبل هذا الشيء الذي يضم أنواع مختلفة وتستقبل كـ object ويكون interface أو abstract class
ثم ترمي مسؤولية الـ implementation على الكلاسات الفرعية التي سيمثلها هذا الـ object

فبدلًا من

class PermissionService {
  public isAllowTo(user: User, action: string) {
    if (user.role === 'admin') {
      return true;
    } else if (
      user.role === 'editor' &&
      action === 'read' &&
      action === 'write' &&
      action === 'edit'
    ) {
      return true;
    } else if (user.role === 'viewer' && action === 'read') {
      return true;
    } else {
      return false;
    }
  }
}

تجعله هكذا

class PermissionService {
  public isAllowTo(user: IUser, action: string) {
    return user.isAllowTo(action);
  }
}

interface IUser {
  isAllowTo(action: string): boolean;
}

class Admin implements IUser {
  public isAllowTo(action: string) {
    return true;
  }
}

class Editor implements IUser {
  public isAllowTo(action: string) {
    return action === 'read' || action === 'write' || action === 'edit';
  }
}

class Viewer implements IUser {
  public isAllowTo(action: string) {
    return action === 'read';
  }
}

// ... ContentCreator, MediaBuyer, etc.

أو لو كانت الدالة تقوم بواجبها على أكمل شكل ولا تريد أن تعدلها أبدًا
أو لو كان الكلاس ككل يقوم بواجبه بالشكل المطلوب واختبرته وكتبت أكثر من unit test وكل شيء يعمل بشكل جيد
ولا تريد تعديله لكن تريدان تضيف عليه او تضيف أشياء جديدة للدوال
فيمكنك وراثة هذا الكلاس وتقوم بعمل override للدوال التي تريد تغيرها وتضيف الأشياء التي تريدها
وتبقي الكلاس الأساسي كما هو دون تعديل

Liskov Substitution

يركز على تقليل المشاكل التي قد تحدث عند استبدال كلاس بآخر يرث منه
فهو ينص على أن يكون الكلاس الفرعي قادرًا على القيام بنفس الوظائف التي يقوم بها الكلاس الأساسي دون ان ينقص منه شيء
وأن لا يحدث أي مشاكل عند استبدال الكلاس الأساسي بالكلاس الفرعي لأنه بطبيعة الحال يرث منه ويرث كل شيء يقوم به

فالمبدأ يحسن منطقنا في استخدامنا لمفهوم الوراثة وبناء الـ interface المختلفة بشكل منطقي ونحدد من ينتمي لمن وهل يصلح أن يكون الكلاس الفرعي يرث من الكلاس الأساسي أم لا

بمعنى هل من المنطقي أن ينتمي كلاس مثل Student إلى عائلة Employee ؟ بالطبع لا
لأن الـ Employee قد يحتوى على أمور لن يستطيع الـ Employee القيام بها مثل work أو salary وغيرها لأن الـ Employee متخصص وليس عام

لكن هل الـ Student يمكن أن ينتمي إلى عائلة Person ؟ بالطبع نعم
بشرط أن يقدم Person يحتوي على الأمور الأساسية فقط والعامة التي ستتواجد في جميع الأشخاص دون استثناء

أو قد يكون لديك كلاسات مثل CoffeeMachine و TeaMachine و CacaoMachine يقومان ببناء وتنفيذ كل شيء في interface يدعى IMachine بشكل كامل بدون نقصان
لكن أحد الكلاسات قرر تنفيذ دالة معينة بشكل خاطئ
بمعنى أنه قد يكون هناك دالة تدعى makeZeroSugarCup وهي دالة داخل الـ IMachine تقوم بعمل كوب ما بدون سكر
ثم تجد كلاس الـ CoffeeMachine يقوم بتنفيذ هذه الدالة بشكل خاطئ وبكل بجاحة يضيف عليه سكر

إذا فتلك الدالة منطقيًا لا تتبع مبدأ الـ Liskov لأن أي شخص يقوم بالتعامل مع IMachine
فهذا الشخص يتوقع أن الدالة makeZeroSugarCup تقوم بعمل كوب بدون سكر وليس بسكر ويقوم ببناء تطبيقه على هذا الأساس
ثم يتفاجيء أن هناك مشكلة غير متوقعة بسبب أن object عندما يكون من نوع CoffeeMachine يقوم بإضافة سكر ويفسد عليه كل شيء

function makeCupWithoutSugar(machine: IMachine) {
  return machine.makeZeroSugarCup();
}

let coffeeMachine = new CoffeeMachine();
let teaMachine = new teaMachine();
let cacaoMachine = new CacaoMachine();

makeCupWithoutSugar(teaMachine); // it will work fine
makeCupWithoutSugar(cacaoMachine); // it will work fine
makeCupWithoutSugar(coffeeMachine); // Unexpected bug, it will add sugar

صاحب الدالة makeCupWithoutSugar لم يتوقع أن يحدث هذا الأمر لأنه يعتمد على الـ interface الـ IMachine ويتوقع أن يكون كل شيء على ما يرام
الآن سيسهر الليلة ليبحث عن هذا الخطأ الخفي الغير متوقع

فالمبدأ الـ Liskov Substitution قد يتم مخالفته عن طريق عدم بناء الدالة وتنفيذها أو عن طريقة تنفيذها لكن بشكل خاطئ من حيث المنطق

Interface Segregation

يركز على تقسيم الـ interface الكبير إلى عدة interface صغيرة وكل واحدة تركز على شيء معين
بمعنى أنك لا تجعل الـ interface يحتوي على العديد من الأمور التي قد لا تحتاجها كل الكلاسات
بحيث لا تجبر من يقوم ببناء الـ interface على استخدام متغيرات أودوال لا يحتاجها

فإذا كان لديك interface يدعى IPerson ويحتوي على العديد من الأمور مثل:

interface IPerson {
  name: string;
  age: number;
  workplace: string;
  salary: number;
  school: string;
  grade: number;
  getSchool(): string;
  getGrade(): number;
  getWorkplace(): string;
  getSalary(): number;
}

هل تعتقد أن كلاسات مثل الـ Student يستطيع بناء IPerson واستخدام كل هذه الأمور؟ بالطبع لا
لأن الـ Student ليس لديه مكان عمل أو راتب ولا يحتاجها فلما يتم اجباره ؟
هنا نحتاج إلى تقسيم IPerson إلى IBasicPerson و IWorkablePerson و IEducablePerson وغيرها

interface IBasicPerson {
  name: string;
  age: number;
}

interface IWorkablePerson {
  workplace: string;
  salary: number;
  getWorkplace(): string;
  getSalary(): number;
}

interface IEducablePerson {
  school: string;
  grade: number;
  getSchool(): string;
  getGrade(): number;
}

ثم تجعل كل كلاس يبني ما يريده

class Student implements IBasicPerson, IEducablePerson {
  public name: string;
  public age: number;
  public school: string;
  public grade: number;

  public getSchool() {
    return this.school;
  }

  public getGrade() {
    return this.grade;
  }
}

class Employee implements IBasicPerson, IWorkablePerson {
  public name: string;
  public age: number;
  public workplace: string;
  public salary: number;

  public getWorkplace() {
    return this.workplace;
  }

  public getSalary() {
    return this.salary;
  }
}

Dependency Inversion

يركز على منع الاعتماد المباشر بين الكلاسات بمعنى أنه لا يأتي كلاس معين يعتمد على كلاسات محددة بذاتها
مثل أن يعتمد كلاس مثل NotificationService ويعتمد على كلاسات بعينها مثل EmailService و SMSService ويعتمد على هذه الأنواع بشكل مباشر

class NotificationService {
  public emailService: EmailService;
  public smsService: SMSService;
  constructor() {
    this.emailService = new EmailService();
    this.smsService = new SMSService();
  }

  public sendEmail() {
    this.emailService.send();
  }

  public sendSMS() {
    this.smsService.send();
  }
}

هذا الذي تراه الآن هو يتعارض مع المبدأ لأن الـ NotificationService يعتمد على كلاسات محددة بذاتها
ولأنه فرضًا لو تخلينا عن أحد هذه الكلاس أوأضفنا أنواع جديدة من الكلاسات فماذا ستفعل ؟
هل ستذهب وتعدل الـ NotificationService ليعتمد على هذه الكلاسات الجديدة؟ وتزيل القديم التي لم تعد تحتاجها؟

بالطبع لا، لذا يجب أن تجعل الـ NotificationService يعتمد على الـ interface أو abstract class بدلاً من الكلاسات المحددة بذاتها
هذا interface قد يدعى INotifiableProvider ويحتوي على دالة أساسية مثل notify
ثم تجعل الكلاس NotificationService يعتمد على object من هذا الـ interface بدلاً من الكلاسات المحددة بذاتها

interface INotifiableProvider {
  notify(): void;
}

class NotificationService {
  public notifiableProvider: INotifiableProvider;
  constructor(notifiableProvider: INotifiableProvider) {
    this.notifiableProvider = notifiableProvider;
  }

  public sendNotification() {
    this.notifiableProvider.notify();
  }
}

الآن يمكنك أن تقوم بإنشاء كلاسات تبني الـ INotifiableProvider مثل EmailService و SMSService و PushNotificationService و WhatsAppService وغيرها
وترسل ما تريده إلى الـ NotificationService وتجعله يقوم بالعمل بشكل طبيعي

let emailService = new EmailService();
let notificationService = new NotificationService(emailService);
notificationService.sendNotification();

///////////////////////////////////////

let smsService = new SMSService();
let notificationService = new NotificationService(smsService);
notificationService.sendNotification();

///////////////////////////////////////

let pushNotificationService = new PushNotificationService();
let notificationService = new NotificationService(pushNotificationService);
notificationService.sendNotification();

الآن يمكنك أن تزيل أو تضيف أي خدمة دون الحاجة لتعديل أي شيء في الـ NotificationService