1. Вступ
Коли проєкт маленький, здається, що папки й пакети — це «для великих і страшних ентерпрайзів», а вам би просто дописати команду add і піти пити чай. Проблема в тому, що проєкт непомітно зростає: сьогодні 3 файли, завтра 12, а за тиждень ви шукаєте «де в нас розбирається команда» — і раптом знаходите 4 функції parseCommand, які відрізняються рівно одним пробілом.
Пакети в Kotlin — це спосіб назвати «райони міста» вашого проєкту: тут живе CLI, тут — предметна логіка, тут — зберігання. А коли ви дотримуєтеся правила «хто кого може імпортувати», структура починає працювати як огорожа на сходах: вона не заважає ходити, але дуже допомагає не полетіти вниз.
Kotlin технічно не вимагає, щоб папки на диску збігалися з назвою пакета: файли можуть лежати «як завгодно». Але людський мозок — не компілятор, і йому значно приємніше, коли domain/model/Expense.kt справді містить package domain.model. Тож на практиці майже завжди варто тримати структуру каталогів узгодженою з пакетами. Так простіше навігувати, імпорти стають очевиднішими, а випадкових помилок під час перенесення менше.
Можна думати про це так: компілятору байдуже, де лежить файл, а людині — ні. Ми пишемо код для людей (і трішки для компілятора, аби він не плакав).
2. Пригадуємо package і import
У Kotlin оголошення пакета пишеться на початку файла, а імпорти — одразу після нього. Це не просто традиція «бо так прийнято». Так файл зручніше читати зверху вниз: спершу адреса (пакет), потім залежності (імпорти), а вже потім код.
Водночас Kotlin технічно не вимагає, щоб папки на диску збігалися з назвою пакета: файли можуть лежати «як завгодно». Але для команди та IDE значно спокійніше, коли структура каталогів повторює структуру пакетів. Особливо це відчутно в JVM‑проєктах, де поруч іде Java.
Міні‑приклад: пакет як «адреса» файла
package domain.model
data class Expense(val title: String, val amount: Int)
Тут усе чесно: файл каже «я з району domain.model». Якщо ми хочемо використати Expense в іншому пакеті, доведеться додати імпорт.
3. Структура пакетів: фіксуємо відповідальність
Щоб пакети працювали як архітектурна підказка, їхні назви мають відображати відповідальність. Домовмося про просту й читабельну структуру, яка допомагає тримати межі:
| Шар (відповідальність) | Пакет | Що тут живе | Чого тут бути не повинно |
|---|---|---|---|
| CLI | |
читання команд, розбір рядка, друк, сценарій програми | бізнес‑правил і зберігання даних «аби як» |
| Domain (модель) | |
предметної області |
, , команди, форматування UI |
| Domain (правила/операції) | |
валідація, нормалізація, операції над моделями | прямої роботи з консоллю |
| Storage | |
зберігання та видача даних (у памʼяті) | розбору команд і виводу в консоль |
Це розкладання навмисно дуже просте. Нам важливо, щоб з одного погляду на пакет було зрозуміло: «це частина сценарію» чи «це частина предметної логіки».
4. Напрям залежностей: хто кого імпортує
Ось тут починається справжня архітектура — навіть якщо проєкт поки маленький. Напрям залежностей — це правило «хто від кого залежить». У коді це видно буквально: де ми пишемо import і на які пакети посилаємося.
Ми хочемо, щоб зовнішній шар (CLI) залежав від внутрішнього (domain), а не навпаки. Тоді domain можна повторно використати будь-де: у тестах, в іншому інтерфейсі, в іншому способі введення команд. Навіть якщо ви поки не робите нічого такого, сама наявність такої можливості робить код спокійнішим.
Схематично (мапа дозволених імпортів) це виглядає так:
flowchart TD
CLI[app.cli] --> DS[domain.service]
CLI --> DM[domain.model]
CLI --> ST[storage]
DS --> DM
ST --> DM
%% важливе обмеження:
%% domain НЕ залежить від app.cli
Якщо спростити до одного правила: domain.* не імпортує app.cli.*. Ніколи. Навіть «лише одну маленьку функцію printHelp()». Бо це не «маленька функція», а дірка в межі.
Чому це важливо в реальному проєкті
Якщо domain почне імпортувати CLI, у вас зʼявиться «кільцева залежність за змістом». Ви вже не зможете чітко зрозуміти, де закінчуються правила предметної області й починається інтерфейс. У якийсь момент будь-яке виправлення вимагатиме змін у багатьох місцях одразу — і проєкт стане крихким.
5. Практичний приклад: розкладаємо код за пакетами
Зараз ми візьмемо наш навчальний проєкт (умовний трекер витрат) і розкладемо його за пакетами так, щоб структура відображала архітектуру. Важливо: ми не робимо «проєкт мрії на 200 файлів». Ми робимо маленький проєкт, але з правильною «географією».
Уявімо таку структуру в src/main/kotlin:
src/main/kotlin
├── app
│ └── cli
│ ├── Main.kt
│ └── ParsedCommand.kt
├── domain
│ ├── model
│ │ └── Expense.kt
│ └── service
│ └── ExpenseService.kt
└── storage
└── InMemoryExpenseStorage.kt
Зверніть увагу: Kotlin не змушує нас організовувати код саме так. Але угоди та практики організації вихідних кодів дуже це рекомендують — особливо якщо ви хочете, щоб структура каталогів відповідала пакетам.
domain.model: доменна модель
Почнімо з най«чистішого» — моделі.
package domain.model
data class Expense(val title: String, val amount: Int)
Тут немає ні консолі, ні команд, ні «як гарно вивести». Лише дані.
domain.service: правила й операції
Тепер сервіс: він створює коректний Expense із сирих параметрів. Зверніть увагу: сервіс не зберігає список і не друкує. Він реалізує правила.
package domain.service
import domain.model.Expense
class ExpenseService {
fun createExpense(title: String, amount: Int): Expense {
val normalizedTitle = title.trim()
require(normalizedTitle.isNotEmpty()) { "Назва не може бути порожньою" }
require(amount > 0) { "Сума має бути > 0" }
return Expense(title = normalizedTitle, amount = amount)
}
}
Ми імпортуємо модель із domain.model — це нормальна залежність «сервіс → модель».
storage: зберігання в памʼяті
Сховище в памʼяті — це наш «мінімальний storage‑шар». Воно вміє зберегти витрату й повернути всі витрати. При цьому воно нічого не знає про команди та консоль — лише про модель.
package storage
import domain.model.Expense
class InMemoryExpenseStorage {
private val items = mutableListOf<Expense>()
fun add(expense: Expense) {
items.add(expense)
}
fun all(): List<Expense> = items
}
Тут важливий момент: назовні ми повертаємо List<Expense>, а не MutableList<Expense>. Так сховище не перетвориться на «прохідний двір», де будь-хто може змінювати дані як завгодно.
app.cli: команди й розбір рядка
CLI‑шар — це місце, де ми працюємо з рядками, пробілами та людськими помилками на кшталт «я ввів add каву двісті». Доменні класи не повинні цим займатися.
Зробімо невеликий тип ParsedCommand, щоб main не возився з «магічними індексами».
package app.cli
data class ParsedCommand(val name: String, val args: List<String>)
І функцію розбору (поки максимально просту, без претензії на ідеальність):
package app.cli
fun parseCommandLine(line: String): ParsedCommand {
val parts = line.trim().split(" ").filter { it.isNotEmpty() }
val name = parts.firstOrNull()?.lowercase() ?: ""
val args = if (parts.size > 1) parts.drop(1) else emptyList()
return ParsedCommand(name = name, args = args)
}
Тут усе максимально «CLI‑шне»: trim, split, lowercase.
app.cli.Main: збирання застосунку й сценарій
І тепер main. Він імпортує все, що потрібно, і «склеює» сценарій: прочитав рядок → розібрав → застосував правила → зберіг → показав результат.
package app.cli
import domain.service.ExpenseService
import storage.InMemoryExpenseStorage
fun main() {
val service = ExpenseService()
val storage = InMemoryExpenseStorage()
print("Команда: ") // Команда:
val cmd = parseCommandLine(readln())
when (cmd.name) {
"add" -> {
val title = cmd.args.getOrNull(0) ?: ""
val amount = cmd.args.getOrNull(1)?.toIntOrNull() ?: 0
val expense = service.createExpense(title, amount)
storage.add(expense)
println("Додано: $expense") // Додано: Expense(title=..., amount=...)
}
"list" -> println(storage.all())
else -> println("Невідома команда: ${cmd.name}")
}
}
З погляду залежностей усе красиво: CLI залежить від domain і storage. Domain не залежить від CLI. Storage теж не залежить від CLI. Отже, якщо ви завтра захочете інший інтерфейс (наприклад, не консольний), у вас не буде «потяга з println».
6. Імпорти як індикатор проблем і пастка utils
Є корисний прийом: інколи достатньо відкрити файл і подивитися на його import, щоб зрозуміти, чи «попливла» архітектура. Якщо ви бачите, що файл із domain.service імпортує щось із app.cli, це майже завжди сигнал: «ми запхали UI‑деталь у предметну логіку».
Є ще один нюанс: правила розвʼязання імен у Kotlin враховують імпорти за пріоритетами. Наприклад, явний імпорт зазвичай має вищий пріоритет, ніж зірочковий (*) або «знайшлося в тому самому пакеті». Це не означає, що нам потрібно жити в страху перед імпортами. Це означає, що імпорти — частина дизайну проєкту, а не «сміття зверху».
Дуже швидко в проєкті зʼявляються «зручні функції»: formatMoney, readInt, normalizeTitle, parseSomething. І рука тягнеться зробити пакет utils та складати все туди. Це виглядає як прибирання, але часто перетворюється на «коробку з дротами»: туди кидають усе, що поки незрозуміло, куди саме це має потрапити.
Проблема не в назві utils, а у відсутності відповідальності. Коли утиліта не привʼязана до шару, вона починає тягнути залежності в усі боки. Наприклад, utils.formatExpense() раптом починає імпортувати domain.model.Expense, а потім туди ж додають println «для налагодження» — і утиліта вже не утиліта, а маленький портал хаосу.
Набагато спокійніше, коли допоміжні функції живуть поруч зі своїм шаром. Розбір команд — у app.cli, правила нормалізації — у domain.service, робота зі списком — у сховищі, тобто в storage. Пакет — це не смітник, а адреса.
7. Типові помилки
Помилка № 1: доменний код починає залежати від CLI через «невинний імпорт».
Зазвичай це починається з фрази «та я просто хочу гарно надрукувати повідомлення про помилку». І зʼявляється import app.cli.printError. Після цього domain уже не можна використати окремо від консолі, і межа відповідальності зникає. Краще нехай domain повертає зрозумілу помилку (або кидає require), а CLI вирішує, як це показати користувачу.
Помилка № 2: неузгодженість папок і package, бо «Kotlin же дозволяє».
Так, дозволяє: файл може лежати будь-де. Але рекомендації з організації вихідних кодів прямо кажуть, що структура каталогів має відповідати структурі пакетів, особливо на JVM. Інакше навігація в IDE стає дивною, перенесення файлів — болісним, а проєкт виглядає як лабіринт без мапи.
Помилка № 3: сховище повертає назовні MutableList, і будь-який код може змінювати дані.
Ззовні це здається зручним: «ну я ж обережно». Але за тиждень ви забудете, де саме ви були «обережні», і отримаєте несподівані зміни стану. Повертайте List, а мутацію тримайте всередині шару зберігання — так простіше контролювати, хто і де змінює дані.
Помилка № 4: зірочкові імпорти (import something.*) починають жити своїм життям.
*‑імпорт інколи здається зручним, але у великих проєктах він погіршує читабельність і може призводити до несподіваних конфліктів імен. До того ж у Kotlin під час розвʼязання імен є пріоритети: явні імпорти «перемагають» те, що прийшло з *. Це ще один привід не перетворювати імпорти на лотерею.
Помилка № 5: пакет utils перетворюється на «звалище всього підряд».
Поки проєкт маленький, це відчувається як порядок. Але щойно туди потрапляють функції з різних шарів, пакет починає тягнути залежності в усі боки й руйнує межі відповідальності. Краще тримати «утиліти» поруч із їхнім шаром і задачею — тоді імпортами буде простіше керувати, а структура стане самопояснювальною.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ