1. Інтерфейси як межі між шарами
Можна написати застосунок без інтерфейсів — і він навіть працюватиме. Так само можна їхати на велосипеді без рук: інколи це вдається, доки не трапиться яма. Інтерфейси потрібні не для «краси», а щоб захистити код від хаосу змін. Коли ви змінюєте зберігання або формат звіту, не хочеться, щоб через одну правку «посипалися» логіка команд, сервіс, друк і пів проєкту.
Уявімо, що в нас є проєкт «облік витрат» (умовний мінітрекер). На доменному рівні ми хочемо вміти:
- додавати витрату з перевірками,
- отримувати список витрат,
- будувати текстовий звіт.
Але як саме витрати зберігаються (у списку, у мапі, у кеші, у файлі — не сьогодні) і як саме виглядає звіт (plain text, таблиця, CSV — теж не сьогодні) — це деталі, які постійно хочеться змінювати.
Інтерфейс у цій історії — це «розетка»: вам не обовʼязково знати, як улаштована електростанція, щоб увімкнути ноутбук.
Інтерфейс як договір
Дуже важливо не перетворювати інтерфейс на «бог‑обʼєкт», який уміє все на світі. Хороший інтерфейс — це договір: мінімум методів, максимум ясності. Він відповідає на запитання: «Що потрібно коду, який викликає?» — і відкидає інше: «А що було б прикольно додати на майбутнє?».
Є класична пастка для новачків: ви написали InMemoryExpenseRepository, а потім зробили інтерфейс ExpenseRepository рівно такої самої «ширини», як і реалізація, — разом з усіма допоміжними методами. У результаті інтерфейс стає не договором, а «фотографією реалізації». Тоді він перестає захищати систему: будь-яка нова забаганка в реалізації тягне за собою зміни інтерфейсу, а отже — зміни в усіх шарах.
Тримайте в голові правило:
Інтерфейс — це не перелік ваших можливостей. Інтерфейс — це перелік чужих потреб.
Доменна модель
Перш ніж писати репозиторії та експортери, нам потрібна модель даних. На доменному рівні вона має бути максимально простою: дані — і нічого зайвого.
// FILE: domain/model/Expense.kt
package domain.model
data class Expense(
val title: String,
val amount: Int,
)
Тут немає println, немає readln, немає форматування, немає сховища. Є лише зміст: «витрата — це назва і сума».
2. Repository: контракт доступу до доменних даних
Репозиторій (Repository) — це «точка входу» для роботи з даними. На нашому рівні (v1) це просто контракт: як додати витрату і як отримати список витрат. Нічого надприродного.
Важливо: repository — це не обовʼязково база даних. Сьогодні це може бути звичайний список у памʼяті. Сенс не в тому, де лежать дані, а в тому, що доменний код не повинен залежати від конкретного способу зберігання.
Інтерфейс ExpenseRepository: вузький і нудний — отже, хороший
Зробімо контракт мінімальним: add() і all().
// FILE: domain/service/ExpenseRepository.kt
package domain.service
import domain.model.Expense
interface ExpenseRepository {
fun add(expense: Expense)
fun all(): List<Expense>
}
Чому all() повертає List, а не MutableList? Тому що ми хочемо віддавати назовні «вигляд лише для читання», щоб зовнішній код не міг «випадково» видалити дані напряму.
У Kotlin є важлива практична ідея: MutableList — це список з операціями зміни, а List — «лише читання». Навіть якщо «під капотом» лежить змінюваний список, назовні краще віддавати інтерфейс лише для читання. Це відповідає загальному підходу до колекцій у Kotlin: є типи лише для читання та mutable‑типи, а в mutable — операції зміни.
Реалізація InMemoryExpenseRepository: ховаємо MutableList усередину
Тепер напишемо сховище в памʼяті. Це шар зберігання, але він реалізує доменний контракт.
// FILE: storage/InMemoryExpenseRepository.kt
package storage
import domain.model.Expense
import domain.service.ExpenseRepository
class InMemoryExpenseRepository : ExpenseRepository {
private val items = mutableListOf<Expense>()
override fun add(expense: Expense) {
items.add(expense)
}
override fun all(): List<Expense> = items
}
Зверніть увагу на деталь: items — private. Це не «дрібниця», а прямий захист від витоків відповідальності. Якби items був публічним, CLI міг би зробити repo.items.clear() і «чесно зламати все без попередження».
Нюанс: «read-only List» не означає «незмінюваність обʼєкта»
Тут є тонкий момент Kotlin: якщо ви повернули items як List, це обмежує того, хто викликає, на рівні типів (він не може викликати add()), але це все ще та сама колекція. Просто «вигляд» на неї — інший.
Іноді це нормально (і в навчальному проєкті v1 — нормально). Але інколи хочеться повертати «знімок» (toList()), щоб зовнішній код узагалі не міг вплинути на ваш стан навіть опосередковано.
Можна зробити так:
override fun all(): List<Expense> = items.toList()
Це безпечніше з погляду інкапсуляції, але дорожче за памʼяттю й часом. Для v1 ми частіше лишимо простий варіант, але важливо розуміти: вибір є.
3. Domain‑сервіс
Тепер зберімо логіку «що вважається коректною витратою» в одному місці. Сервіс не друкує, не читає введення, не зберігає дані напряму — він застосовує правила та звертається до репозиторію.
// FILE: domain/service/ExpenseService.kt
package domain.service
import domain.model.Expense
class ExpenseService(private val repo: ExpenseRepository) {
fun add(title: String, amount: Int) {
val normalizedTitle = title.trim()
require(normalizedTitle.isNotEmpty()) { "Назва не повинна бути порожньою" }
require(amount > 0) { "Сума повинна бути > 0" }
repo.add(Expense(normalizedTitle, amount))
}
fun all(): List<Expense> = repo.all()
}
Тут важливо побачити архітектурну «геометрію» залежностей:
- ExpenseService залежить від інтерфейсу ExpenseRepository,
- конкретну реалізацію (InMemoryExpenseRepository) підʼєднаємо ззовні — у CLI,
- сервіс не знає й не повинен знати, де і як це зберігається.
Це і є впровадження залежностей через конструктор (constructor injection) у найпростішому, «ручному» вигляді.
4. Exporter: окремий контракт для форматування звіту
Тепер друга межа — звіти. Зазвичай новачки роблять так: сервіс формує рядок, а потім ще й друкує його. Або CLI будує звіт вручну. Обидва варіанти створюють зайву звʼязність.
Логіка форматування — це окрема відповідальність: «перетворити дані на текст». Ми спеціально робимо так, щоб exporter повертав String, а не викликав println. Чому? Тому що друк — це CLI‑шар, а форматування — окрема роль, яку легко перевикористати.
Інтерфейс ReportExporter
// FILE: domain/service/ReportExporter.kt
package domain.service
import domain.model.Expense
interface ReportExporter {
fun export(expenses: List<Expense>): String
}
Інтерфейс знову вузький: один метод, одна задача. Він не знає про консоль, не знає про команди, не знає про репозиторій. Йому дали дані — він повернув текст.
Реалізація PlainTextExporter: простий текстовий звіт
// FILE: domain/service/PlainTextExporter.kt
package domain.service
import domain.model.Expense
class PlainTextExporter : ReportExporter {
override fun export(expenses: List<Expense>): String {
return expenses.joinToString(separator = "\n") { e ->
"${e.title}: ${e.amount}"
}
}
}
Цей exporter дає максимально прямолінійний формат. І це добре: коли ви будуєте архітектуру, краще почати з простого, щоб чітко побачити межі.
Ще одна реалізація: «табличка» з вирівнюванням
Іноді хочеться, щоб звіт виглядав акуратніше. Ми можемо зробити другий exporter, не змінюючи сервіс і не змінюючи репозиторій.
// FILE: domain/service/TableTextExporter.kt
package domain.service
import domain.model.Expense
class TableTextExporter : ReportExporter {
override fun export(expenses: List<Expense>): String {
return expenses.joinToString(separator = "\n") { e ->
val title = e.title.padEnd(20, ' ')
val amount = e.amount.toString().padStart(6, ' ')
"$title | $amount"
}
}
}
Важливий ефект: ви можете перемкнути формат звіту однією заміною реалізації в CLI (ми це зробимо нижче), не переписуючи решту системи.
5. Як усе звʼязується: CLI збирає залежності
Найчастіша помилка на цьому етапі — «притягнути все назад у main». Тому ми тримаємо main коротким: він створює репозиторій, сервіс, експортер і викликає операції.
// FILE: app/cli/Main.kt
package app.cli
import domain.service.ExpenseService
import domain.service.TableTextExporter
import storage.InMemoryExpenseRepository
fun main() {
val repo = InMemoryExpenseRepository()
val service = ExpenseService(repo)
val exporter = TableTextExporter()
service.add("Coffee", 200)
service.add("Taxi", 500)
println(exporter.export(service.all()))
// Coffee | 200
// Taxi | 500
}
Зверніть увагу: CLI викликав println, бо це його робота. Exporter повернув рядок. Сервіс застосував правила. Репозиторій зберіг. У кожного — своя роль, і ніхто не заходить на чужу територію.
6. Чому «вузький API» — це не занудство, а захист від майбутніх проблем
Зараз дуже хочеться додати в ExpenseRepository усе підряд: remove, update, findByTitle, clear, count, totalAmount і «ще ось цю штуку — згодиться». Але так інтерфейс перетворюється на «центр всесвіту», і будь-який шар починає залежати від надто великої кількості деталей.
Натомість тримаймо інтерфейси мінімальними, а складні операції виражаймо або як окремі методи сервісу (доменні правила), або як звіти (exporters).
Нижче — невелика таблиця, щоб закріпити інтуїцію.
| Запитання | Погана відповідь | Хороша відповідь |
|---|---|---|
| «Куди подіти println?» | «В exporter — він же про звіти» | «У CLI: exporter повертає String» |
| «Хто має прибирати пробіли з назви?» | «CLI, там же введення» | «Service: це правило предметної області» |
| «Чи можна повернути MutableList назовні?» | «Так, бо швидше» | «Ні, назовні — List, мутацію сховано» |
| «Чи треба all() робити toList()?» | «Завжди, бо так гарніше» | «Іноді: це “знімок”, але за нього треба платити» |
7. Схема залежностей: хто про кого має право знати
Щоб голова не перетворювалася на компілятор Kotlin (хоч для когось це й звучить як мрія), корисно тримати в уяві таку картинку:
flowchart TD
CLI[app.cli<br/>readln/println/команди]
SERVICE[domain.service<br/>правила та сценарії]
MODEL[domain.model<br/>типи даних]
REPO[domain.service.ExpenseRepository<br/>контракт]
EXPORT[domain.service.ReportExporter<br/>контракт]
STORAGE[storage<br/>InMemoryExpenseRepository]
CLI --> SERVICE
CLI --> EXPORT
SERVICE --> MODEL
SERVICE --> REPO
STORAGE --> REPO
EXPORT --> MODEL
Сенс такий: CLI може зібрати систему з деталей і викликати сценарії. Domain тримає правила й контракти. Storage реалізує контракт. Exporter реалізує контракт. Але domain не повинен «імпортувати CLI», інакше шари переплутаються.
8. Типові помилки
Помилка № 1: інтерфейс «про всяк випадок» стає величезним.
Зазвичай це виглядає так: ви написали InMemoryExpenseRepository, побачили, що там можна «за бажання» і видаляти, і очищати, і сортувати, і рахувати суми, — та додали все це в ExpenseRepository. Потім CLI починає використовувати половину методів, сервіс — іншу половину, і ви вже не можете змінити реалізацію репозиторію без переписування всього проєкту. Лікується це нудно, зате ефективно: в інтерфейсі лишаються тільки ті методи, без яких код, що викликає, справді не може обійтися.
Помилка № 2: репозиторій «протікає назовні» через MutableList.
Коли all() повертає MutableList, зовнішній код отримує право змінювати внутрішній стан репозиторію напряму. Це руйнує інваріанти сервісу: він може перевіряти amount > 0, а потім хтось у CLI додасть витрату із -100 напряму в список. Тому назовні віддаємо List, а всередині зберігаємо MutableList. У Kotlin це узгоджується із загальною моделлю колекцій лише для читання та mutable.
Помилка № 3: exporter друкує сам (println усередині export).
Якщо exporter друкує, він стає «напів CLI», а ще його неможливо нормально перевикористовувати: ви не можете отримати рядок звіту й, наприклад, показати його як частину іншого повідомлення. Дотримуйтеся правила: exporter повертає String, а друк — у CLI.
Помилка № 4: сервіс створює репозиторій усередині себе (val repo = InMemoryExpenseRepository()).
Так сервіс стає жорстко привʼязаним до конкретної реалізації зберігання. Ніби дрібниця, але це миттєво вбиває сенс інтерфейсу. Сервіс має залежати від контракту (ExpenseRepository) і отримувати реалізацію через конструктор. Тоді CLI може «підставити» іншу реалізацію без змін у доменному коді.
Помилка № 5: all(): List<Expense> = items сприймають як «повний захист від змін».
Read-only List справді обмежує доступ до операцій зміни, але це все ще може бути «вигляд» на ту саму колекцію. Іноді цього достатньо, іноді хочеться повертати копію (toList()), тобто «знімок» даних. Kotlin окремо підкреслює різницю між копіюванням колекції та створенням другого посилання на ту саму колекцію.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ