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("Added: $title") // Added: Coffee
} else if (line == "list") {
println(expenses.joinToString())
} else {
println("Unknown command")
}
}
}
На первых порах кажется: “ну ок, читается”. А потом вы захотите, например, чтобы у расхода была сумма, чтобы сумма была положительной, чтобы пробелы в названии чистились одинаково, чтобы вывод был красивее, чтобы можно было считать итог. И внезапно выясняется, что у нас:
- ввод смешан с правилами (валидация/нормализация),
- правила смешаны с хранением (мы напрямую лезем в 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()) { "Title must not be empty" }
require(amount > 0) { "Amount must be > 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("Unknown command: ${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: added") // OK: added
} catch (e: IllegalArgumentException) {
println("ERROR: ${e.message}") // ERROR: Amount must be > 0
}
}
Мы специально ловим IllegalArgumentException, потому что require(...) кидает именно её. Это “мягкая” обработка: программа не падает, а объясняет пользователю, что не так.
handleList:
fun handleList(service: ExpenseService) {
val items = service.listExpenses()
if (items.isEmpty()) {
println("(empty)") // (empty)
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 — вы уже молодец, и код станет заметно спокойнее.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ