Lab 22
SOLID + DI
SOLID принципи, Strategy, Decorator, IServiceCollection
Лабораторна робота 22 — SOLID + Dependency Injection
Проблема
Після Labs 03-21 у нас є ~5 300 рядків коду, що працює. Але є прихована проблема — жорсткі залежності:
// Clinic.cs — 16 залежностей hardcoded у конструкторі
public Clinic(string name)
{
Patients = new PatientManager(); // ← hardcoded
Logger = new ClinicLogger(); // ← hardcoded
Exporter = new ClinicExporter(this); // ← hardcoded
// ...ще 13 рядків...
}Що з цим не так?
- Неможливо підмінити
ClinicLoggerнаTestLoggerдля тестів - Неможливо додати новий тип ціноутворення без зміни
AppointmentProcessor Clinicзнає про 16 конкретних класів — при зміні будь-якого треба змінювати Clinic
SOLID — 5 принципів що вирішують ці проблеми.
Dependency Injection — механізм що робить залежності керованими.
Ключові концепції
S — Single Responsibility Principle
"Клас повинен мати тільки одну причину для зміни."
Клас Clinic порушує SRP — у нього п'ять причин змінитись:
- Змінилась конфігурація клініки → міняємо Clinic
- Додали новий менеджер → міняємо Clinic
- Змінилась логіка подій → міняємо Clinic
- Змінився формат звіту → міняємо Clinic
- Змінився формат розкладу → міняємо Clinic
Рішення: виділяємо окремий ClinicConfig record для конфігурації.
// Було:
public class Clinic { public string Name { get; } ... }
// Стало:
public record ClinicConfig(string Name, string Address = "", DateTime? Founded = null);
public class Clinic { public ClinicConfig Config { get; } ... }O — Open/Closed Principle
"Класи відкриті для розширення, закриті для змін."
Проблема без OCP:
// AppointmentProcessor — щоб додати нову ставку, треба змінити існуючий клас:
if (appointment is UrgentAppointment) cost *= 1.5m;
else if (appointment is SpecialistAppointment) cost *= 1.3m;
// Новий тип → новий if → зміна існуючого коду → ризик регресійРішення — Strategy pattern:
public interface ICostStrategy
{
string Description { get; }
decimal Calculate(Appointment appointment);
}
// Новий тип ціноутворення = новий клас, без змін AppointmentProcessor:
public class NightShiftCostStrategy : ICostStrategy { ... }L — Liskov Substitution Principle
"Підтипи повинні бути замінними для своїх базових типів."
У нашому проєкті вже реалізовано: RegularAppointment, UrgentAppointment, SpecialistAppointment — всі замінні для Appointment. Метод що приймає Appointment — працює з будь-яким підтипом.
Порушення LSP (для аналізу):
// ❌ Порушення: підклас кидає виняток де базовий — не кидає
class ReadOnlyCollection : Collection {
public override void Add(T item) => throw new NotSupportedException(); // LSP порушено
}I — Interface Segregation Principle
"Клієнти не повинні залежати від методів, які вони не використовують."
Погано (один великий інтерфейс):
interface IClinicService {
Task<List<Patient>> GetPatients();
Task<List<Doctor>> GetDoctors();
Task AddPatient(Patient p);
Task BookAppointment(Appointment a);
Task ExportCsv(string path);
Task GenerateReport();
// ... 20+ методів
}
// Клас що потребує тільки GetDoctors() — знає про все іншеДобре (ISP):
interface IPatientService { Task<List<Patient>> GetAllAsync(); ... }
interface IDoctorService { Task<List<Doctor>> GetAllAsync(); ... }
interface IAppointmentService { Task<List<Appointment>> GetUpcomingAsync(); ... }D — Dependency Inversion Principle
"Модулі верхнього рівня залежать від абстракцій, не від конкретних реалізацій."
// ❌ Погано — пряма залежність від конкретного класу:
public class ReportGenerator
{
private readonly PatientService _service; // конкретний клас
public ReportGenerator() { _service = new PatientService(new ClinicDbContext()); }
}
// ✅ Добре — залежність від абстракції:
public class ReportGenerator
{
private readonly IPatientService _service; // інтерфейс
public ReportGenerator(IPatientService service) { _service = service; }
// DI-контейнер підставить реалізацію автоматично
}Dependency Injection — IServiceCollection
var services = new ServiceCollection();
// Реєстрація з lifetime:
services.AddSingleton<ClinicLogger>(); // один на весь застосунок
services.AddScoped<ClinicDbContext>(); // новий на кожен scope
services.AddScoped<IPatientService, PatientService>(); // interface → implementation
var provider = services.BuildServiceProvider();
// Отримання сервісу:
var logger = provider.GetRequiredService<ClinicLogger>(); // кидає якщо не зареєстровано
var svc = provider.GetService<IPatientService>(); // null якщо не зареєстрованоLifetimes:
| Lifetime | Новий екземпляр | Підходить для |
|---|---|---|
Singleton |
Один раз | Logger, HttpClient, конфігурація |
Scoped |
На кожен scope | DbContext, Repository, Service |
Transient |
На кожен запит | Легкі stateless об'єкти |
Singleton + Scoped — небезпечна комбінація:
// ❌ Singleton не може залежати від Scoped!
services.AddSingleton<MyService>(sp =>
new MyService(sp.GetRequiredService<ClinicDbContext>())); // DbContext — Scoped
// При першому використанні: DbContext буде "захоплений" назавжди → memory leakПаттерн Decorator
Decorator реалізує той самий інтерфейс і делегує виклики до "справжнього" об'єкта, додаючи поведінку:
public class LoggingPatientService(IPatientService inner, ClinicLogger logger) : IPatientService
{
public async Task<List<Patient>> GetAllAsync(CancellationToken ct = default)
{
logger.LogInfo("GetAllAsync викликано");
var result = await inner.GetAllAsync(ct); // делегування
logger.LogInfo($"GetAllAsync → {result.Count} пацієнтів");
return result;
}
// ...
}DI реєстрація Decorator:
services.AddScoped<IPatientService>(sp =>
new LoggingPatientService(
new PatientService(sp.GetRequiredService<ClinicDbContext>()),
sp.GetRequiredService<ClinicLogger>()));Завдання
Завдання 1. S — Single Responsibility: ClinicConfig
Задача: виділити конфігурацію клініки в окремий record.
Створіть ClinicConfig у src/Models/:
public record ClinicConfig(string Name, string Address = "", DateTime? Founded = null)
{
public string FoundedYear => Founded.HasValue ? ... : "невідомо";
}Модифікуйте Clinic:
- Додайте
public ClinicConfig Config { get; } - Додайте конструктор
Clinic(ClinicConfig config) - Залиште
Clinic(string name) : this(new ClinicConfig(name))для зворотної сумісності public string Name => Config.Name;— делегат замість прямого поля
Задокументуйте (XML-коментарі або //): скільки ще відповідальностей залишилось у Clinic.
Чому використано record а не class для ClinicConfig? Що дає незмінність конфігурації?
Ключові питання:
- Скільки причин змінитись у вашій поточній
Clinic.cs? - Де проходить межа між "це одна відповідальність" і "це дві"?
Завдання 2. O — Open/Closed: ICostStrategy
Задача: додати підтримку стратегій ціноутворення без зміни AppointmentProcessor.
Створіть у src/Strategies/:
ICostStrategy — interface: Description, Calculate(Appointment)
RegularCostStrategy — базова ставка: DurationMinutes × 10 грн
UrgentCostStrategy — коефіцієнт: базова × multiplier (default 1.5)
DiscountCostStrategy — знижка: базова × (1 - discountPercent)Розширте AppointmentProcessor (не переписуйте!):
private ICostStrategy? _costStrategy;
public AppointmentProcessor WithCostStrategy(ICostStrategy strategy) { ... return this; }
public decimal CalculateCost(Appointment a) => _costStrategy?.Calculate(a) ?? a.GetCost();
public static (decimal Regular, decimal WithStrategy) CompareCost(Appointment a, ICostStrategy s) => ...Тест OCP: додайте NightShiftCostStrategy (ставка ×1.2 після 18:00) — без жодних змін у AppointmentProcessor, Appointment, або існуючих стратегіях.
Ключові питання:
- Чому
ICostStrategy?(nullable) а не обов'язковий параметр конструктора? _costStrategy?.Calculate(a) ?? a.GetCost()— що відбудеться якщо стратегія не встановлена?
Завдання 3+4. I + D — ISP + DIP: інтерфейси та реалізації
Задача: визначити три сервісних інтерфейси і реалізувати їх поверх EF Core.
Створіть у src/Services/:
Інтерфейси (ISP):
IPatientService — GetAllAsync, GetByIdAsync, SearchAsync, AddAsync, SoftDeleteAsync, CountAsync
IDoctorService — GetAllAsync, GetByIdAsync, GetBySpecialityAsync, CountAsync
IAppointmentService — GetUpcomingAsync, GetByPatientAsync, BookAsync, CancelAsync, CompleteAsync, GetTotalRevenueAsyncРеалізації (DIP — залежать від ClinicDbContext через конструктор):
Використайте primary constructor (C# 12):
public class PatientService(ClinicDbContext context) : IPatientService
{
public async Task<List<Patient>> GetAllAsync(CancellationToken ct = default)
=> await context.Patients.AsNoTracking().OrderBy(p => p.LastName).ToListAsync(ct);
// ...
}Primary constructor — параметри доступні як поля без явного оголошення.
DIP перевірка: напишіть метод що приймає IPatientService (не PatientService):
static async Task PrintPatientCount(IPatientService service)
=> Console.WriteLine($"Пацієнтів: {await service.CountAsync()}");Цей метод може працювати з PatientService, LoggingPatientService, або будь-яким mock.
Ключові питання:
- В чому різниця між ISP і звичайним розбиттям на кілька класів?
- Чому primary constructor зручніший для DIP ніж звичайний constructor?
Завдання 5. IServiceCollection: DI-контейнер і Decorator
Задача А — ServiceContainer:
Створіть src/Infrastructure/ServiceContainer.cs:
public static class ServiceContainer
{
public static IServiceProvider Build()
{
var services = new ServiceCollection();
services.AddDbContext<ClinicDbContext>(); // Scoped
services.AddSingleton<ClinicLogger>(); // Singleton
services.AddScoped<IDoctorService, DoctorService>();
services.AddScoped<IAppointmentService, AppointmentService>();
// IPatientService через Decorator — зареєструйте через фабрику (lambda sp => ...):
// new LoggingPatientService(new PatientService(...), ...)
// Отримуйте залежності через sp.GetRequiredService<T>()
services.AddScoped<IPatientService>(sp => /* ваша фабрика */);
return services.BuildServiceProvider();
}
}Задача Б — Decorator (LoggingPatientService):
public class LoggingPatientService(IPatientService inner, ClinicLogger logger) : IPatientService
{
public async Task<List<Patient>> GetAllAsync(CancellationToken ct = default)
{
// 1. logger.LogInfo — фіксуємо виклик
// 2. await inner.GetAllAsync(ct) — делегуємо до "справжнього" сервісу
// 3. logger.LogInfo — фіксуємо результат (кількість записів)
// 4. return result
}
// інші методи — реалізуйте аналогічно (з логуванням або без)
}Задача В — lifetime перевірка:
var a = provider.GetRequiredService<ClinicLogger>();
var b = provider.GetRequiredService<ClinicLogger>();
Console.WriteLine(ReferenceEquals(a, b)); // true — Singleton
using var s1 = provider.CreateScope();
using var s2 = provider.CreateScope();
var svc1 = s1.ServiceProvider.GetRequiredService<IAppointmentService>();
var svc2 = s2.ServiceProvider.GetRequiredService<IAppointmentService>();
Console.WriteLine(ReferenceEquals(svc1, svc2)); // false — Scoped, різні scopeКлючові питання:
- Що відбудеться якщо зареєструвати
ClinicDbContextяк Singleton? - Навіщо
provider.CreateScope()в консольному застосунку?
Завдання 6. GetRequiredService vs GetService + L: Liskov
Задача А — DI resolution:
// GetRequiredService<T> — кидає InvalidOperationException якщо не зареєстровано
var logger = provider.GetRequiredService<ClinicLogger>();
// GetService<T> — повертає null якщо не зареєстровано
var opt = provider.GetService<ClinicLogger>(); // не null (зареєстровано)
var missing = provider.GetService<SessionManager>(); // null (не зареєстровано)Правило: GetRequiredService — коли сервіс обов'язковий. GetService — коли опціональний.
Задача Б — L: Liskov Substitution аналіз:
Перевірте що наша ієрархія Appointment дотримується LSP:
// Метод приймає базовий тип
static void ProcessAppointment(Appointment a)
{
Console.WriteLine(a.GetDescription()); // поліморфний виклик
Console.WriteLine($"Вартість: {a.GetCost()}");
}
// Всі підтипи замінні:
ProcessAppointment(new RegularAppointment(...)); // ✅
ProcessAppointment(new UrgentAppointment(...)); // ✅
ProcessAppointment(new SpecialistAppointment(...)); // ✅Знайдіть у кодовій базі приклад де LSP могло б бути порушено (наприклад, якби UrgentAppointment.Cancel() кидав виняток замість повернення false). Задокументуйте.
Ключові питання:
- Де в нашому проєкті можна замінити
PatientServiceнаLoggingPatientService— без зміни коду що їх використовує? GetRequiredServicevsActivatorUtilities.CreateInstance— коли потрібен другий варіант?
Рефлексійні питання
SOLID як єдине ціле. Покажіть як порушення одного принципу призводить до порушення інших. Наприклад: якщо
Clinicпорушує SRP → чи стає складніше дотриматись DIP?Decorator vs Inheritance для логування. Чому
LoggingPatientServiceреалізований як Decorator (композиція), а не якclass LoggingPatientService : PatientService(спадкування)? Коли спадкування було б кращим вибором?Singleton DbContext — чому небезпечно.
DbContextтримає в пам'яті стан змінених об'єктів (Change Tracker). Якщо він Singleton — що відбудеться при конкурентних запитах? Де стан одного запиту "просочиться" в інший?Primary constructor і DI.
public class PatientService(ClinicDbContext context)— як компілятор C# 12 перетворює цей запис? Які є обмеження primary constructor порівняно зі звичайним?OCP і кількість файлів. OCP зменшує ризик регресій але збільшує кількість файлів (
RegularCostStrategy,UrgentCostStrategy,DiscountCostStrategy...). Як знайти баланс? Коли варто відмовитись від стратегій і залишити простий if/switch?ISP в реальних проєктах. ASP.NET Core's
ILogger<T>— великий чи маленький інтерфейс? Чи порушує він ISP? Порівняйте з нашимIPatientService.