Уявіть проєкт, який почався як елегантне рішення певної задачі. Проходить час, додаються нові вимоги, фіксяться баги, приходять нові розробники. Код розростається, класи стають "всезнаючими монстрами", методи перетворюються на сотні рядків заплутаної логіки, а зміна в одному місці ламає функціональність у зовсім іншому. Це і є технічний борг (Technical Debt) — наслідок ігнорування принципів проектування.
Розвиток підходів до проектування програмного забезпечення пройшов довгий шлях:
Код організовувався як набір процедур та функцій. Дані та логіка існували окремо, що призводило до складності у підтриманні великих систем.
ООП об'єднало дані та поведінку в об'єкти. Це був революційний крок, але виникла нова проблема: як правильно проектувати класи та їх взаємодії?
Індустрія усвідомила необхідність формалізованих підходів. Robert C. Martin (також відомий як "Uncle Bob") сформулював SOLID принципи, які стали фундаментом сучасної розробки.
Принципи проектування інтегровані в практики Clean Architecture, Domain-Driven Design (DDD), та agile-методології.
Принципи проектування — це не абстрактні академічні концепції. Вони вирішують конкретні проблеми:
| Проблема | Рішення через принципи |
|---|---|
| Складність підтримки | Код стає модульним та зрозумілим |
| Висока зв'язаність (Coupling) | Класи стають незалежними один від одного |
| Низька згуртованість (Cohesion) | Кожен клас має чітку, єдину відповідальність |
| Ламка архітектура | Зміни в одному місці не впливають на інші |
| Складність тестування | Компоненти легко ізолюються для unit-тестів |
| Дублювання коду | Повторне використання через абстракції |
Перед вивченням цього матеріалу ви повинні бути знайомі з:
Software Design Principles (Принципи Проектування Програмного Забезпечення) — це набір фундаментальних правил та рекомендацій, які допомагають розробникам створювати код, що є:
Важливо розуміти різницю:
Принципи — це ЩО потрібно досягти (наприклад, "клас повинен мати одну відповідальність").
Шаблони — це ЯК це досягти (наприклад, "використай Strategy pattern для інкапсуляції алгоритмів").
Три стовпи якісної архітектури:
Maintainability
Супроводжуваність — можливість вносити зміни швидко та безпечно. Вимірюється:
Scalability
Масштабованість — можливість системи обробляти зростаюче навантаження або складність. Включає:
Testability
Тестованість — можливість ізольовано тестувати компоненти. Досягається через:
SOLID — це акронім п'яти базових принципів об'єктно-орієнтованого проектування:
| Літера | Принцип | Суть |
|---|---|---|
| S | Single Responsibility Principle | Клас має одну причину для зміни |
| O | Open/Closed Principle | Відкритий для розширення, закритий для модифікації |
| L | Liskov Substitution Principle | Підкласи замінюють базові класи без зламу логіки |
| I | Interface Segregation Principle | Клієнти не залежать від невикористовуваних методів |
| D | Dependency Inversion Principle | Залежність від абстракцій, а не конкретних реалізацій |
Single Responsibility Principle: Клас повинен мати одну, і тільки одну, причину для зміни.
— Robert C. Martin
Альтернативне формулювання: Клас повинен відповідати лише за одну частину функціональності системи, і ця відповідальність має бути повністю інкапсульована класом.
Що таке "причина для зміни"? Це зміна вимог до системи, яка примушує нас модифікувати клас.
Приклад: Якщо зміна формату логування вимагає модифікації класу UserService, це означає, що UserService має дві відповідальності: логіку користувача + логування.
Розглянемо класичний приклад класу, який порушує SRP:
public class UserService
{
// Відповідальність 1: Робота з базою даних
public void SaveUser(User user)
{
using var connection = new SqlConnection("connection_string");
connection.Open();
// SQL логіка збереження
}
// Відповідальність 2: Валідація
public bool ValidateUser(User user)
{
if (string.IsNullOrEmpty(user.Email)) return false;
if (!user.Email.Contains("@")) return false;
return true;
}
// Відповідальність 3: Відправка email
public void SendWelcomeEmail(User user)
{
var smtpClient = new SmtpClient("smtp.example.com");
// Логіка відправки email
}
// Відповідальність 4: Логування
public void LogUserAction(string action)
{
File.AppendAllText("log.txt", $"{DateTime.Now}: {action}\n");
}
}
Розділимо відповідальності на окремі класи:
/// <summary>
/// Відповідальність: Робота з базою даних користувачів
/// </summary>
public class UserRepository
{
private readonly string _connectionString;
public UserRepository(string connectionString)
{
_connectionString = connectionString;
}
public void Save(User user)
{
using var connection = new SqlConnection(_connectionString);
connection.Open();
using var command = new SqlCommand(
"INSERT INTO Users (Id, Email, Name) VALUES (@Id, @Email, @Name)",
connection
);
command.Parameters.AddWithValue("@Id", user.Id);
command.Parameters.AddWithValue("@Email", user.Email);
command.Parameters.AddWithValue("@Name", user.Name);
command.ExecuteNonQuery();
}
public User? GetById(Guid id)
{
// Логіка отримання користувача
return null; // Для прикладу
}
}
/// <summary>
/// Відповідальність: Валідація даних користувача
/// </summary>
public class UserValidator
{
public ValidationResult Validate(User user)
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(user.Email))
errors.Add("Email не може бути порожнім");
if (!user.Email.Contains("@"))
errors.Add("Email повинен містити символ '@'");
if (string.IsNullOrWhiteSpace(user.Name))
errors.Add("Ім'я не може бути порожнім");
return new ValidationResult
{
IsValid = errors.Count == 0,
Errors = errors
};
}
}
public record ValidationResult
{
public bool IsValid { get; init; }
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
}
/// <summary>
/// Відповідальність: Відправка email повідомлень
/// </summary>
public class EmailService
{
private readonly SmtpClient _smtpClient;
public EmailService(string smtpServer)
{
_smtpClient = new SmtpClient(smtpServer);
}
public void SendWelcomeEmail(User user)
{
var message = new MailMessage
{
To = { user.Email },
Subject = "Ласкаво просимо!",
Body = $"Привіт, {user.Name}! Дякуємо за реєстрацію."
};
_smtpClient.Send(message);
}
}
/// <summary>
/// Відповідальність: Логування подій системи
/// </summary>
public class Logger
{
private readonly string _logFilePath;
public Logger(string logFilePath)
{
_logFilePath = logFilePath;
}
public void Log(string message)
{
var logEntry = $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {message}";
File.AppendAllText(_logFilePath, logEntry + Environment.NewLine);
}
public void LogInfo(string message) => Log($"INFO: {message}");
public void LogWarning(string message) => Log($"WARNING: {message}");
public void LogError(string message) => Log($"ERROR: {message}");
}
/// <summary>
/// Відповідальність: Оркестрація бізнес-логіки користувача
/// Координує роботу інших сервісів
/// </summary>
public class UserService
{
private readonly UserRepository _repository;
private readonly UserValidator _validator;
private readonly EmailService _emailService;
private readonly Logger _logger;
public UserService(
UserRepository repository,
UserValidator validator,
EmailService emailService,
Logger logger)
{
_repository = repository;
_validator = validator;
_emailService = emailService;
_logger = logger;
}
public Result RegisterUser(User user)
{
// 1. Валідація
var validationResult = _validator.Validate(user);
if (!validationResult.IsValid)
{
_logger.LogWarning($"Помилка валідації користувача: {user.Email}");
return Result.Failure(validationResult.Errors);
}
// 2. Збереження
try
{
_repository.Save(user);
_logger.LogInfo($"Користувач збережений: {user.Email}");
}
catch (Exception ex)
{
_logger.LogError($"Помилка збереження: {ex.Message}");
return Result.Failure("Не вдалося зберегти користувача");
}
// 3. Відправка email
try
{
_emailService.SendWelcomeEmail(user);
_logger.LogInfo($"Вітальний email відправлений: {user.Email}");
}
catch (Exception ex)
{
_logger.LogWarning($"Не вдалося відправити email: {ex.Message}");
// Не фейлимо всю операцію через проблеми з email
}
return Result.Success();
}
}
public record Result
{
public bool IsSuccess { get; init; }
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
public static Result Success() => new() { IsSuccess = true };
public static Result Failure(params string[] errors) =>
new() { IsSuccess = false, Errors = errors };
public static Result Failure(IEnumerable<string> errors) =>
new() { IsSuccess = false, Errors = errors.ToList() };
}
| Критерій | До SRP | Після SRP |
|---|---|---|
| Розуміння коду | Складно: клас робить "все" | Легко: кожен клас має чітку мету |
| Тестування | Важко: потрібно мокати всі залежності одразу | Просто: тестуємо кожну відповідальність окремо |
| Зміни | Ризиковано: зміна в одному місці може зламати інше | Безпечно: зміни локалізовані |
| Повторне використання | Неможливо: логіка переплетена | Можливо: класи можна використовувати в інших контекстах |
- ✓ "UserRepository зберігає та отримує користувачів з бази даних"
- ✗ "UserService зберігає користувачів і валідує їх і відправляє email і логує події"
- ✓ "Logger змінюється, якщо змінюється формат або місце зберігання логів"
- ✗ "UserService змінюється, якщо змінюється БД, або валідація, або email"
Проблема: Створення окремого класу для кожного методу.
// Занадто дрібно!
public class UserEmailValidator { }
public class UserNameValidator { }
public class UserAgeValidator { }
Рішення: Групуйте пов'язану логіку. Валідація користувача — це одна відповідальність.
// Правильно
public class UserValidator
{
public ValidationResult ValidateEmail(string email) { }
public ValidationResult ValidateName(string name) { }
public ValidationResult ValidateAge(int age) { }
}
Проблема: Думка, що "клас може мати лише один метод".
SRP не про кількість методів, а про єдину причину для зміни. Клас може мати багато методів, якщо вони всі служать одній відповідальності.
// Правильно: всі методи служать одній меті — роботі з БД
public class UserRepository
{
public void Add(User user) { }
public void Update(User user) { }
public void Delete(Guid id) { }
public User? GetById(Guid id) { }
public IEnumerable<User> GetAll() { }
}
Проблема: Класи на кшталт Manager, Helper, Utility, які роблять "все".
// Анти-патерн
public class UserManager
{
public void ValidateUser() { }
public void SaveUser() { }
public void SendEmail() { }
public void GenerateReport() { }
public void ExportToJson() { }
}
Рішення: Розділіть на класи з чіткими назвами та відповідальностями.
Open/Closed Principle: Програмні сутності (класи, модулі, функції) повинні бути відкриті для розширення, але закриті для модифікації.
— Bertrand Meyer
Сучасна інтерпретація (Robert C. Martin): Ви повинні мати можливість додавати нову функціональність без зміни існуючого коду.
Дв компоненти цього принципу:
Ключ до дотримання OCP — програмування через інтерфейси та абстракції:
Розглянемо систему обробки платежів:
public class PaymentProcessor
{
public void ProcessPayment(Order order, string paymentMethod)
{
if (paymentMethod == "CreditCard")
{
Console.WriteLine("Обробка платежу кредитною карткою");
// Логіка для кредитної картки
}
else if (paymentMethod == "PayPal")
{
Console.WriteLine("Обробка платежу через PayPal");
// Логіка для PayPal
}
else if (paymentMethod == "Bitcoin")
{
Console.WriteLine("Обробка платежу Bitcoin");
// Логіка для Bitcoin
}
else
{
throw new NotSupportedException($"Метод оплати '{paymentMethod}' не підтримується");
}
}
}
PaymentProcessorСтворимо абстракцію через інтерфейс:
/// <summary>
/// Абстракція для методів оплати
/// </summary>
public interface IPaymentMethod
{
string Name { get; }
void ProcessPayment(Order order);
bool ValidatePayment(Order order);
}
/// <summary>
/// Конкретна реалізація: оплата кредитною карткою
/// </summary>
public class CreditCardPayment : IPaymentMethod
{
public string Name => "Кредитна картка";
public void ProcessPayment(Order order)
{
Console.WriteLine($"Обробка платежу на суму {order.TotalAmount:C} кредитною карткою");
// Специфічна логіка для кредитних карток
ConnectToPaymentGateway();
AuthorizeCard(order.Amount);
CapturePayment(order.Amount);
}
public bool ValidatePayment(Order order)
{
// Валідація номера картки, CVV, тощо
return true; // Спрощено для прикладу
}
private void ConnectToPaymentGateway() { /* ... */ }
private void AuthorizeCard(decimal amount) { /* ... */ }
private void CapturePayment(decimal amount) { /* ... */ }
}
/// <summary>
/// Конкретна реалізація: оплата через PayPal
/// </summary>
public class PayPalPayment : IPaymentMethod
{
public string Name => "PayPal";
public void ProcessPayment(Order order)
{
Console.WriteLine($"Обробка платежу на суму {order.TotalAmount:C} через PayPal");
// Специфічна логіка для PayPal
RedirectToPayPal(order);
HandlePayPalCallback();
}
public bool ValidatePayment(Order order)
{
// Валідація PayPal аккаунта
return true; // Спрощено для прикладу
}
private void RedirectToPayPal(Order order) { /* ... */ }
private void HandlePayPalCallback() { /* ... */ }
}
/// <summary>
/// Конкретна реалізація: оплата Bitcoin
/// </summary>
public class BitcoinPayment : IPaymentMethod
{
public string Name => "Bitcoin";
public void ProcessPayment(Order order)
{
Console.WriteLine($"Обробка платежу на суму {order.TotalAmount:C} через Bitcoin");
// Специфічна логіка для Bitcoin
GenerateWalletAddress();
WaitForConfirmations();
}
public bool ValidatePayment(Order order)
{
// Валідація Bitcoin транзакції
return true; // Спрощено для прикладу
}
private void GenerateWalletAddress() { /* ... */ }
private void WaitForConfirmations() { /* ... */ }
}
/// <summary>
/// Процесор платежів, закритий для модифікації
/// Нові методи додаються через створення нових класів
/// </summary>
public class PaymentProcessor
{
private readonly IPaymentMethod _paymentMethod;
public PaymentProcessor(IPaymentMethod paymentMethod)
{
_paymentMethod = paymentMethod;
}
public void ProcessOrder(Order order)
{
if (!_paymentMethod.ValidatePayment(order))
{
throw new InvalidOperationException(
$"Платіж через {_paymentMethod.Name} не пройшов валідацію"
);
}
_paymentMethod.ProcessPayment(order);
Console.WriteLine($"Замовлення #{order.Id} успішно оброблено через {_paymentMethod.Name}");
}
}
// Використання
var order = new Order
{
Id = Guid.NewGuid(),
TotalAmount = 100.50m
};
// Оплата кредитною карткою
var creditCardProcessor = new PaymentProcessor(new CreditCardPayment());
creditCardProcessor.ProcessOrder(order);
// Оплата через PayPal
var paypalProcessor = new PaymentProcessor(new PayPalPayment());
paypalProcessor.ProcessOrder(order);
// Додаємо Apple Pay БЕЗ зміни PaymentProcessor!
var applePayProcessor = new PaymentProcessor(new ApplePayPayment());
applePayProcessor.ProcessOrder(order);
Тепер додамо Apple Pay НЕ змінюючи PaymentProcessor:
/// <summary>
/// Нова реалізація додається БЕЗ модифікації існуючого коду
/// </summary>
public class ApplePayPayment : IPaymentMethod
{
public string Name => "Apple Pay";
public void ProcessPayment(Order order)
{
Console.WriteLine($"Обробка платежу на суму {order.TotalAmount:C} через Apple Pay");
// Специфічна логіка для Apple Pay
AuthenticateWithTouchID();
ProcessThroughAppleServers(order);
}
public bool ValidatePayment(Order order)
{
// Валідація Apple Pay токена
return true;
}
private void AuthenticateWithTouchID() { /* ... */ }
private void ProcessThroughAppleServers(Order order) { /* ... */ }
}
PaymentProcessor. Це і є суть OCP — розширення через додавання, а не модифікацію.Наш приклад з платежами — це реалізація Strategy Pattern (Шаблон Стратегія):
| Аспект | Без OCP | З OCP |
|---|---|---|
| Додавання функціональності | Зміна існуючого коду | Створення нового класу |
| Ризик регресії | Високий | Низький |
| Тестування | Перетестовування всього | Тестування лише нового класу |
| Командна робота | Конфлікти merge | Паралельна робота без конфліктів |
Симптом: Інтерфейс для кожного класу, навіть коли розширення не очікується.
Рішення: Не створюйте абстракції "на всякий випадок". Очікуйте конкретних вимог до розширення.
// Не потрібно, якщо ProductRepository ніколи не буде мати альтернативних реалізацій
public interface IProductRepository { }
public class ProductRepository : IProductRepository { }
Симптом: Інтерфейс містить деталі реалізації або специфічні для однієї реалізації методи.
// Погано: метод специфічний для SQL
public interface IRepository
{
void ExecuteSqlCommand(string sql); // ✗ Протікаюча абстракція
}
Рішення: Інтерфейс має бути незалежним від implementation details.
// Добре: загальний метод
public interface IRepository
{
void Save(Entity entity); // ✓ Чиста абстракція
}
Liskov Substitution Principle: Об'єкти підкласу повинні бути взаємозамінними з об'єктами базового класу без порушення коректності програми.
— Barbara Liskov
Формальне визначення: Якщо S є підтипом T, то об'єкти типу T можуть бути замінені об'єктами типу S без зміни бажаних властивостей програми (коректність, завдання, що виконується тощо).
LSP тісно пов'язаний з концепцією Design by Contract (Проектування за Контрактом), яка включає три види умов:
| Тип умови | Опис | LSP вимога |
|---|---|---|
| Preconditions (Передумови) | Умови, що мають бути істинними перед виконанням методу | Підклас не може посилювати передумови |
| Postconditions (Постумови) | Умови, що мають бути істинними після виконання методу | Підклас не може послаблювати постумови |
| Invariants (Інваріанти) | Умови, що мають залишатися істинними протягом існування об'єкта | Підклас має зберігати інваріанти базового класу |
Розглянемо класичну проблему Rectangle-Square, яка ілюструє порушення LSP:
/// <summary>
/// Базовий клас: прямокутник
/// Інваріант: Width та Height можуть бути незалежними
/// </summary>
public class Rectangle
{
public virtual int Width { get; set; }
public virtual int Height { get; set; }
public int CalculateArea()
{
return Width * Height;
}
}
/// <summary>
/// Квадрат — це прямокутник, де Width == Height. Звучить логічно, чи не так?
/// Але це ПОРУШУЄ LSP!
/// </summary>
public class Square : Rectangle
{
// Порушення: змінюємо інваріант базового класу
// У базовому класі Width і Height незалежні, тут — ні
public override int Width
{
get => base.Width;
set
{
base.Width = value;
base.Height = value; // Побічний ефект!
}
}
public override int Height
{
get => base.Height;
set
{
base.Width = value; // Побічний ефект!
base.Height = value;
}
}
}
/// <summary>
/// Клієнтський код, який працює з Rectangle
/// </summary>
public class GeometryService
{
public void TestRectangle(Rectangle rect)
{
rect.Width = 5;
rect.Height = 10;
// Очікуємо: 5 * 10 = 50
Console.WriteLine($"Площа: {rect.CalculateArea()}");
}
}
// Використання
var geometry = new GeometryService();
var rectangle = new Rectangle();
geometry.TestRectangle(rectangle); // Виведе: Площа: 50 ✓
var square = new Square();
geometry.TestRectangle(square); // Виведе: Площа: 100 ✗
// Замість очікуваних 50, отримуємо 100!
// LSP ПОРУШЕНО: Square не може замінити Rectangle
Square в метод, що очікує Rectangle, поведінка змінюється неочікуваним чином. Метод TestRectangle припускає, що встановлення Width та Height незалежні операції (інваріант Rectangle), але Square порушує цей інваріант.Це класичне порушення LSP: підклас змінює інваріанти базового класу.Правильніше створити спільну абстракцію:
/// <summary>
/// Абстракція для геометричних фігур
/// </summary>
public interface IShape
{
int CalculateArea();
string GetDescription();
}
/// <summary>
/// Прямокутник із незалежними сторонами
/// </summary>
public class Rectangle : IShape
{
public int Width { get; set; }
public int Height { get; set; }
public int CalculateArea() => Width * Height;
public string GetDescription() => $"Прямокутник {Width}x{Height}";
}
/// <summary>
/// Квадрат із однією стороною
/// НЕ успадковується від Rectangle!
/// </summary>
public class Square : IShape
{
public int Side { get; set; }
public int CalculateArea() => Side * Side;
public string GetDescription() => $"Квадрат {Side}x{Side}";
}
/// <summary>
/// Клієнтський код тепер працює через абстракцію
/// </summary>
public class GeometryService
{
public void ProcessShape(IShape shape)
{
Console.WriteLine(shape.GetDescription());
Console.WriteLine($"Площа: {shape.CalculateArea()}");
}
}
// Використання
var geometry = new GeometryService();
var rect = new Rectangle { Width = 5, Height = 10 };
geometry.ProcessShape(rect); // Прямокутник 5x10, Площа: 50
var square = new Square { Side = 10 };
geometry.ProcessShape(square); // Квадрат 10x10, Площа: 100
// LSP ДОТРИМАНО: обидва типи коректно реалізують IShape
Інший поширений приклад порушення LSP:
public abstract class Bird
{
public abstract void Fly();
}
public class Sparrow : Bird
{
public override void Fly()
{
Console.WriteLine("Горобець летить");
}
}
public class Penguin : Bird
{
public override void Fly()
{
// Пінгвіни не літають!
throw new NotImplementedException("Пінгвіни не вміють літати");
}
}
// Клієнтський код
public void MakeBirdFly(Bird bird)
{
bird.Fly(); // Якщо передати Penguin — Exception!
}
NotImplementedException в перевизначеному методі — це явна ознака порушення LSP. Якщо підклас не може виконати контракт базового класу, він не повинен від нього успадковуватися.Правильне рішення:
public abstract class Bird
{
public abstract void Move();
}
public interface IFlyable
{
void Fly();
}
public class Sparrow : Bird, IFlyable
{
public override void Move()
{
Fly();
}
public void Fly()
{
Console.WriteLine("Горобець летить");
}
}
public class Penguin : Bird
{
public override void Move()
{
Swim();
}
private void Swim()
{
Console.WriteLine("Пінгвін пливе");
}
}
// Клієнтський код
public void MoveBird(Bird bird)
{
bird.Move(); // Працює для всіх Bird
}
public void MakeFly(IFlyable flyable)
{
flyable.Fly(); // Працює лише для птахів, що літають
}
Помилка: Думка, що "Square IS-A Rectangle" (математично) означає успадкування в коді.
Рішення: У програмуванні важлива поведінкова сумісність, а не математична класифікація. "Square BEHAVES-LIKE-A Rectangle" лише якщо він може замінити Rectangle у всіх контекстах без порушення логіки.
Питання для перевірки:
Симптом: Успадкування від класу лише для отримання його методів, хоча насправді не IS-A відношення.
Рішення: Використовуйте композицію (Composition over Inheritance). Якщо вам потрібна функціональність без IS-A відношення, створіть поле з потрібним класом замість успадкування.
// Погано: успадкування для повторного використання
public class Stack<T> : List<T>
{
public void Push(T item) => Add(item);
public T Pop() { /* ... */ }
}
// Проблема: Stack тепер має методи Insert, Remove, які не мають сенсу для стека
// Добре: композиція
public class Stack<T>
{
private readonly List<T> _items = new();
public void Push(T item) => _items.Add(item);
public T Pop() { /* ... */ }
// Лише методи, що мають сенс для стека
}
Interface Segregation Principle: Клієнти не повинні залежати від інтерфейсів, які вони не використовують.
— Robert C. Martin
Інша формулювка: Краще мати багато спеціалізованих інтерфейсів, ніж один універсальний (Fat Interface).
| Fat Interface | Role Interface |
|---|---|
| Один інтерфейс з багатьма методами | Кілька маленьких, фокусованих інтерфейсів |
| Клієнти імплементують непотрібні методи | Клієнти імплементують лише потрібне |
| Висока зв'язаність | Низька зв'язаність |
| Складно тестувати | Легко мокати окремі ролі |
/// <summary>
/// Fat Interface: занадто багато відповідальностей
/// </summary>
public interface IWorker
{
void Work();
void Eat();
void Sleep();
void TakeSalary();
void FileTimeSheet();
}
/// <summary>
/// Робот має імплементувати методи, які не мають сенсу
/// </summary>
public class Robot : IWorker
{
public void Work()
{
Console.WriteLine("Робот працює");
}
public void Eat()
{
// Роботи не їдять!
throw new NotSupportedException();
}
public void Sleep()
{
// Роботи не сплять!
throw new NotSupportedException();
}
public void TakeSalary()
{
// Роботи не отримують зарплату!
throw new NotSupportedException();
}
public void FileTimeSheet()
{
Console.WriteLine("Робот реєструє робочий час");
}
}
public class Human : IWorker
{
// Людина має імплементувати ВСІ методи
public void Work() { /* ... */ }
public void Eat() { /* ... */ }
public void Sleep() { /* ... */ }
public void TakeSalary() { /* ... */ }
public void FileTimeSheet() { /* ... */ }
}
Robot змушений імплементувати методи Eat(), Sleep(), TakeSalary(), які не мають сенсуNotSupportedException — порушення LSPIWorker, потрібно реалізувати всі методи/// <summary>
/// Role Interface: здатність працювати
/// </summary>
public interface IWorkable
{
void Work();
}
/// <summary>
/// Role Interface: потреба в їжі
/// </summary>
public interface IFeedable
{
void Eat();
}
/// <summary>
/// Role Interface: потреба в сні
/// </summary>
public interface ISleepable
{
void Sleep();
}
/// <summary>
/// Role Interface: отримання оплати
/// </summary>
public interface IPayable
{
void TakeSalary();
}
/// <summary>
/// Role Interface: облік робочого часу
/// </summary>
public interface ITimeTrackable
{
void FileTimeSheet();
}
/// <summary>
/// Робот імплементує лише те, що йому потрібно
/// </summary>
public class Robot : IWorkable, ITimeTrackable
{
public void Work()
{
Console.WriteLine("Робот працює 24/7");
}
public void FileTimeSheet()
{
Console.WriteLine("Робот реєструє робочий час");
}
}
/// <summary>
/// Людина імплементує всі потрібні інтерфейси
/// </summary>
public class Human : IWorkable, IFeedable, ISleepable, IPayable, ITimeTrackable
{
public void Work()
{
Console.WriteLine("Людина працює");
}
public void Eat()
{
Console.WriteLine("Людина обідає");
}
public void Sleep()
{
Console.WriteLine("Людина спить");
}
public void TakeSalary()
{
Console.WriteLine("Людина отримує зарплату");
}
public void FileTimeSheet()
{
Console.WriteLine("Людина заповнює timesheet");
}
}
/// <summary>
/// Менеджер працює з різними аспектами через окремі інтерфейси
/// </summary>
public class WorkManager
{
public void ManageWork(IWorkable worker)
{
worker.Work();
}
public void ProvideLunch(IFeedable worker)
{
worker.Eat();
}
public void ProcessPayroll(IPayable employee)
{
employee.TakeSalary();
}
public void CollectTimesheets(ITimeTrackable worker)
{
worker.FileTimeSheet();
}
}
| Аспект | Fat Interface | Segregated Interfaces |
|---|---|---|
| Гнучкість | Класи змушені імплементувати все | Класи вибирають потрібне |
| Тестування | Важко мокати великий інтерфейс | Легко мокати маленькі інтерфейси |
| Зміни | Зміна впливає на всіх | Зміни локалізовані |
| Читабельність | Незрозуміло, що саме потрібно | Чітко видно залежності |
ISP особливо важливий при використанні DI:
public class OrderProcessor
{
// Залежить лише від того, що йому потрібно
private readonly IEmailSender _emailSender;
private readonly IOrderRepository _orderRepository;
private readonly IPaymentGateway _paymentGateway;
public OrderProcessor(
IEmailSender emailSender,
IOrderRepository orderRepository,
IPaymentGateway paymentGateway)
{
_emailSender = emailSender;
_orderRepository = orderRepository;
_paymentGateway = paymentGateway;
}
public void ProcessOrder(Order order)
{
_paymentGateway.Charge(order.Amount);
_orderRepository.Save(order);
_emailSender.SendConfirmation(order.CustomerEmail);
}
}
Симптом: Кожен метод в своєму інтерфейсі, безладдя з інтерфейсами.
Рішення: Шукайте баланс. Групуйте методи, що логічно належать разом. ISP не означає "один метод = один інтерфейс".
// Занадто дрібно
public interface INameGetter { string GetName(); }
public interface INameSetter { void SetName(string name); }
// Правильно: пов'язані операції разом
public interface INamed
{
string Name { get; set; }
}
Питання: Що робити, якщо клієнту потрібно кілька інтерфейсів одночасно?
Рішення: Використовуйте множинну реалізацію або створіть composite interface:
// Варіант 1: Множинна реалізація
public void ProcessWorker(IWorkable workable, IPayable payable)
{
workable.Work();
payable.TakeSalary();
}
// Варіант 2: Composite interface (тільки якщо часто використовується разом)
public interface IEmployee : IWorkable, IPayable, ITimeTrackable
{
// Не додаємо нові методи, просто композиція
}
Dependency Inversion Principle:
- Високорівневі модулі не повинні залежати від низькорівневих модулів. Обидва мають залежати від абстракцій.
- Абстракції не повинні залежати від деталей. Деталі мають залежати від абстракцій.
— Robert C. Martin
| High-Level Modules | Low-Level Modules |
|---|---|
| Бізнес-логіка | Інфраструктурні деталі |
| Оркестрація | Конкретні реалізації |
| Політики та правила | Технічні механізми |
| Приклад: OrderService | Приклад: SqlOrderRepository |
| Приклад: PaymentProcessor | Приклад: StripePaymentGateway |
/// <summary>
/// High-level модуль залежить від конкретної реалізації
/// </summary>
public class OrderService
{
private readonly SqlDatabase _database; // Пряма залежність!
public OrderService()
{
_database = new SqlDatabase(); // Створення залежності всередині!
}
public void ProcessOrder(Order order)
{
// Бізнес-логіка
order.Status = OrderStatus.Confirmed;
// Збереження через конкретну реалізацію
_database.ExecuteQuery($"INSERT INTO Orders VALUES ('{order.Id}', ...)");
}
}
public class SqlDatabase
{
public void ExecuteQuery(string sql)
{
// SQL логіка
}
}
OrderService знає про SqlDatabaseOrderServiceOrderService/// <summary>
/// Абстракція, визначена HIGH-LEVEL модулем
/// </summary>
public interface IOrderRepository
{
void Save(Order order);
Order? GetById(Guid id);
IEnumerable<Order> GetAll();
}
/// <summary>
/// High-level модуль залежить від абстракції
/// </summary>
public class OrderService
{
private readonly IOrderRepository _repository;
// Залежність ін'єктується ззовні (Dependency Injection)
public OrderService(IOrderRepository repository)
{
_repository = repository;
}
public void ProcessOrder(Order order)
{
// Бізнес-логіка
order.Status = OrderStatus.Confirmed;
// Збереження через абстракцію
_repository.Save(order);
}
}
/// <summary>
/// Low-level модуль реалізує абстракцію
/// </summary>
public class SqlOrderRepository : IOrderRepository
{
private readonly string _connectionString;
public SqlOrderRepository(string connectionString)
{
_connectionString = connectionString;
}
public void Save(Order order)
{
using var connection = new SqlConnection(_connectionString);
connection.Open();
var command = new SqlCommand(
"INSERT INTO Orders (Id, Status, Amount) VALUES (@Id, @Status, @Amount)",
connection
);
command.Parameters.AddWithValue("@Id", order.Id);
command.Parameters.AddWithValue("@Status", order.Status);
command.Parameters.AddWithValue("@Amount", order.Amount);
command.ExecuteNonQuery();
}
public Order? GetById(Guid id)
{
// Реалізація SQL запиту
return null; // Спрощено
}
public IEnumerable<Order> GetAll()
{
// Реалізація SQL запиту
return new List<Order>(); // Спрощено
}
}
/// <summary>
/// Альтернативна реалізація — легко додати!
/// OrderService НЕ потребує змін
/// </summary>
public class MongoOrderRepository : IOrderRepository
{
private readonly IMongoCollection<Order> _collection;
public MongoOrderRepository(IMongoDatabase database)
{
_collection = database.GetCollection<Order>("orders");
}
public void Save(Order order)
{
_collection.InsertOne(order);
}
public Order? GetById(Guid id)
{
return _collection.Find(o => o.Id == id).FirstOrDefault();
}
public IEnumerable<Order> GetAll()
{
return _collection.Find(_ => true).ToList();
}
}
/// <summary>
/// Реалізація для тестування
/// </summary>
public class InMemoryOrderRepository : IOrderRepository
{
private readonly List<Order> _orders = new();
public void Save(Order order)
{
_orders.Add(order);
}
public Order? GetById(Guid id)
{
return _orders.FirstOrDefault(o => o.Id == id);
}
public IEnumerable<Order> GetAll()
{
return _orders;
}
}
// Production: SQL
var sqlRepository = new SqlOrderRepository("connection_string");
var orderService = new OrderService(sqlRepository);
orderService.ProcessOrder(new Order { /* ... */ });
// Production: MongoDB
var mongoRepository = new MongoOrderRepository(mongoDatabase);
var orderService2 = new OrderService(mongoRepository);
orderService2.ProcessOrder(new Order { /* ... */ });
// Testing
var testRepository = new InMemoryOrderRepository();
var orderService3 = new OrderService(testRepository);
// Легко тестувати без реальної БД!
DIP реалізується через Dependency Injection (DI). Існує три основних патерни ін'єкції:
Найпоширеніший та рекомендований спосіб.
public class OrderService
{
private readonly IOrderRepository _repository;
private readonly IEmailService _emailService;
private readonly ILogger _logger;
// Залежності передаються через конструктор
public OrderService(
IOrderRepository repository,
IEmailService emailService,
ILogger logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_emailService = emailService ?? throw new ArgumentNullException(nameof(emailService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public void ProcessOrder(Order order)
{
_repository.Save(order);
_emailService.Send(order);
_logger.Log($"Order {order.Id} processed");
}
}
Переваги:
readonlyНедоліки:
Використовується для опціональних залежностей.
public class OrderService
{
private readonly IOrderRepository _repository;
// Обов'язкова залежність через конструктор
public OrderService(IOrderRepository repository)
{
_repository = repository;
}
// Опціональна залежність через property
public ILogger? Logger { get; set; }
public void ProcessOrder(Order order)
{
_repository.Save(order);
// Використання опціональної залежності
Logger?.Log($"Order {order.Id} processed");
}
}
// Usage
var service = new OrderService(repository);
service.Logger = new ConsoleLogger(); // Опціонально
Переваги:
Недоліки:
NullReferenceExceptionЗалежність передається безпосередньо в метод.
public class OrderService
{
private readonly IOrderRepository _repository;
public OrderService(IOrderRepository repository)
{
_repository = repository;
}
// Залежність передається в метод
public void ProcessOrder(Order order, IEmailService emailService)
{
_repository.Save(order);
emailService.SendConfirmation(order);
}
}
// Usage
var service = new OrderService(repository);
service.ProcessOrder(order, new SmtpEmailService()); // Для цього замовлення email через SMTP
service.ProcessOrder(anotherOrder, new SendGridEmailService()); // Для іншого через SendGrid
Переваги:
Недоліки:
Inversion of Control (IoC) Container — це framework, що автоматизує створення об'єктів та ін'єкцію залежностей.
// Реєстрація залежностей (Composition Root)
var services = new ServiceCollection();
// Реєстрація інтерфейсів та реалізацій
services.AddScoped<IOrderRepository, SqlOrderRepository>();
services.AddScoped<IEmailService, SmtpEmailService>();
services.AddScoped<ILogger, FileLogger>();
services.AddScoped<OrderService>();
// Побудова контейнера
var serviceProvider = services.BuildServiceProvider();
// Контейнер автоматично створює OrderService з усіма залежностями
var orderService = serviceProvider.GetRequiredService<OrderService>();
orderService.ProcessOrder(order);
Lifetimes (життєві цикли):
| Lifetime | Опис | Використання |
|---|---|---|
| Transient | Новий екземпляр при кожному запиті | Stateless сервіси, легкі об'єкти |
| Scoped | Один екземпляр на scope (HTTP request в ASP.NET) | Repository, DbContext |
| Singleton | Один екземпляр на весь час роботи додатку | Configuration, Logger, Cache |
services.AddTransient<IEmailService, SmtpEmailService>(); // Новий щоразу
services.AddScoped<IOrderRepository, SqlOrderRepository>(); // Один на request
services.AddSingleton<ILogger, FileLogger>(); // Один на application
new: У бізнес-логіці не створюйте залежності через new, ін'єктуйте їхСимптом: Використання static класу або singleton для "доставання" залежностей.
// АНТИ-ПАТЕРН: Service Locator
public class OrderService
{
public void ProcessOrder(Order order)
{
var repository = ServiceLocator.Get<IOrderRepository>(); // Погано!
repository.Save(order);
}
}
Проблема:
Рішення: Використовуйте Constructor Injection.
// ПРАВИЛЬНО
public class OrderService
{
private readonly IOrderRepository _repository;
public OrderService(IOrderRepository repository) // Явна залежність
{
_repository = repository;
}
public void ProcessOrder(Order order)
{
_repository.Save(order);
}
}
Симптом: Абстракція містить деталі конкретної реалізації.
// Погано: SQL деталі в інтерфейсі
public interface IOrderRepository
{
void ExecuteSqlQuery(string sql); // SQL-специфічний метод
DataTable GetOrdersAsDataTable(); // ADO.NET специфічний тип
}
Рішення: Абстракція має бути implementation-agnostic.
// Добре: незалежна абстракція
public interface IOrderRepository
{
void Save(Order order);
Order? GetById(Guid id);
IEnumerable<Order> GetAll();
}
DRY Principle: Кожен елемент знання має мати єдине, недвозначне, авторитетне представлення всередині системи.
— Andy Hunt and Dave Thomas, "The Pragmatic Programmer"
Проста формулювка: Не дублюйте код та знання. Якщо ви копіюєте код — ви робите щось неправильно.
Важливо розрізняти:
| Тип | Опис | Приклад |
|---|---|---|
| Дублювання коду | Ідентичний або схожий код в різних місцях | Copy-paste одного і того ж методу |
| Дублювання знань | Різний код, що представляє одне знання | Логіка валідації email в UI та на backend |
| Випадковий збіг | Код виглядає схоже, але представляє різні концепції | Два методи Calculate() в різних контекстах |
public class UserService
{
public void CreateUser(string email, string password)
{
// Валідація email (копія #1)
if (string.IsNullOrEmpty(email))
throw new ArgumentException("Email не може бути порожнім");
if (!email.Contains("@"))
throw new ArgumentException("Email має містити @");
// Створення користувача
}
public void UpdateUserEmail(Guid userId, string newEmail)
{
// Валідація email (копія #2)
if (string.IsNullOrEmpty(newEmail))
throw new ArgumentException("Email не може бути порожнім");
if (!newEmail.Contains("@"))
throw new ArgumentException("Email має містити @");
// Оновлення email
}
public void SendInvitation(string email)
{
// Валідація email (копія #3)
if (string.IsNullOrEmpty(email))
throw new ArgumentException("Email не може бути порожнім");
if (!email.Contains("@"))
throw new ArgumentException("Email має містити @");
// Відправка запрошення
}
}
/// <summary>
/// Єдине джерело знань про валідацію email
/// </summary>
public static class EmailValidator
{
public static void Validate(string email)
{
if (string.IsNullOrEmpty(email))
throw new ArgumentException("Email не може бути порожнім", nameof(email));
if (!email.Contains("@"))
throw new ArgumentException("Email має містити @", nameof(email));
// Додаткова валідація через regex
var emailRegex = new Regex(@"^[^@\s]+@[^@\s]+\.[^@\s]+$");
if (!emailRegex.IsMatch(email))
throw new ArgumentException("Некоректний формат email", nameof(email));
}
public static bool IsValid(string email)
{
try
{
Validate(email);
return true;
}
catch
{
return false;
}
}
}
/// <summary>
/// Використання єдиного джерела валідації
/// </summary>
public class UserService
{
public void CreateUser(string email, string password)
{
EmailValidator.Validate(email); // Використовуємо загальний метод
// Створення користувача
}
public void UpdateUserEmail(Guid userId, string newEmail)
{
EmailValidator.Validate(newEmail); // Використовуємо загальний метод
// Оновлення email
}
public void SendInvitation(string email)
{
EmailValidator.Validate(email); // Використовуємо загальний метод
// Відправка запрошення
}
}
Дублювання в конфігураційних файлах теж порушує DRY:
{
"EmailSettings": {
"SmtpServer": "smtp.gmail.com",
"SmtpPort": 587,
"EnableSsl": true
},
"NotificationSettings": {
"SmtpServer": "smtp.gmail.com",
"SmtpPort": 587,
"EnableSsl": true
},
"ReportSettings": {
"SmtpServer": "smtp.gmail.com",
"SmtpPort": 587,
"EnableSsl": true
}
}
{
"SmtpSettings": {
"Server": "smtp.gmail.com",
"Port": 587,
"EnableSsl": true
},
"EmailSettings": {
"SmtpSettingsReference": "SmtpSettings"
},
"NotificationSettings": {
"SmtpSettingsReference": "SmtpSettings"
},
"ReportSettings": {
"SmtpSettingsReference": "SmtpSettings"
}
}
KISS Principle: Більшість систем працюють найкраще, якщо вони залишаються простими, а не ускладненими.
Альтернативна формулювка: Простота має бути ключовою метою в дизайні, а непотрібної складності слід уникати.
| Simple (Просте) | Easy (Легке) |
|---|---|
| Одна відповідальність | Швидко написати |
| Зрозуміла структура | Не вимагає думки |
| Мінімум залежностей | Copy-paste рішення |
| Приклад: Окремі маленькі класи | Приклад: Один великий клас з усім |
/// <summary>
/// Надмірно складна система для простого завдання
/// </summary>
public interface ICalculationStrategy
{
decimal Execute(decimal a, decimal b);
}
public class AdditionStrategy : ICalculationStrategy
{
public decimal Execute(decimal a, decimal b) => a + b;
}
public class CalculationContext
{
private readonly ICalculationStrategy _strategy;
public CalculationContext(ICalculationStrategy strategy)
{
_strategy = strategy;
}
public decimal Calculate(decimal a, decimal b)
{
return _strategy.Execute(a, b);
}
}
public class CalculationFactory
{
public ICalculationStrategy CreateStrategy(string operation)
{
return operation switch
{
"add" => new AdditionStrategy(),
_ => throw new NotSupportedException()
};
}
}
// Використання для простого додавання!
var factory = new CalculationFactory();
var strategy = factory.CreateStrategy("add");
var context = new CalculationContext(strategy);
var result = context.Calculate(5, 3); // Просто додати 5 + 3!
/// <summary>
/// Просте рішення для простого завдання
/// </summary>
public static class Calculator
{
public static decimal Add(decimal a, decimal b) => a + b;
public static decimal Subtract(decimal a, decimal b) => a - b;
public static decimal Multiply(decimal a, decimal b) => a * b;
public static decimal Divide(decimal a, decimal b) =>
b != 0 ? a / b : throw new DivideByZeroException();
}
// Використання
var result = Calculator.Add(5, 3); // Просто та зрозуміло
Частина KISS — це Principle of Least Surprise: код має поводитися так, як очікує розробник.
// Несподівана поведінка
public class User
{
public string Name { get; set; }
// Несподівано! Getter робить I/O операцію
public string Email => LoadEmailFromDatabase();
private string LoadEmailFromDatabase()
{
// Запит до БД кожен раз!
return "user@example.com";
}
}
// Використання
var user = new User();
var email1 = user.Email; // Запит до БД
var email2 = user.Email; // Ще один запит до БД
// Несподівано: просте читання property робить два запити!
// Очікувана поведінка
public class User
{
public string Name { get; set; }
public string Email { get; set; } // Звичайна property
// Метод явно вказує, що робить I/O
public static User LoadFromDatabase(Guid id)
{
// Запит до БД
return new User
{
Name = "John",
Email = "user@example.com"
};
}
}
// Використання
var user = User.LoadFromDatabase(userId); // Очевидно, що робимо запит
var email1 = user.Email; // Просто читання поля
var email2 = user.Email; // Просто читання поля
// Очікувано: property не робить складних операцій
// Погано: змішані рівні абстракції
public void ProcessOrder(Order order)
{
// Високий рівень
ValidateOrder(order);
// Низький рівень (деталі SQL)
using var connection = new SqlConnection(_connectionString);
connection.Open();
var command = new SqlCommand("INSERT INTO Orders...", connection);
// ...
// Високий рівень
SendConfirmation(order);
}
// Добре: один рівень абстракції
public void ProcessOrder(Order order)
{
ValidateOrder(order);
SaveOrder(order);
SendConfirmation(order);
}
private void SaveOrder(Order order)
{
// Деталі SQL інкапсульовані
_repository.Save(order);
}
YAGNI Principle: Завжди імплементуйте речі, коли вони дійсно потрібні, а не коли ви просто передбачаєте, що вони можуть знадобитися.
— Extreme Programming
Проста формулювка: Не пишіть код для функціональності, яка може знадобитися в майбутньому. Пишіть лише те, що потрібно зараз.
Speculative Generality (Спекулятивна Загальність) — створення абстракцій та гнучкості для можливих майбутніх потреб.
/// <summary>
/// Зараз підтримується тільки SQL, але "може в майбутньому" потрібна буде MongoDB
/// Створюємо абстракцію "на всякий випадок"
/// </summary>
public interface IRepository<T>
{
void Save(T entity);
T GetById(object id);
IEnumerable<T> GetAll();
void Delete(object id);
void Update(T entity);
}
public interface IUnitOfWork
{
void BeginTransaction();
void Commit();
void Rollback();
}
public class GenericRepository<T> : IRepository<T>
{
// Складна generic реалізація
// ...100+ рядків коду
}
// Використовується тільки для User!
public class UserService
{
private readonly IRepository<User> _repository;
public UserService(IRepository<User> repository)
{
_repository = repository;
}
// Використовується лише Save та GetById
}
/// <summary>
/// Версія 1: Просто збереження користувачів (що потрібно ЗАРАЗ)
/// </summary>
public class UserRepository
{
private readonly string _connectionString;
public UserRepository(string connectionString)
{
_connectionString = connectionString;
}
public void SaveUser(User user)
{
using var connection = new SqlConnection(_connectionString);
connection.Open();
// SQL логіка
}
public User? GetUserById(Guid id)
{
// SQL логіка
return null;
}
}
// Якщо ПІЗНІШЕ дійсно знадобиться MongoDB:
// 1. Створимо інтерфейс IUserRepository
// 2. UserRepository : IUserRepository
// 3. MongoUserRepository : IUserRepository
// Але ЛИШЕ коли це дійсно потрібно, а не "на всякий випадок"
| YAGNI (Робити) | NOT YAGNI (Не робити) |
|---|---|
| Імплементувати поточні вимоги | Додавати "корисні" features "на всякий випадок" |
| Писати тести для поточної функціональності | Писати тести для неіснуючих кейсів |
| Проектувати для поточних потреб | Створювати generic frameworks |
| Рефакторити при появі нових вимог | Робити "гнучкість" заздалегідь |
Law of Demeter: Об'єкт має спілкуватися лише зі своїми "безпосередніми друзями" і не повинен знати про внутрішню структуру об'єктів, які йому повертаються.
Альтернативна назва: Principle of Least Knowledge (Принцип Найменшого Знання)
Метод об'єкта O може викликати методи лише таких об'єктів:
this)Методи самого об'єкта завжди доступні.
public void DoSomething()
{
this.HelperMethod(); // ✓ Дозволено
}
Об'єкти, передані як параметри в метод.
public void ProcessOrder(Order order)
{
order.Confirm(); // ✓ Дозволено
}
Локальні змінні, створені в методі.
public void CreateUser()
{
var user = new User();
user.SetName("John"); // ✓ Дозволено
}
Безпосередні залежності об'єкта.
private readonly ILogger _logger;
public void Log(string message)
{
_logger.Log(message); // ✓ Дозволено
}
Симптом порушення LoD: багато крапок в ланцюжку викликів.
/// <summary>
/// Порушення Law of Demeter: "знає занадто багато"
/// </summary>
public class OrderProcessor
{
public void ProcessOrder(Customer customer)
{
// "Dot counting" - багато крапок!
var street = customer.GetAddress().GetStreet().GetName();
// Ще гірше: ми знаємо внутрішню структуру Address і Street
// Якщо структура зміниться, OrderProcessor зламається
var discount = customer.GetMembershipLevel().GetBenefits().GetDiscount();
}
}
OrderProcessor залежить від внутрішньої структури Customer, Address, StreetAddress або Street може зламати OrderProcessorOrderProcessor знає занадто багато про внутрішні деталі/// <summary>
/// Customer приховує внутрішню структуру та надає методи для доступу
/// </summary>
public class Customer
{
private readonly Address _address;
private readonly MembershipLevel _membershipLevel;
public Customer(Address address, MembershipLevel membershipLevel)
{
_address = address;
_membershipLevel = membershipLevel;
}
// Tell, Don't Ask: замість геттерів надаємо поведінку
public string GetStreetName()
{
return _address.GetStreetName(); // Делегування
}
public decimal GetMembershipDiscount()
{
return _membershipLevel.GetDiscount(); // Делегування
}
}
public class Address
{
private readonly Street _street;
public Address(Street street)
{
_street = street;
}
public string GetStreetName()
{
return _street.Name; // Інкапсуляція деталей
}
}
/// <summary>
/// OrderProcessor тепер говорить лише з Customer
/// </summary>
public class OrderProcessor
{
public void ProcessOrder(Customer customer)
{
// Говоримо з "другом" (customer), а не з "друзями друзів"
var street = customer.GetStreetName();
var discount = customer.GetMembershipDiscount();
// OrderProcessor не знає про Address, Street, MembershipLevel, Benefits
}
}
Tell-Don't-Ask — це пов'язаний принцип, який каже: замість того, щоб запитувати об'єкт про його стан і приймати рішення, скажіть об'єкту, що робити.
// "Ask" - Запитуємо та приймаємо рішення ззовні
public class OrderService
{
public void ProcessOrder(Order order)
{
// Запитуємо стан
if (order.Status == OrderStatus.Pending)
{
if (order.TotalAmount > 0)
{
// Приймаємо рішення ззовні
order.Status = OrderStatus.Confirmed;
SaveOrder(order);
}
}
}
}
// "Tell" - Говоримо об'єкту, що робити
public class OrderService
{
public void ProcessOrder(Order order)
{
// Говоримо, що робити, не запитуючи деталі
order.Confirm();
SaveOrder(order);
}
}
// Логіка інкапсульована всередині Order
public class Order
{
public OrderStatus Status { get; private set; }
public decimal TotalAmount { get; private set; }
public void Confirm()
{
if (Status != OrderStatus.Pending)
throw new InvalidOperationException("Лише pending orders можна підтверджувати");
if (TotalAmount <= 0)
throw new InvalidOperationException("Сума замовлення має бути більше 0");
Status = OrderStatus.Confirmed;
}
}
a.b().c().d() — це ознака проблеми// OK: це один об'єкт (builder)
var query = queryBuilder
.Where(x => x.Age > 18)
.OrderBy(x => x.Name)
.Take(10);
Питання: Чи можна використовувати order.Customer.Address.City?
Відповідь: Якщо це структура даних (DTO, анемічна модель), а не об'єкт з поведінкою, то LoD менш важливий. Структури даних створені для доступу до даних.
// DTO - структура даних, не об'єкт
public class OrderDTO
{
public CustomerDTO Customer { get; set; }
}
public class CustomerDTO
{
public AddressDTO Address { get; set; }
}
// OK для DTO
var city = orderDTO.Customer.Address.City;
Але для об'єктів з поведінкою (domain models) — дотримуйтесь LoD!
Перевірте своє розуміння принципів проектування на трьох рівнях складності.
Мета: Навчитись розпізнавати порушення принципів у коді.
Проаналізуйте наступний код та визначте, які SOLID принципи порушено:
public class UserManager
{
public void CreateUser(string name, string email)
{
// Валідація
if (string.IsNullOrEmpty(email) || !email.Contains("@"))
throw new ArgumentException("Invalid email");
// Збереження в БД
using var connection = new SqlConnection("connection_string");
connection.Open();
var command = new SqlCommand($"INSERT INTO Users VALUES ('{name}', '{email}')", connection);
command.ExecuteNonQuery();
// Відправка email
var smtpClient = new SmtpClient("smtp.gmail.com");
smtpClient.Send("admin@example.com", email, "Welcome", "Welcome message");
// Логування
File.AppendAllText("log.txt", $"{DateTime.Now}: User {name} created\n");
}
}
Питання:
Порушені принципи:
Причини для зміни: 4
Рефакторинг: Розділити на UserValidator, UserRepository, EmailService, Logger, UserService (оркестратор)
Знайдіть дублювання в наступному коді:
public class ReportService
{
public string GeneratePdfReport(Order order)
{
var content = $"Order #{order.Id}\n";
content += $"Date: {order.Date:yyyy-MM-dd}\n";
content += $"Customer: {order.CustomerName}\n";
content += $"Total: ${order.Total:F2}\n";
return ConvertToPdf(content);
}
public string GenerateEmailReport(Order order)
{
var content = $"Order #{order.Id}\n";
content += $"Date: {order.Date:yyyy-MM-dd}\n";
content += $"Customer: {order.CustomerName}\n";
content += $"Total: ${order.Total:F2}\n";
return FormatForEmail(content);
}
}
Дублювання: Формування змісту звіту повторюється в обох методах.
Рішення:
public class ReportService
{
private string GenerateOrderContent(Order order)
{
var content = $"Order #{order.Id}\n";
content += $"Date: {order.Date:yyyy-MM-dd}\n";
content += $"Customer: {order.CustomerName}\n";
content += $"Total: ${order.Total:F2}\n";
return content;
}
public string GeneratePdfReport(Order order)
{
return ConvertToPdf(GenerateOrderContent(order));
}
public string GenerateEmailReport(Order order)
{
return FormatForEmail(GenerateOrderContent(order));
}
}
Мета: Практикувати рефакторинг коду для дотримання принципів.
Рефакторте наступний код для дотримання SOLID, DRY, та KISS:
public class PaymentProcessor
{
public void ProcessPayment(string paymentType, decimal amount, string cardNumber)
{
if (paymentType == "CreditCard")
{
if (cardNumber.Length != 16)
throw new ArgumentException("Invalid card number");
// Обробка кредитної картки
Console.WriteLine($"Processing credit card payment: ${amount}");
LogTransactionToFile("CreditCard", amount, "Success");
}
else if (paymentType == "PayPal")
{
// Перевірка PayPal account
if (string.IsNullOrEmpty(cardNumber))
throw new ArgumentException("PayPal account required");
// Обробка PayPal
Console.WriteLine($"Processing PayPal payment: ${amount}");
LogTransactionToFile("PayPal", amount, "Success");
}
else if (paymentType == "Bitcoin")
{
// Bitcoin wallet validation
if (cardNumber.Length < 26)
throw new ArgumentException("Invalid Bitcoin wallet");
// Обробка Bitcoin
Console.WriteLine($"Processing Bitcoin payment: ${amount}");
LogTransactionToFile("Bitcoin", amount, "Success");
}
SendConfirmationEmail(amount);
}
private void LogTransactionToFile(string type, decimal amount, string status)
{
File.AppendAllText("transactions.log",
$"{DateTime.Now}: {type} - ${amount} - {status}\n");
}
private void SendConfirmationEmail(decimal amount)
{
Console.WriteLine($"Email sent: Payment of ${amount} confirmed");
}
}
// Інтерфейси (ISP, DIP)
public interface IPaymentMethod
{
void Process(decimal amount);
void Validate(string accountInfo);
}
public interface ILogger
{
void Log(string message);
}
public interface INotificationService
{
void SendConfirmation(decimal amount);
}
// Реалізації (OCP - додавання нових методів не змінює існуючий код)
public class CreditCardPayment : IPaymentMethod
{
public void Validate(string cardNumber)
{
if (cardNumber?.Length != 16)
throw new ArgumentException("Invalid card number");
}
public void Process(decimal amount)
{
Console.WriteLine($"Processing credit card payment: ${amount}");
}
}
public class PayPalPayment : IPaymentMethod
{
public void Validate(string account)
{
if (string.IsNullOrEmpty(account))
throw new ArgumentException("PayPal account required");
}
public void Process(decimal amount)
{
Console.WriteLine($"Processing PayPal payment: ${amount}");
}
}
// Logger (SRP, DIP)
public class FileLogger : ILogger
{
public void Log(string message)
{
File.AppendAllText("transactions.log", $"{DateTime.Now}: {message}\n");
}
}
// Notification (SRP)
public class EmailNotificationService : INotificationService
{
public void SendConfirmation(decimal amount)
{
Console.WriteLine($"Email sent: Payment of ${amount} confirmed");
}
}
// Main processor (SRP - coordination only)
public class PaymentProcessor
{
private readonly IPaymentMethod _paymentMethod;
private readonly ILogger _logger;
private readonly INotificationService _notificationService;
public PaymentProcessor(
IPaymentMethod paymentMethod,
ILogger logger,
INotificationService notificationService)
{
_paymentMethod = paymentMethod;
_logger = logger;
_notificationService = notificationService;
}
public void ProcessPayment(decimal amount, string accountInfo)
{
_paymentMethod.Validate(accountInfo);
_paymentMethod.Process(amount);
_logger.Log($"{_paymentMethod.GetType().Name} - ${amount} - Success");
_notificationService.SendConfirmation(amount);
}
}
Мета: Застосувати всі принципи при проектуванні нової системи.
Вимоги: Спроектуйте систему управління замовленнями з наступними можливостями:
Обмеження:
// Domain Model
public class Order
{
public Guid Id { get; private set; }
public DateTime CreatedAt { get; private set; }
public Customer Customer { get; private set; }
public List<OrderItem> Items { get; private set; }
public decimal TotalAmount => Items.Sum(i => i.Price * i.Quantity);
private Order() { } // EF Core
public static Order Create(Customer customer, List<OrderItem> items)
{
// Domain validation
if (customer == null)
throw new ArgumentNullException(nameof(customer));
if (items == null || items.Count == 0)
throw new ArgumentException("Order must have at least one item");
return new Order
{
Id = Guid.NewGuid(),
CreatedAt = DateTime.UtcNow,
Customer = customer,
Items = items
};
}
// Law of Demeter: Order знає, як отримати знижку через Customer
public decimal GetDiscountAmount()
{
return Customer.CalculateDiscount(TotalAmount);
}
public decimal GetFinalAmount()
{
return TotalAmount - GetDiscountAmount();
}
}
// Strategy Pattern для знижок (OCP)
public interface IDiscountStrategy
{
decimal CalculateDiscount(decimal amount);
}
public class RegularCustomerDiscount : IDiscountStrategy
{
public decimal CalculateDiscount(decimal amount) => amount * 0.05m; // 5%
}
public class VIPCustomerDiscount : IDiscountStrategy
{
public decimal CalculateDiscount(decimal amount) => amount * 0.15m; // 15%
}
public class Customer
{
public Guid Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
private readonly IDiscountStrategy _discountStrategy;
public Customer(IDiscountStrategy discountStrategy)
{
_discountStrategy = discountStrategy;
}
// Tell-Don't-Ask
public decimal CalculateDiscount(decimal amount)
{
return _discountStrategy.CalculateDiscount(amount);
}
}
// Repository (DIP, SRP)
public interface IOrderRepository
{
void Save(Order order);
Order? GetById(Guid id);
}
public class SqlOrderRepository : IOrderRepository
{
private readonly string _connectionString;
public SqlOrderRepository(string connectionString)
{
_connectionString = connectionString;
}
public void Save(Order order)
{
// SQL implementation
}
public Order? GetById(Guid id)
{
// SQL implementation
return null;
}
}
// Services (SRP, ISP)
public interface IEmailService
{
void SendOrderConfirmation(Order order);
}
public interface ILogger
{
void LogInfo(string message);
void LogError(string message);
}
// Application Service (SRP - orchestration)
public class OrderService
{
private readonly IOrderRepository _repository;
private readonly IEmailService _emailService;
private readonly ILogger _logger;
public OrderService(
IOrderRepository repository,
IEmailService emailService,
ILogger logger)
{
_repository = repository;
_emailService = emailService;
_logger = logger;
}
public Order CreateOrder(Customer customer, List<OrderItem> items)
{
// KISS: проста послідовність дій
var order = Order.Create(customer, items);
_repository.Save(order);
_logger.LogInfo($"Order {order.Id} created for {customer.Name}");
_emailService.SendOrderConfirmation(order);
_logger.LogInfo($"Confirmation email sent for order {order.Id}");
return order;
}
}
Застосовані принципи:
SOLID
P: Single Responsibility
O: Open/Closed
L: Liskov Substitution
I: Interface Segregation
D: Dependency Inversion
Основа об'єктно-орієнтованого проектування
Інші Принципи
DRY: Don't Repeat Yourself
KISS: Keep It Simple, Stupid
YAGNI: You Aren't Gonna Need It
LoD: Law of Demeter
Направляють до простоти та maintainability
| Принцип | Сприяє | Конфліктує |
|---|---|---|
| SRP | ISP, DIP, DRY | - |
| OCP | DIP, Strategy Pattern | YAGNI (якщо передчасно) |
| LSP | OCP (корректність підстановки) | - |
| ISP | SRP, DIP | - |
| DIP | OCP, Testability | - |
| DRY | Maintainability | KISS (якщо абстракція складна) |
| KISS | Readability | DRY (іноді дублювання простіше) |
| YAGNI | KISS, Agile | OCP (якщо потрібна гнучкість) |
| LoD | Low Coupling | - |
Тепер, коли ви розумієте принципи проектування, наступні кроки: