Практичний посібник із принципів SOLID для бекенд-розробників

Ці п'ять принципів розробки програмного забезпечення є керівництвом, якого слід дотримуватися під час створення програм, щоб їх було легше масштабувати та підтримувати. Вони були популяризовані інженером-програмістом Робертом К. Мартіном.

S — Принцип єдиної відповідальності (Single-responsibility Principle)

Клас повинен мати лише одну відповідальність і лише одну причину для змін.

Як застосувати цей принцип у нашому коді?

Запитайте себе:

  • Яку роль виконує цей клас у системі?
  • За яке одне завдання відповідає цей клас?
  • Якщо в системі відбудуться зміни, чи повинен цей клас бути змінений?

Наприклад, ми хочемо реалізувати функцію, де після оплати замовлення необхідно створити рахунок-фактуру та надіслати її клієнту електронною поштою.

Тут ми не повинні виконувати всі ці завдання в одному класі, наприклад, у PaymentService.

Ми можемо розбити функцію на три завдання:

  • Обробка оплати
  • Генерація рахунку-фактури
  • Надсилання рахунку-фактури електронною поштою

Виходячи з цих завдань, ми можемо створити три класи для реалізації функції, яку почали розробляти:

  • PaymentService: Обробляє оплату.
  • InvoiceService: Виконує операції, пов’язані з рахунками-фактурами.
  • EmailService: Відповідає за надсилання електронних листів.

Це забезпечує довговічність коду, адже в майбутньому:

  • якщо потрібно виконати операцію оплати без рахунку-фактури та електронного листа, можна використати клас PaymentService;
  • якщо потрібно виконати оплату та згенерувати рахунок-фактуру, але без надсилання електронного листа, можна використати класи PaymentService та InvoiceService;
  • якщо для іншої функції потрібно лише надіслати електронний лист, можна використати клас EmailService.

Таким чином, модульний підхід до коду допомагає уникнути дублювання, дозволяючи повторно використовувати класи замість копіювання однакового коду для різних функцій.

O — Принцип відкритості/закритості (Open-closed Principle)

Класи мають бути відкритими для розширення, але закритими для модифікації.

Як застосувати цей принцип у нашому застосунку?

Під час проектування застосунку слід враховувати, що додавати нову функціональність має бути легко, але при цьому існуючий код залишається незмінним.

Це зазвичай досягається за допомогою абстракцій (наприклад, інтерфейсів, базових класів) та поліморфізму.

Наприклад, у нас є клас PaymentProcessor для обробки платежів у застосунку.

public class PaymentProcessor  
{  
 public void ProcessPayment(string paymentType, decimal amount)  
 {  
 if (paymentType == "CreditCard")  
 {  
 // Process credit card payment  
 }  
 }  
}

Проблема цього прикладу в тому, що ми приймаємо тип платежу як параметр методу ProcessPayment і використовуємо умови if, щоб визначити метод платежу та обробити його.

Отже, коли потрібно додати інший метод оплати, наприклад PayPal, Google Pay, Apple Pay, то...
Нам потрібно змінити метод ProcessPayment і додати більше умов if залежно від типу платежу.

Це порушує принцип OCP, оскільки клас не закритий для модифікації.

Ми можемо застосувати OCP, використовуючи інтерфейс для визначення поведінки обробки платежів та дозволяючи розширення через нові реалізації:

public interface IPaymentMethod  
{  
 void ProcessPayment();  
}  

// Реалізація конкретних методів оплати  
public class CreditCardPayment : IPaymentMethod  
{  
 public void ProcessPayment()  
 {  
 Console.WriteLine("Processing credit card payment...");  
 }  
}  

public class PayPalPayment : IPaymentMethod  
{  
 public void ProcessPayment()  
 {  
 Console.WriteLine("Processing PayPal payment...");  
 }  
}  

// Обробник платежів тепер залежить від абстракції  
public class PaymentProcessor  
{  
 public void ProcessPayment(IPaymentMethod paymentMethod)  
 {  
 paymentMethod.ProcessPayment();  
 }  
}  

public class PaymentService  
{  
 private readonly IPaymentProcessor _paymentProcessor;  

 public PaymentService(IPaymentProcessor paymentProcessor)  
 {  
 _paymentProcessor = paymentProcessor;  
 }  

 public void MakePayment(decimal amount)  
 {  
 _paymentProcessor.ProcessPayment(amount);  
 }  
}
IPaymentProcessor creditCardProcessor = new CreditCardProcessor();  
IPaymentProcessor payPalProcessor = new PayPalProcessor();  

PaymentService creditCardPaymentService = new PaymentService(creditCardProcessor);  
PaymentService payPalPaymentService = new PaymentService(payPalProcessor);  
creditCardPaymentService.MakePayment(100m);  
payPalPaymentService.MakePayment(200m);

Тепер ми можемо додавати нові методи оплати, створюючи нові класи, а не змінюючи існуючі, що відповідає принципу відкритості/закритості.

L — Принцип підстановки Барбари Лісков (Liskov Substitution Principle)

Кожен підклас або похідний клас повинен бути взаємозамінним із базовим або батьківським класом.

Наприклад, ми хочемо конвертувати документ у HTML. Для цього у нас є інтерфейс IDocumentRenderer і класи, такі як PdfRenderer та MarkdownRenderer, які реалізують IDocumentRenderer.

Ми маємо забезпечити, щоб класи PdfRenderer та MarkdownRenderer завжди повертали очікуваний результат, рендерячи валідний контент у HTML, і ніколи не повертали null або порожнього значення.

public interface IDocumentRenderer  
{  
 // Контракт: Рендерити валідний контент у HTML (ніколи не повертає null/порожнього значення)  
 string RenderToHtml(string content);  
}  

public class PdfRenderer : IDocumentRenderer  
{  
 public string RenderToHtml(string content)  
 {  
 // Логіка конвертації PDF у HTML  
 return $"
{content}
";  
 }  
}  

public class MarkdownRenderer : IDocumentRenderer  
{  
 public string RenderToHtml(string content)  
 {  
 try  
 {  
 // Конвертація Markdown у HTML  
 // повернути HTML-рядок  
 }  
 catch  
 {  
 // Обробити помилку без виклику винятків  
 return "
Error rendering Markdown
";  
 }  
 }  
} 
IDocumentRenderer pdfService = new PdfRenderer();  
Console.WriteLine(pdfService.GeneratePreview("PDF content"));  

IDocumentRenderer mdService = new MarkdownRenderer();  
Console.WriteLine(mdService.GeneratePreview("## Hello Markdown"));

I — Принцип розділення інтерфейсу (Interface Segregation Principle)

Клієнти не повинні залежати від інтерфейсів, які вони не використовують, або від методів, які їм не потрібні.

Наприклад, ми хочемо надсилати сповіщення користувачам. Для цього можемо оголосити інтерфейс INotificationService:

public interface INotificationService  
{  
 void SendEmail(string emailAddress, string message);  
 void SendSms(string phoneNumber, string message);  
 void SendPushNotification(string deviceToken, string message);  
} 

Ми вважаємо, що цей інтерфейс відповідає за надсилання сповіщень. Потім з’являється сценарій, у якому потрібно надіслати електронний лист користувачу.
Отже, ми створюємо клас EmailService, який реалізує інтерфейс INotificationService, але це створює серйозну проблему:

public class EmailService : INotificationService  
{  
 public void SendEmail(string emailAddress, string message)  
 {  
 Console.WriteLine($"Email sent to {emailAddress}: {message}");  
 }  

 public void SendSms(string phoneNumber, string message)  
 {  
 throw new NotImplementedException("EmailNotification does not support SMS.");  
 }  
 public void SendPushNotification(string deviceToken, string message)  
 {  
 throw new NotImplementedException("EmailNotification does not support Push Notifications.");  
 }  
}

У класі EmailService ми змушені реалізовувати всі методи, навіть SendSms та SendPushNotification, які явно не потрібні.

Рішенням є застосування Принципу розділення інтерфейсів (Interface Segregation Principle).

Ми розділимо інтерфейс INotificationService на три окремі інтерфейси:

  • IEmailService: Відповідає за надсилання електронних листів.
  • ISmsService: Відповідає за надсилання SMS.
  • IPushNotificationService: Відповідає за надсилання push-сповіщень.
public interface IEmailService  
{  
 void SendEmail(string emailAddress, string message);  
}  

public interface ISmsService  
{  
 void SendSms(string phoneNumber, string message);  
}  

public interface IPushNotificationService  
{  
 void SendPushNotification(string deviceToken, string message);  
}

Потім ми можемо створити класи EmailService, SmsService і PushNotificationService, які реалізують ці інтерфейси:

public class EmailService : IEmailService  
{  
 public void SendEmail(string emailAddress, string message)  
 {  
 Console.WriteLine($"Email sent to {emailAddress}: {message}");  
 }  
}  

public class SmsService : ISmsService  
{  
 public void SendSms(string phoneNumber, string message)  
 {  
 Console.WriteLine($"SMS sent to {phoneNumber}: {message}");  
 }  
}  

public class PushNotificationService : IPushNotificationService  
{  
 public void SendPushNotification(string deviceToken, string message)  
 {  
 Console.WriteLine($"Push notification sent to {deviceToken}: {message}");  
 }  
}

Тепер жоден із цих класів не змушений реалізовувати методи, які їх не стосуються. Отже, проблема вирішена.

D — Принцип інверсії залежностей (Dependency Inversion Principle)

Модулі високого рівня не повинні залежати від модулів низького рівня. Обидва повинні залежати від абстракції.
Абстракції не повинні залежати від деталей. Деталі повинні залежати від абстракцій.

Ми вже застосовували цей принцип під час реалізації Принципу відкритості/закритості.

Дядько Боб формулює цей принцип так:

"Якщо OCP визначає мету об'єктно-орієнтованої архітектури, то DIP визначає її основний механізм".

public class EmailService  
{  
 public void Send(string message)  
 {  
 Console.WriteLine($"Email sent: {message}");  
 }  
}  

public class NotificationService  
{  
 private readonly EmailService _emailService;  
 public NotificationService()  
 {  
 _emailService = new EmailService(); // Пряма залежність від класу  
 }  
 public void SendNotification(string message)  
 {  
 _emailService.Send(message);  
 }  
}

Клас NotificationService безпосередньо залежить від класу EmailService. Таким чином, Принцип інверсії залежностей порушується.

Як це виправити?

Ми можемо запровадити інтерфейс IEmailService, який буде реалізований класом EmailService.

Після цього NotificationService не буде залежати від реалізації EmailService.
І клас NotificationService, і клас EmailService будуть залежати від абстракції IEmailService.

public interface IEmailService  
{  
 void Send(string message);  
}  

public class EmailService : IEmailService  
{  
 public void Send(string message)  
 {  
 Console.WriteLine($"Email sent: {message}");  
 }  
}
public class NotificationService  
{  
 private readonly IEmailService _emailService;  

 public NotificationService(IEmailService emailService)  
 {  
 _emailService = emailService;  
 }  
 public void SendNotification(string message)  
 {  
 _emailService.Send(message);  
 }  
}
IEmailService emailService = new EmailService();  

NotificationService emailNotificationService = new NotificationService(emailService);  
emailNotificationService.SendNotification("This is an email notification.");

Приклад: Використання вбудованого механізму впровадження залежностей (Dependency Injection) у .NET Core відповідно до Принципу інверсії залежностей

У .NET Core ми можемо використовувати вбудований контейнер DI (Dependency Injection), щоб ефективно дотримуватися Принципу інверсії залежностей. Ось як можна реєструвати сервіси та впроваджувати залежності:

  • AddSingleton: Створює один екземпляр сервісу, який використовується протягом усього життєвого циклу застосунку.
  • AddScoped: Створює екземпляр сервісу для кожного HTTP-запиту (або для кожної одиниці роботи в інших сценаріях).
  • AddTransient: Створює новий екземпляр сервісу кожного разу, коли він запитується.

Ось приклад, як ми можемо використовувати ці життєві цикли DI у нашому файлі Program.cs:

builder.Services.AddScoped();

Завдяки цьому нам не потрібно вручну впроваджувати залежність у клас NotificationService.

public class NotificationService  
{  
 private readonly IEmailService _emailService;  

 public NotificationService(IEmailService emailService)  
 {  
 _emailService = emailService;  
 }  
 public void SendNotification(string message)  
 {  
 _emailService.Send(message);  
 }  
}

З DI клас NotificationService автоматично отримує свої залежності під час створення екземпляра. Це гарантує, що класу не потрібно турбуватися про керування своїми залежностями безпосередньо, що відповідає Принципу інверсії залежностей.

Висновок

Принципи SOLID забезпечують основу для написання чистого, підтримуваного та масштабованого коду, який легше розуміти, розширювати й тестувати.

  • Принцип єдиної відповідальності (Single Responsibility Principle) гарантує, що кожен клас зосереджується на одній відповідальності, сприяючи модульності та повторному використанню.
  • Принцип відкритості/закритості (Open-Closed Principle) дозволяє розширювати функціональність без зміни існуючого коду, використовуючи абстракції, такі як інтерфейси.
  • Принцип підстановки Барбари Лісков (Liskov Substitution Principle) забезпечує замінність, гарантуючи, що похідні класи зберігають поведінку базових класів.
  • Принцип розділення інтерфейсів (Interface Segregation Principle) закликає до створення менших, орієнтованих на клієнта інтерфейсів, щоб уникнути непотрібних залежностей.
  • Принцип інверсії залежностей (Dependency Inversion Principle) сприяє проектуванню систем, у яких як високорівневі, так і низькорівневі модулі залежать від абстракцій, а не від конкретних реалізацій.

Інтегруючи ці принципи у ваш процес розробки, кодова база стає більш гнучкою й легшою для адаптації до змін або нових вимог.

Оригінально опубліковано на https://sayyedulawwab.com/blog 28 січня 2025 року.

Перекладено з: A Practical Guide to SOLID Principles for Backend Developers

Leave a Reply

Your email address will not be published. Required fields are marked *