هذه ستكون ملخص لمقالة بسيطة عن مبادئ الـ SOLID كنت قد نشرتها على مدونتي هنا https://tabarani.tk/article... مع شرح تفصيلي أكثر وأمثلة أخرى

مبادئ الـ 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