Lab 22
SOLID + DI
SOLID принципи, Strategy, Decorator, IServiceCollection
Лаба 22 — SOLID + Dependency Injection
Мета
Застосувати п'ять принципів SOLID до наявного проєкту і зробити залежності між класами керованими через Dependency Injection: виділити конфігурацію, додати стратегії ціноутворення, розбити сервіси на вузькі інтерфейси, зареєструвати їх у DI-контейнері з декоратором і перевірити принцип підстановки Лісков.
Контекст
Після Лаб 03–21 у нас тисячі рядків робочого коду, але з прихованою проблемою — жорсткими залежностями:
// Clinic.cs — усі залежності створюються прямо в конструкторі
public Clinic(string name)
{
Patients = new PatientManager(); // ← жорстко
Logger = new ClinicLogger(); // ← жорстко
Exporter = new ClinicExporter(this); // ← жорстко
// … ще десяток рядків
}Наслідки: ClinicLogger неможливо підмінити тестовим; новий тип ціноутворення вимагає змінювати AppointmentProcessor; Clinic знає про десятки конкретних класів і змінюється разом із кожним. SOLID — п'ять принципів проти цих проблем; Dependency Injection — механізм, що робить залежності керованими.
Структура проєкту на початку лаби
Це результат Лаби 21 — стан main після її злиття:
oop-course/ ← гілка main (після злиття Лаби 21)
├── .gitignore
├── oop-course.slnx
└── ClinicApp/
├── ClinicApp.csproj
├── Program.cs
├── Clinic.cs
├── Enums/ (4 файли)
├── Models/ (20 файлів)
├── Managers/
│ ├── AppointmentProcessor.cs
│ └── … ще 12 файлів без змін
├── Utils/ (12 файлів)
├── Interfaces/ (4 файли)
├── Comparators/ (4 файли)
├── Attributes/ (3 файли)
├── Events/ (4 файли)
├── Extensions/ (3 файли)
├── UI/ (1 файл)
├── Data/ (6 файлів)
└── Migrations/ (9 файлів — генерує EF)Структуру наприкінці лаби (з позначками, що створюється і змінюється в кожній задачі) наведено в розділі «Структура проєкту наприкінці лаби» перед перевіркою.
SOLID коротко
| Принцип | Формулювання | Де в цій лабі |
|---|---|---|
| S — Single Responsibility | у класу одна причина для зміни | ClinicConfig (Задача 1) |
| O — Open/Closed | відкритий для розширення, закритий для змін | стратегії ціни (Задача 2) |
| L — Liskov Substitution | підтип замінює базовий тип без сюрпризів | ієрархія Appointment (Задача 5) |
| I — Interface Segregation | клієнт не залежить від методів, яких не використовує | вузькі сервісні інтерфейси (Задача 3) |
| D — Dependency Inversion | залежати від абстракцій, а не від конкретних класів | сервіси через конструктор (Задачі 3–4) |
Dependency Injection
DI-контейнер (ServiceCollection) зберігає правила «який тип створювати для якої залежності» і сам будує об'єкти з усіма їхніми залежностями. Час життя реєстрації:
| Lifetime | Новий екземпляр | Підходить для |
|---|---|---|
Singleton |
один на весь застосунок | логер, HttpClient, конфігурація |
Scoped |
один на кожен scope (CreateScope()) |
DbContext, репозиторії, сервіси |
Transient |
при кожному запиті | легкі об'єкти без стану |
Singleton не може залежати від Scoped: інакше він «захопить» перший DbContext назавжди.
Декоратор реалізує той самий інтерфейс, що й «справжній» об'єкт, і делегує йому виклики, додаючи свою поведінку (наприклад, логування) — без зміни самого об'єкта.
Що нового дозволено (і тільки воно)
recordз обчислюваними властивостями;- primary constructor (C# 12):
class PatientService(ClinicDbContext context); - пакет
Microsoft.Extensions.DependencyInjection:ServiceCollection,AddSingleton/AddScoped,AddDbContext,GetRequiredService/GetService,CreateScope.
Крок 1. Гілка
Робочий процес (повністю — Git Воркшоп): лаба = гілка
Lab-XXвідmain, коміт на кожне завдання (LabXX TaskYY), у кінці — злиття вmain.
Проєкт ClinicApp/ уже існує. Тут лише нова гілка від main:
git checkout main
git checkout -b Lab-22Коміт — на кожне завдання (Lab22 TaskNN).
Ваш домен
За замовчуванням виконуйте завдання як написано (домен «клініка»). Для власного домену дивіться таблицю «Адаптація до вашого домену» в кінці кожного завдання.
Як користуватися підказками
Підказки — напрям думки, не готовий код. «Що реалізувати» і «Специфікація» кажуть що; підказки — як міркувати; блок 📖 Документація — де прочитати синтаксис. Спершу документація і власна спроба.
Задача 1. S — конфігурація клініки в `ClinicConfig` ⭐
Умова
У Clinic кілька причин змінитись: конфігурація, нові менеджери, події, формат звітів… Почніть зі SRP: винесіть конфігурацію клініки в окремий незмінний record.
Що реалізувати:
- Створити
record ClinicConfigуClinicApp/Models/(специфікація нижче). - У
Clinicдодати властивістьConfigі конструкторClinic(ClinicConfig config). - Залишити конструктор
Clinic(string name)для зворотної сумісності — він передає роботу новому конструктору. NameуClinicтепер береться зConfig.- Коментарем у
Clinic.csперелічити, які відповідальності в класі ще лишились.
Специфікація
ClinicConfig |
|
|---|---|
Name |
string |
Address |
string, за замовчуванням "" |
Founded |
DateTime?, за замовчуванням null |
FoundedYear |
обчислювана: рік заснування або "невідомо" |
Clinic |
|
|---|---|
Config |
ClinicConfig, лише читання |
Clinic(ClinicConfig config) |
новий основний конструктор |
Clinic(string name) |
: this(new ClinicConfig(name)) |
Name |
повертає Config.Name |
Приклад
var clinic = new Clinic(new ClinicConfig("Медична клініка", "вул. Медична 1", new DateTime(2010, 3, 1)));
Console.WriteLine(clinic.Config.FoundedYear); // 2010
var old = new Clinic("Клініка"); // старий код і далі працюєПідказки
recordз позиційними параметрами — незмінний тип: конфігурацію не можна «випадково» змінити з будь-якого місця програми.- Обчислювана властивість у
recordоголошується в тілі після параметрів. - Межа «одна відповідальність» — це одна причина для зміни, а не один метод.
📖 Документація:
Адаптація до вашого домену
| Клініка | Готель | Ресторан | Університет | Прокат авто | Бібліотека | Спортзал |
|---|---|---|---|---|---|---|
ClinicConfig |
HotelConfig |
RestaurantConfig |
UniversityConfig |
RentalConfig |
LibraryConfig |
GymConfig |
Коміт
git add ClinicApp/Models/ClinicConfig.cs ClinicApp/Clinic.cs
git commit -m "Lab22 Task01"Задача 2. O — стратегії ціноутворення ⭐⭐
Умова
Щоб додати нову ставку, зараз треба дописувати if в існуючий код — і ризикувати зламати те, що працювало. Застосуйте патерн Strategy: кожна ставка — окремий клас, а AppointmentProcessor лише використовує передану стратегію.
Що реалізувати:
- Створити теку
ClinicApp/Strategies/з інтерфейсомICostStrategyі трьома стратегіями (специфікація нижче). - Розширити (не переписати)
AppointmentProcessor(Лаба 15): необов'язкова стратегія, метод її встановлення, розрахунок ціни і статичне порівняння. - Перевірити OCP: додати
NightShiftCostStrategy(×1.2 для прийомів, що починаються о 18:00 і пізніше) — без жодних змін уAppointmentProcessor,Appointmentі наявних стратегіях.
Специфікація
| Тип | Опис |
|---|---|
ICostStrategy |
string Description { get; }, decimal Calculate(Appointment appointment) |
RegularCostStrategy |
DurationMinutes × 10 грн |
UrgentCostStrategy |
базова × множник (за замовчуванням 1.5, задається конструктором) |
DiscountCostStrategy |
базова × (1 − знижка), знижка задається конструктором |
NightShiftCostStrategy |
базова × 1.2, якщо прийом о 18:00 і пізніше; інакше базова |
Нове в AppointmentProcessor |
Опис |
|---|---|
поле ICostStrategy? |
стратегія, спочатку не задана |
WithCostStrategy(ICostStrategy strategy) |
встановлює стратегію, повертає this |
CalculateCost(Appointment a) |
ціна за стратегією; якщо стратегії немає — a.GetCost() |
static CompareCost(Appointment a, ICostStrategy s) |
(decimal Regular, decimal WithStrategy) |
Приклад
var (regular, night) = AppointmentProcessor.CompareCost(appt, new NightShiftCostStrategy());
Console.WriteLine($"{regular:F2} → {night:F2}"); // 300.00 → 360.00 для прийому о 19:00Підказки
- Стратегія необов'язкова: без неї процесор поводиться, як раніше, — тому не параметр конструктора, а метод
WithCostStrategy. ?.і??разом дають «ціна за стратегією або звичайна ціна» одним виразом.- Перевірка OCP — це саме те, що змінилось у
git diffЗадачі 2 після додаванняNightShiftCostStrategy: лише новий файл.
📖 Документація:
Адаптація до вашого домену
| Клініка | Готель | Ресторан | Університет | Прокат авто | Бібліотека | Спортзал |
|---|---|---|---|---|---|---|
NightShiftCostStrategy |
WeekendRateStrategy |
LateDinnerStrategy |
EveningCourseStrategy |
HolidayRateStrategy |
OverdueFineStrategy |
PeakHoursStrategy |
Коміт
git add ClinicApp/Strategies/ ClinicApp/Managers/AppointmentProcessor.cs
git commit -m "Lab22 Task02"Задача 3. I + D — вузькі інтерфейси сервісів і їх реалізації ⭐⭐⭐
Умова
Замість одного великого «сервісу клініки» опишіть три вузькі інтерфейси (ISP) і реалізуйте їх поверх EF Core так, щоб реалізації отримували ClinicDbContext ззовні, через конструктор (DIP).
Що реалізувати:
- Створити теку
ClinicApp/Services/з трьома інтерфейсами (специфікація нижче); усі методи асинхронні зCancellationToken ct = default. - Реалізувати
PatientService,DoctorService,AppointmentServiceз primary constructor, що приймаєClinicDbContext. - У
Program.csдодати функціюPrintPatientCount(IPatientService service)— вона приймає інтерфейс, а не клас.
Специфікація
| Інтерфейс | Методи |
|---|---|
IPatientService |
GetAllAsync, GetByIdAsync(int id), SearchAsync(string query), AddAsync(Patient p), SoftDeleteAsync(int id), CountAsync |
IDoctorService |
GetAllAsync, GetByIdAsync(int id), GetBySpecialityAsync(Speciality s), CountAsync |
IAppointmentService |
GetUpcomingAsync, GetByPatientAsync(int patientId), BookAsync(Appointment a), CancelAsync(int id), CompleteAsync(int id), GetTotalRevenueAsync |
Приклад
public class PatientService(ClinicDbContext context) : IPatientService
{
// context доступний у всіх методах без явного поля
}Пацієнтів: 5Підказки
- Primary constructor — параметри конструктора в оголошенні класу; вони доступні всім методам без явного поля.
- Для читання —
AsNoTracking(); для змін — звичайне відстеження іSaveChangesAsync. GetTotalRevenueAsync:GetCost()не перекладається в SQL (Лаба 18) — завантажте записи і порахуйте суму в пам'яті.PrintPatientCountпрацюватиме з будь-якою реалізацієюIPatientService—PatientService, декоратором із Задачі 4 чи тестовою заглушкою.
📖 Документація:
Адаптація до вашого домену
| Клініка | Готель | Ресторан | Університет | Прокат авто | Бібліотека | Спортзал |
|---|---|---|---|---|---|---|
IPatientService / IDoctorService / IAppointmentService |
IGuestService / IStaffService / IBookingService |
ICustomerService / IWaiterService / IReservationService |
IStudentService / ILecturerService / IEnrollmentService |
IClientService / IManagerService / IRentalService |
IReaderService / ILibrarianService / ILoanService |
IMemberService / ITrainerService / ISessionService |
Коміт
git add ClinicApp/Services/ ClinicApp/Program.cs
git commit -m "Lab22 Task03"Задача 4. DI-контейнер і декоратор логування ⭐⭐⭐
Умова
Перестаньте створювати сервіси вручну: зареєструйте їх у DI-контейнері, а логування додайте декоратором — без змін у PatientService.
Що реалізувати:
- Додати пакет
Microsoft.Extensions.DependencyInjection(команда нижче). - Створити
static class ServiceContainerуClinicApp/Infrastructure/з методомBuild(), що реєструє сервіси за специфікацією і повертаєIServiceProvider. - Створити декоратор
LoggingPatientServiceуClinicApp/Services/: реалізуєIPatientService, отримує «справжній»IPatientServiceіClinicLogger, логує виклик і результат, делегує роботу. - Зареєструвати
IPatientServiceфабрикою, що обгортаєPatientServiceуLoggingPatientService. - У
Program.csпобудувати контейнер, отримати сервіси в scope і перевірити час життя (див. приклад).
Специфікація
dotnet add ClinicApp package Microsoft.Extensions.DependencyInjection --version 8.0.0| Реєстрація | Lifetime |
|---|---|
ClinicDbContext |
Scoped (AddDbContext) |
ClinicLogger |
Singleton |
IDoctorService → DoctorService |
Scoped |
IAppointmentService → AppointmentService |
Scoped |
IPatientService → LoggingPatientService(PatientService) |
Scoped, фабрика sp => … |
Приклад
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 x = s1.ServiceProvider.GetRequiredService<IAppointmentService>();
var y = s2.ServiceProvider.GetRequiredService<IAppointmentService>();
Console.WriteLine(ReferenceEquals(x, y)); // False — Scoped, різні scopeПідказки
- У фабриці залежності беріть із
sp.GetRequiredService<T>()— контейнер сам віддасть потрібний екземпляр із правильним часом життя. - Декоратор — композиція: він містить
IPatientService, а не успадковуєPatientService. - Методи без логування декоратор просто передає далі —
inner.Метод(...). - У консольному застосунку scope створюється явно —
CreateScope(), один scope ≈ одна «операція».
📖 Документація:
Адаптація до вашого домену
| Клініка | Готель | Ресторан | Університет | Прокат авто | Бібліотека | Спортзал |
|---|---|---|---|---|---|---|
LoggingPatientService |
LoggingGuestService |
LoggingCustomerService |
LoggingStudentService |
LoggingClientService |
LoggingReaderService |
LoggingMemberService |
Коміт
git add ClinicApp/ClinicApp.csproj ClinicApp/Infrastructure/ServiceContainer.cs ClinicApp/Services/LoggingPatientService.cs ClinicApp/Program.cs
git commit -m "Lab22 Task04"Задача 5. `GetRequiredService` чи `GetService`; L — підстановка Лісков ⭐⭐
Умова
Покажіть різницю між обов'язковим і необов'язковим отриманням сервісу з контейнера і перевірте, що ієрархія записів дотримується принципу підстановки Лісков.
Що реалізувати:
- У
Program.csпродемонструвати:GetRequiredServiceдля зареєстрованого сервісу;GetServiceдля зареєстрованого (неnull) і для незареєстрованогоSessionManager(null). - Додати функцію
ProcessAppointment(Appointment a), що виводить опис і вартість, і викликати її з об'єктами всіх трьох підтипів — безis/as. - Коментарем описати місце в проєкті, де LSP могло б бути порушено (наприклад, якби
UrgentAppointment.Cancel()кидав виняток замість поверненняfalse).
Специфікація
| Метод | Сервіс не зареєстровано |
|---|---|
GetRequiredService<T>() |
InvalidOperationException — для обов'язкових залежностей |
GetService<T>() |
null — для необов'язкових |
Приклад
GetService<ClinicLogger>: знайдено
GetService<SessionManager>: null
Звичайний прийом | 300.00 грн
Терміновий (біль у грудях) | 450.00 грн
Консультація спеціаліста: кардіологія | 780.00 грнПідказки
- LSP — не про синтаксис, а про поведінку: підтип не має кидати винятків чи посилювати вимоги там, де базовий тип цього не робить.
- Підстановку декоратора замість
PatientService(Задача 4) теж можна розглядати як LSP: код, що працює зIPatientService, не помічає різниці.
📖 Документація:
Адаптація до вашого домену
| Клініка | Готель | Ресторан | Університет | Прокат авто | Бібліотека | Спортзал |
|---|---|---|---|---|---|---|
ProcessAppointment(Appointment) |
ProcessBooking(Booking) |
ProcessReservation(TableReservation) |
ProcessEnrollment(Enrollment) |
ProcessRental(Rental) |
ProcessLoan(BookLoan) |
ProcessSession(Session) |
Коміт
git add ClinicApp/Program.cs
git commit -m "Lab22 Task05"Структура проєкту наприкінці лаби
Так має виглядати ClinicApp/, коли всі завдання виконано:
oop-course/ ← гілка Lab-22 (після злиття — main)
├── .gitignore
├── oop-course.slnx
└── ClinicApp/
├── ClinicApp.csproj ✏ Т4
├── Program.cs ✏ Т3 Т4 Т5
├── Clinic.cs ✏ Т1
├── Enums/ (4 файли)
├── Models/
│ ├── ClinicConfig.cs 🆕 Т1
│ └── … ще 20 файлів без змін
├── Managers/
│ ├── AppointmentProcessor.cs ✏ Т2
│ └── … ще 12 файлів без змін
├── Utils/ (12 файлів)
├── Interfaces/ (4 файли)
├── Comparators/ (4 файли)
├── Attributes/ (3 файли)
├── Events/ (4 файли)
├── Extensions/ (3 файли)
├── UI/ (1 файл)
├── Data/ (6 файлів)
├── Migrations/ (9 файлів — генерує EF)
├── Infrastructure/
│ └── ServiceContainer.cs 🆕 Т4
├── Services/
│ ├── IPatientService.cs 🆕 Т3
│ ├── IDoctorService.cs 🆕 Т3
│ ├── IAppointmentService.cs 🆕 Т3
│ ├── PatientService.cs 🆕 Т3
│ ├── DoctorService.cs 🆕 Т3
│ ├── AppointmentService.cs 🆕 Т3
│ └── LoggingPatientService.cs 🆕 Т4
└── Strategies/
├── ICostStrategy.cs 🆕 Т2
├── RegularCostStrategy.cs 🆕 Т2
├── UrgentCostStrategy.cs 🆕 Т2
├── DiscountCostStrategy.cs 🆕 Т2
└── NightShiftCostStrategy.cs 🆕 Т2Легенда: 🆕 — новий файл · ✏ — змінено вміст · Тn — номер задачі, у якій ви працюєте з файлом. Файли без позначки лишились такими, як були після Лаби 21.
Назви файлів наведено для домену «клініка»; у власному домені назви ваші — важливо, що саме створюється й змінюється.
Перевірка перед здачею
dotnet build ClinicApp
dotnet run --project ClinicAppПереконайтесь, що:
- Структура проєкту збігається зі схемою вище
-
new Clinic("…")іnew Clinic(new ClinicConfig(…))— обидва працюють - Додавання
NightShiftCostStrategyне змінило жодного існуючого файлу -
PrintPatientCountприймаєIPatientServiceі виводить кількість пацієнтів - Виклики
IPatientServiceз'являються в лозі (працює декоратор) - Перевірка часу життя: Singleton —
True, Scoped у різних scope —False -
GetService<SessionManager>()повертаєnull -
ProcessAppointmentпрацює з усіма трьома підтипами безis/as
Питання для самоперевірки
- SOLID як ціле. Як порушення одного принципу тягне за собою інші? Чи складніше дотриматись DIP, якщо
Clinicпорушує SRP? - Декоратор чи успадкування. Чому
LoggingPatientService— декоратор (композиція), а неclass LoggingPatientService : PatientService? Коли успадкування було б кращим? - Singleton
DbContext.DbContextтримає в пам'яті стан змінених об'єктів. Що станеться при одночасних запитах, якщо він Singleton? - Primary constructor. На що компілятор перетворює
class PatientService(ClinicDbContext context)? Які обмеження порівняно зі звичайним конструктором? - OCP і кількість файлів. Стратегії зменшують ризик регресій, але множать файли. Коли варто залишити простий
switch? - ISP у .NET.
ILogger<T>— великий чи маленький інтерфейс? Чи порушує він ISP? Порівняйте зIPatientService.
Статус гілки
Після всіх 5 завдань (кожне — окремий коміт Lab22 TaskNN на гілці Lab-22):
git push -u origin Lab-22
git checkout main
git merge --no-ff Lab-22 -m "Merge Lab-22: SOLID and Dependency Injection"
git pushЦе остання лаба курсу. Вітаємо!