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()) { "Title must not be empty" }
require(amount > 0) { "Amount must be > 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("Command: ") // Command:
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("Added: $expense") // Added: Expense(title=..., amount=...)
}
"list" -> println(storage.all())
else -> println("Unknown command: ${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 превращается в «свалку всего подряд».
Пока проект маленький, это ощущается как порядок. Но как только туда попадают функции из разных слоёв, пакет начинает тянуть зависимости во все стороны и разрушает границы ответственности. Лучше держать «утилиты» рядом с их слоем и задачей — тогда импортами будет проще управлять, а структура станет самообъясняющей.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ