1. Вступ
Майже всі починають однаково: беруть main, роблять усередині readln(), додають кілька if, список витрат і кілька команд (add, list, remove) — і радіють життю. Це нормальний етап: ви навчаєтеся, хочете швидко побачити результат, а println() дарує приємний дофамін.
Але далі відбувається типова історія: команд стає більше, перевірок — теж. Вивід «трошки підкрутимо» — і main перетворюється на «командний центр космольота», де одночасно і кермування, і двигун, і кухня, і кіт.
Зобразімо типовий підхід «усе в одному місці» (це не «погана людина написала», це «ми так ростемо»):
fun main() {
val expenses = mutableListOf<String>()
while (true) {
print("> ")
val line = readln().trim()
if (line == "exit") break
if (line.startsWith("add ")) {
val title = line.removePrefix("add ").trim()
expenses.add(title)
println("Додано: $title") // Додано: Coffee
} else if (line == "list") {
println(expenses.joinToString())
} else {
println("Невідома команда")
}
}
}
Спочатку здається: «ну ок, читається». А потім ви захочете, наприклад, щоб у витрати була сума, щоб сума була додатною, щоб пробіли в назві прибиралися однаково, щоб вивід був охайнішим, щоб можна було порахувати підсумок. І раптом зʼясовується, що в нас:
- введення змішане з правилами (валідацією/нормалізацією),
- правила змішані зі зберіганням (ми напряму ліземо в MutableList),
- виведення змішане з усім іншим,
- і будь-яку дрібницю страшно змінювати: раптом зламаємо команду add, а разом із нею — list, бо ми там «трошки перевикористали змінну».
Головна думка: коли все в main, кожна нова можливість додає хаосу не лінійно, а лавиноподібно.
2. Три ролі: CLI, domain, storage
Зараз ми введемо три поняття. Вони звучать «по-дорослому», але означають дуже просту річ: хто за що відповідає. Уявіть мінікафе:
- одна людина приймає замовлення у клієнта,
- друга готує,
- третя веде склад/облік інгредієнтів.
Якщо одна людина робить усе одразу, то вона або герой, або в неї скоро закінчиться здоровʼя (радше друге). У програмі приблизно так само: нам потрібен розподіл праці.
Ролі в нашому консольному проєкті такі:
| Роль | Що робить | Чого робити не повинна |
|---|---|---|
| CLI (Command Line Interface) | Читає рядок, розуміє команду, друкує відповідь | Не зберігає бізнес-правила й не вирішує по суті, «правильно чи неправильно» |
| Domain (предметна логіка) | Знає правила: що таке витрата, які перевірки потрібні, як порахувати підсумок | Не викликає readln() і не робить println() |
| Storage (зберігання) | Зберігає й віддає дані (у v1 — просто в памʼяті) | Не розбирає команди й не застосовує бізнес-правила |
Зручно тримати в голові просту схему потоку даних:
flowchart LR
U[Користувач] --> CLI[CLI: прочитав/розпарсив]
CLI --> D[Domain: застосував правила]
D --> S[Storage: зберіг/видав дані]
D --> CLI
CLI --> U
І ключове правило дня: domain-код має викликатися без консолі. Тобто теоретично ми могли б завтра зробити не консоль, а, наприклад, GUI — і domain майже не змінився б. (GUI ми робити не будемо, але думка корисна.)
3. Практичний приклад: облік витрат
Доменна модель: «чисті дані»
Перед тим як ділити обовʼязки, нам потрібно домовитися, що ми взагалі зберігаємо. Раніше ми могли зберігати витрату як рядок або як Pair<String, Int>, або ж у стилі «ну там у рядку через ;». Сьогодні зробимо крок до нормальної моделі: витрата — це обʼєкт із зрозумілими полями.
Важливо: доменна модель — це не «логіка», це «дані предметної області». Вона має бути максимально простою й «чистою»: без читання введення, без друку, без форматування.
data class Expense(
val title: String,
val amount: Int
)
Тут є маленький, але принциповий момент: коли ви бачите Expense(title, amount), вам уже не потрібно згадувати «а що там у .first, а що в .second». Ви читаєте як людина.
Якщо вам хочеться додати category, createdAt і все таке — тримаймося. Сьогодні ми тренуємо розподіл обовʼязків, а не робимо «ідеальну бухгалтерію».
Storage: сховище в памʼяті
Коли люди чують слово «сховище», вони часто уявляють базу даних, міграції, транзакції й легку паніку. Сьогодні все простіше: шар storage у версії v1 — це клас, який ховає всередині список і дає методи «додати» та «отримати все».
Навіщо нам окремий клас, якщо можна просто зробити mutableListOf() у main? Тому що ми хочемо, щоб деталь «як саме зберігаємо» була окремою, а не розмазувалася по всій програмі.
Зверніть увагу на важливу річ: назовні ми віддаємо List<Expense>, а не MutableList<Expense>. Це звичка, яка рятує нерви: хто отримав List, той не зможе «тихо» видалити елемент і залишити вас із загадковим багом. У Kotlin це напряму відповідає ідеї read-only інтерфейсів колекцій.
class ExpenseStorage {
private val items = mutableListOf<Expense>()
fun add(expense: Expense) {
items.add(expense)
}
fun all(): List<Expense> = items
}
Зверніть увагу: тут немає ні readln(), ні println(). Сховище — мовчазне, як хороший холодильник: воно не коментує ваших рішень.
Domain: сервіс із правилами, але без друку
Шар domain — це місце, де живуть «правила гри». Якщо CLI — це «як поговорити з користувачем», то domain — це «що вважається коректною витратою». Саме тут доречні require, перевірки й нормалізація рядків.
Зараз ми зробимо ExpenseService, який приймає готові значення (title, amount), приводить їх до нормального вигляду й зберігає через storage.
class ExpenseService(
private val storage: ExpenseStorage
) {
fun addExpense(title: String, amount: Int) {
val normalizedTitle = title.trim()
require(normalizedTitle.isNotEmpty()) { "Назва не повинна бути порожньою" }
require(amount > 0) { "Сума має бути > 0" }
storage.add(Expense(normalizedTitle, amount))
}
fun listExpenses(): List<Expense> = storage.all()
}
Тут є дві важливі ідеї.
Перша: domain приймає уже готові значення, а не «сирий рядок команди». Тобто ExpenseService не повинен розбирати "add coffee 200". Це робота CLI.
Друга: domain не друкує «Added!» і не питає користувача «введіть суму». Він або виконує операцію, або повідомляє про помилку через виняток (сьогодні так; пізніше ми робитимемо більш «керовані» результати, але це вже наступна частина).
CLI: читаємо рядок, парсимо команду, викликаємо domain
Шар CLI — це «перекладач» між людиною і програмою. Людина пише рядки, а domain хоче нормальні параметри. Отже, CLI має зробити три кроки: прочитати, розібрати, викликати.
readln() і println() живуть саме тут — це нормально й очікувано.
Зробимо маленьку модель команди й найпростіший парсер. Зверніть увагу: код невеликий і «в лоб», без магії — нам важлива читабельність.
data class ParsedCommand(
val name: String,
val args: String
)
fun parseCommand(line: String): ParsedCommand {
val trimmed = line.trim()
val space = trimmed.indexOf(' ')
return if (space == -1) {
ParsedCommand(name = trimmed.lowercase(), args = "")
} else {
val name = trimmed.substring(0, space).lowercase()
val args = trimmed.substring(space + 1).trim()
ParsedCommand(name = name, args = args)
}
}
Тепер CLI-логіка: ми читаємо рядок, розбираємо його, робимо when за назвою команди, а далі викликаємо сервіс.
Поки що зробімо три команди:
- add <title>;<amount>
- list
- exit
Чому через ;? Тому що в назві можуть бути пробіли, а нам потрібно просто відокремити суму. Це не «ідеальний формат», а зручний варіант для навчального прикладу.
fun main() {
val storage = ExpenseStorage()
val service = ExpenseService(storage)
while (true) {
print("> ")
val cmd = parseCommand(readln())
if (cmd.name == "exit") break
when (cmd.name) {
"add" -> handleAdd(cmd.args, service)
"list" -> handleList(service)
else -> println("Невідома команда: ${cmd.name}")
}
}
}
Тут main уже більше схожий на сценарій: «прочитав → розібрав → передав далі». І це дуже хороший знак.
Залишилося реалізувати handleAdd і handleList. Зверніть увагу: це все ще шар CLI, тож друкувати тут можна.
fun handleAdd(args: String, service: ExpenseService) {
val parts = args.split(';', limit = 2)
val title = parts.getOrNull(0)?.trim().orEmpty()
val amount = parts.getOrNull(1)?.trim()?.toIntOrNull() ?: 0
try {
service.addExpense(title, amount)
println("OK: додано") // OK: додано
} catch (e: IllegalArgumentException) {
println("ПОМИЛКА: ${e.message}") // ПОМИЛКА: Сума має бути > 0
}
}
Ми спеціально ловимо IllegalArgumentException, тому що require(...) кидає саме її. Це «мʼяка» обробка: програма не падає, а пояснює користувачеві, що не так.
handleList:
fun handleList(service: ExpenseService) {
val items = service.listExpenses()
if (items.isEmpty()) {
println("(порожньо)") // (порожньо)
return
}
for ((index, e) in items.withIndex()) {
println("${index + 1}. ${e.title} — ${e.amount}")
}
}
Форматування списку — це завдання CLI. Domain має повернути дані, а CLI вирішує, як їх гарно показати.
Де проходить межа між шарами
Коли ви починаєте ділити код на ролі, виникає тонка спокуса: «а можна я трошки тут порушу — це ж усього один println()?». Можна. Рівно до того моменту, поки не стане десять «усього один println».
Щоб межа не розповзалася, корисно тримати в голові кілька «антисигналів».
- Якщо ви бачите readln() усередині сервісу — це майже завжди помилка дизайну. readln() — це про консоль, а консоль — це CLI. Domain має вміти працювати без людини поруч.
- Якщо ви бачите println() усередині storage — це теж сигнал: сховище не повинно «коментувати життя». Воно має зберігати й віддавати.
- Якщо ви віддаєте назовні MutableList, то будь-який код ззовні може викликати clear(), і потім ви шукатимете, «хто зʼїв мої дані». Kotlin-колекції чітко розрізняють read-only і mutable інтерфейси, тож це розрізнення варто використовувати як захисний паркан.
Іноді здається, що «різниці немає, усе одно я один пишу код». Є. Різниця — це ви через два тижні, який відкрив цей проєкт, а в голові вже інше життя, інші задачі й нуль бажання розгадувати ребуси з main.
4. Типові помилки під час поділу на CLI / domain / storage
Перед тим як завершити, давайте пройдемося по граблях, на які наступають майже всі (я теж наступав, не хвилюйтеся — це традиція; як і традиція програмістів створювати ще один «останній» файл final_final2.kt).
Помилка № 1: domain починає друкувати в консоль.
Зазвичай це виглядає невинно: «ну я ж хочу вивести println("Added!") прямо в addExpense». Але так domain стає залежним від консолі. А далі ви захочете інший вивід (наприклад, звіт одним рядком або взагалі без виведення) — і доведеться виколупувати println із логіки. Правильна думка проста: domain або повертає дані, або повідомляє про помилку, а друк — це відповідальність CLI.
Помилка № 2: domain читає введення (readln()) «заради зручності».
Це спокусливо: «нехай сервіс сам запитає суму». Але тоді сервіс неможливо використати без інтерактивної консолі (наприклад, в іншому інтерфейсі). І стає незрозуміло, де взагалі керування сценарієм. readln() — це суто CLI-інструмент, і його місце там.
Помилка № 3: storage стирчить назовні як public val items: MutableList<Expense>.
Так часто роблять «для простоти», а потім якийсь код у CLI випадково викликає items.removeAt(0) — і логіка ламається. Сховище має ховати змінюваність усередині себе, а назовні віддавати List. Це підтримує ідею read-only інтерфейсів колекцій і захищає від випадкової мутації.
Помилка № 4: CLI починає застосовувати бізнес-правила.
Наприклад, ви в handleAdd вирішуєте, що «сума має бути > 0», і перевіряєте це в CLI, а в domain забуваєте. Потім зʼявиться інший шлях додавання (інша команда, імпорт — що завгодно), і частина правил опиниться лише в одному місці. Бізнес-правила мають жити в domain, а CLI має бути «перекладачем», а не «суддею».
Помилка № 5: змішування нормалізації та відображення.
Дуже часта штука: рядок title обрізають (trim()) лише під час друку в println, бо «так красивіше». Але правильніше нормалізувати дані на вході в domain (як ми зробили), щоб усередині програми вони завжди були в одному форматі. Тоді вивід — це просто вивід, без спроб «підлатати» сенс.
Помилка № 6: надто рання спроба зробити «ідеальну архітектуру».
Є й зворотна крайність: людина прочитала слово «архітектура» і вирішила терміново зробити 25 класів, 12 інтерфейсів і діаграму, якої боїться сама. Сьогодні ціль скромніша: відокремити введення/виведення від правил і від зберігання. Якщо ви можете пояснити, де CLI, де domain, а де storage, — ви вже молодець, і код стане помітно спокійнішим.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ