1. Вступ
Коли проєкт зростає, найчастіше ламає не новий код, а «новий код, який випадково зачепив старий». У невеликому навчальному застосунку це теж швидко дається взнаки: ви додаєте нову команду — і раптом старі команди починають друкувати «якось інакше». А помилка виводиться то рядком, то null, то println(repo.all()) (так, це той самий стиль «нехай Kotlin сам пояснить користувачеві, що сталося»).
Фіксація v1 — це момент, коли ви кажете: «у нас є стабільні правила гри». Не «все ідеально» і не «архітектура рівня NASA». Просто: команди повертають результат одного типу, формат відповіді будується в одному місці, а main стає передбачуваним сценарієм збирання та виклику. Це різко зменшує хаос: додавати команди простіше, читати код простіше, і ви менше боїтеся торкатися файлів, бо зміни не тягнуть за собою пів проєкту.
Якщо зовсім приземлено: v1 — це коли ваш проєкт уже не нагадує «коробку з дротами», а починає нагадувати «коробку з розʼємами». Дротів може бути багато, але підʼєднуються вони в чітко визначених місцях.
Проблема «команди повертають що завгодно»
Перш ніж запроваджувати єдиний контракт, корисно відчути цей біль наживо. Зазвичай новачки (а часом і цілком дорослі розробники після важкого дня) роблять так: одна команда повертає рядок, інша — Boolean, третя — друкує сама, четверта — кидає виняток, пʼята — повертає null, бо «ну не вийшло ж».
У результаті CLI починає нагадувати зоопарк перевірок: тут if (result != null), там try/catch, тут println усередині сервісу, а там — усередині репозиторію (це вже справді рідкісний вид, але таке трапляється). І найнеприємніше: з першого погляду не зрозуміло, що взагалі робить команда. Доводиться тримати в голові: «а ця команда друкує сама чи повертає рядок?».
Нам потрібен один спільний «конверт» для результату команди. У нього можна покласти або успіх, або помилку. Тоді CLI завжди робить одне й те саме: викликає обробник, отримує результат і друкує його. Жодних спеціальних гілок для кожної команди — окрім самої маршрутизації.
2. Єдиний результат команди: sealed interface CommandResult
Коли ви вперше бачите sealed, воно може здаватися чимось «просунутим». Але сьогодні ми використаємо його дуже практично: як список допустимих варіантів результату. Ідея проста: команда або успішна (Ok), або ні (Error). Усе. Жодних «майже вийшло», «повернулося 0», «ну воно ніби true, але…».
Створімо тип результату. Логічно тримати його ближче до межі CLI (бо це результат обробки команди), наприклад у пакеті app.cli.contract. Важливо: це не доменна модель, а саме «результат команди для інтерфейсу».
// FILE: app/cli/contract/CommandResult.kt
package app.cli.contract
sealed interface CommandResult {
data class Ok(val message: String) : CommandResult
data class Error(val message: String) : CommandResult
}
Зверніть увагу на деталь: і Ok, і Error містять message. Для v1 це дуже корисна дисципліна: кожен результат має пояснення, зрозуміле людині. У майбутніх версіях усе можна ускладнити (наприклад, додати дані), але у v1 нам важливо досягти стабільності й простоти.
3. Централізоване форматування
Якщо ви бодай раз змінювали текст повідомлень у проєкті, ви знаєте цей квест: «знайдіть усі місця, де друкується ERROR». Зазвичай пошук спрацьовує, але потім ви знаходите ще одне місце, де друкувалося println("Err: ..."). І ще одне. І ще…
У v1 робимо так: форматування результату живе в одному місці. CLI викликає render() і друкує рядок. Формат можна змінювати централізовано — і всі команди автоматично почнуть виглядати однаково.
// FILE: app/cli/contract/CommandResultRender.kt
package app.cli.contract
fun CommandResult.render(): String =
when (this) {
is CommandResult.Ok -> "OK: $message"
is CommandResult.Error -> "ERROR: $message"
}
Тут важлива вичерпність when. Ми не пишемо else. Чому? Тому що sealed interface гарантує: варіантів скінченна кількість. Якщо ви потім додасте новий варіант (наприклад, Help або Exit), компілятор скаже: «гей, ти забув обробити ще один випадок». Це ідеальний «охоронець» для змін: він не дасть вам залишити систему неповною.
4. Єдиний контракт обробки команди: handle(...) -> CommandResult
Тепер нам потрібен обʼєкт (або функція), який приймає розпарсену команду й повертає CommandResult. Це і є «публічна точка» v1 для CLI: ви передаєте рядок у парсер, парсер віддає структуру в handler, а handler повертає результат.
Спочатку зафіксуймо модель розпарсеної команди. Ми не робимо складний парсер — нам достатньо назви команди й аргументу-рядка.
// FILE: app/cli/parse/ParsedCommand.kt
package app.cli.parse
data class ParsedCommand(
val name: String,
val arg: String?
)
Тепер — проста функція парсингу. Тут ми не намагаємося зробити «ідеальний CLI». Натомість фіксуємо стабільну домовленість: команда — це слово, аргумент — усе інше.
// FILE: app/cli/parse/CommandLineParser.kt
package app.cli.parse
fun parseCommandLine(line: String): ParsedCommand {
val s = line.trim()
val space = s.indexOf(' ')
return if (space == -1) ParsedCommand(s.lowercase(), null)
else ParsedCommand(s.substring(0, space).lowercase(), s.substring(space + 1).trim().ifEmpty { null })
}
Так, рядок довгуватий, але логіка проста: нормалізуємо імʼя команди (lowercase()), аргумент відтинаємо і перетворюємо порожній рядок на null. Далі це дуже зручно: arg == null означає «аргументу немає».
І тепер — обробник команд. Він використовує доменний сервіс та експортер (які ми вже зробили в попередніх лекціях), але назовні віддає лише CommandResult.
// FILE: app/cli/CommandHandler.kt
package app.cli
import app.cli.contract.CommandResult
import app.cli.parse.ParsedCommand
import domain.service.ExpenseService
import domain.service.ReportExporter
class CommandHandler(
private val service: ExpenseService,
private val exporter: ReportExporter
) {
fun handle(cmd: ParsedCommand): CommandResult =
when (cmd.name) {
"list" -> CommandResult.Ok(service.report(exporter))
else -> CommandResult.Error("Невідома команда: ${cmd.name}")
}
}
Ми поки реалізували лише "list", щоб приклад лишався невеликим. Але найважливіше тут — форма: будь-який шлях повертає CommandResult. Навіть невідома команда — це не виняток, не null і не друк прямо тут, а нормальний Error(...).
5. Як виглядають шари разом: «збирання» залежностей у main
У main ми не хочемо бізнес-логіки. Нам потрібен сценарій: зібрати залежності → прочитати команду → розпарсити → обробити → відрендерити → вивести. Це той випадок, коли «проста програма» стає ознакою порядку, а не примітивності.
Покажемо мінімальний main, який обробляє одну команду. У реальному проєкті ви, імовірно, уже робили цикл. Але зараз нам важливо побачити структуру v1 цілком.
// FILE: app/cli/Main.kt
package app.cli
import app.cli.contract.render
import app.cli.parse.parseCommandLine
import storage.InMemoryExpenseRepository
import domain.service.ExpenseService
import domain.service.PlainTextExporter
fun main() {
val handler = CommandHandler(ExpenseService(InMemoryExpenseRepository()), PlainTextExporter())
val result = handler.handle(parseCommandLine(readln()))
println(result.render()) // OK: ... або ERROR: ...
}
Так, рядок збирання handler виглядає щільним. Для v1 це нормально. Ми навмисно не запроваджуємо контейнери залежностей і «магічне збирання». Важливе інше: main нічого не знає про правила витрат і формати звіту. Він просто зʼєднує деталі, як конструктор LEGO (інколи болісно наступаючи на кубики).
Якщо ви хочете додати цикл, він теж залишиться «тонким»:
// FILE: app/cli/MainLoop.kt
package app.cli
import app.cli.contract.render
import app.cli.parse.parseCommandLine
fun runLoop(handler: CommandHandler) {
while (true) {
val result = handler.handle(parseCommandLine(readln()))
println(result.render()) // єдиний формат відповіді
}
}
Тут поки немає команди "exit" — це нормально. Ми фіксуємо контракт результату, а не будуємо ідеальний термінал.
6. Чому Ok/Error — це стабільне API
На цьому етапі корисно зробити невелику «архітектурну паузу». Здається, що ми написали лише два невеликі data-класи та одну extension-функцію. Але за ефектом це схоже на ситуацію, коли ви в квартирі нарешті підписали автомати в щитку: електрика була й раніше, але тепер ви не вимикаєте випадково холодильник, коли хотіли вимкнути світло в коридорі.
Ось що стабілізує Ok/Error:
З погляду коду, що викликає (CLI), більше немає різноманіття результатів. Не потрібно памʼятати, «що повернула команда». Ви завжди отримуєте один тип і завжди рендерите його одним способом.
З погляду розвитку проєкту нові команди додаються передбачувано. Ви не розмазуєте повідомлення по main і не плодите println у несподіваних місцях. Якщо ви додасте команду "add", вона теж поверне Ok/Error.
З погляду компілятора у вас зʼявляється «страховка повноти». sealed + when без else — це ваш безплатний статичний аудит: компілятор змушує обробити всі варіанти.
Щоб закріпити цю ідею, зручно бодай раз побачити схему пайплайна:
flowchart LR
A[readln] --> B[parseCommandLine]
B --> C[CommandHandler.handle]
C --> D[CommandResult]
D --> E[render]
E --> F[println]
Це добра ознака того, що «v1 зібрано»: кроки зрозумілі, і кожен крок відповідає за своє.
А тепер — важливий момент про «критерії якості v1». Це не список заради списку, а спосіб швидко перевірити, що структура витримана, коли ви втомилися й хочете «просто дописати фічу». Якщо у вас доменний шар не використовує readln() і println(), storage схований за інтерфейсом, exporter повертає рядок, а main виглядає як сценарій, то ви справді зафіксували межі, а не просто рознесли файли по папках.
7. Маленький практичний нюанс
Іноді виникає спокуса зробити «розумніше»: команда "list" повертає рядок звіту, команда "add" повертає Boolean, команда "help" повертає список рядків… і так далі. Здається, що так «типобезпечніше» (кожна команда ж «унікальна»). Але на рівні CLI це перетворюється на потребу розрізняти команди ще раз: «якщо це list — друкуй так, якщо add — інакше».
Єдиний результат — це компроміс заради стійкості. Ми домовляємося так: на рівні CLI нам важливо одне — вийшло чи ні, і яке повідомлення показати. Усередині домену ви можете повертати будь-які структури. Але на межі команди ми зводимо все до єдиного вигляду.
У v1 це особливо корисно, бо ви ще не будуєте складні інтерфейси. У вас консоль, а консоль любить прості правила: одна команда — один рядок відповіді, і бажано без сюрпризів.
Для наочного порівняння можна тримати в голові таку таблицю:
| Підхід | Що повертають команди | Що відбувається в CLI |
|---|---|---|
| «Кожна команда як хоче» | String, Boolean, null, друк усередині | багато спеціальних випадків |
| Єдиний контракт v1 | CommandResult | один сценарій обробки |
І так, якщо вам здається, що це «занадто просто», вітаю: ви почали цінувати простоту як інженерний інструмент. Це небезпечний шлях. Далі ви почнете нормально називати змінні й перестанете писати x1, x2, x3. Зворотної дороги майже немає.
8. Типові помилки під час фіксації v1 та використання Ok/Error
Помилка № 1: команди все одно друкують усередині себе, а CommandResult зʼявляється «для галочки».
Так часто буває: ви додали CommandResult, але в ExpenseService усе ще живе println("Added!"). У підсумку повідомлення друкується двічі, або друкується в несподіваному місці, або формат «OK/ERROR» не дотримується. Лікується це просто й трохи боляче: прибираєте println із домену й storage та змушуєте себе повертати дані/результат назовні. CLI друкує, інші шари — ні.
Помилка № 2: when по sealed пишуть з else, «щоб компілювалося завжди».
else виглядає як подушка безпеки, але насправді це вимикач сигналізації. Поки все добре — ви цього не помітите. Але коли додасте новий варіант результату, компілятор уже не допоможе знайти місця, де ви забули його обробити. У v1 краще дисциплінувати себе: якщо sealed, то обробляємо варіанти явно, без else.
Помилка № 3: CommandResult починають використовувати як «мішок для всього», і туди потрапляє половина доменної моделі.
Іноді хочеться покласти в Ok не рядок, а список витрат, суму, статистику… — і раптом CommandResult перетворюється на новий шар доменної моделі, тільки з іншою назвою. У v1 тримайте контракт вузьким: message: String. Якщо дуже хочеться даних — краще нехай домен повертає дані, exporter робить рядок, а команда повертає цей рядок у Ok.
Помилка № 4: обробник команд починає напряму лізти в репозиторій або зберігає в собі стан, який має жити в домені.
Якщо CommandHandler раптом починає робити repo.items.add(...) або сам валідує суми, значить межа «попливла»: CLI-команда стала «напівдоменом». У v1 краще терпляче тримати ролі: handler маршрутизує і викликає сервіс, сервіс застосовує правила, репозиторій зберігає, exporter форматує.
Помилка № 5: різні команди використовують різні стилі повідомлень, бо форматують рядки «десь по дорозі».
Сьогодні ви написали "OK: ..." в одному місці, завтра — "Success: ..." в іншому. Користувач бачить різнобій, а ви витрачаєте час на косметику замість логіки. У v1 це вирішується централізованим render() і звичкою: усе, що друкується користувачеві, проходить через одну точку форматування.
Помилка № 6: main перестає бути сценарієм і знову обростає логікою — тільки тепер «у красивих пакетах».
Це хитрий варіант регресу: файли розклали, інтерфейси ввели, а потім у main зʼявилося 60 рядків умов і обробок. Ознака нормального v1: main читається зверху вниз як рецепт. Якщо рецепт перетворюється на роман — значить, час повертати відповідальність назад у handler/service/exporter.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ