JavaRush /Курсы /Kotlin SELF /Фиксация v1 — единый контракт команд

Фиксация v1 — единый контракт команд

Kotlin SELF
39 уровень , 4 лекция
Открыта

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. Важно: это не domain-модель, это именно «результат команды для интерфейса».

// 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 означает «аргумента нет».

И вот теперь — обработчик команд. Он использует domain-сервис и exporter (которые мы уже сделали в прошлых лекциях), но наружу выдаёт только 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("Unknown command: ${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». Это не список ради списка, а способ быстро проверить, что структура выдержана, когда вы устали и хотите “просто дописать фичу”. Если у вас domain-слой не использует readln() и println(), storage спрятан за интерфейсом, exporter возвращает строку, а main выглядит как сценарий, значит вы действительно закрепили границы, а не просто разнесли файлы по папкам.

7. Маленький практический нюанс

Иногда возникает соблазн сделать “умнее”: команда "list" возвращает строку отчёта, команда "add" возвращает Boolean, команда "help" возвращает список строк… и так далее. Кажется, что так «типобезопаснее» (каждая команда же «уникальная»). Но на уровне CLI это превращается в необходимость различать команды ещё раз: «если это list — печатай так, если add — иначе».

Единый результат — это компромисс ради устойчивости. Мы говорим: на уровне CLI нам важно одно: получилось или нет, и какое сообщение показать. Внутри domain вы можете возвращать любые структуры, но на границе команды мы приводим всё к единому виду.

В v1 это особенно полезно, потому что вы ещё не строите сложные интерфейсы. У вас консоль, а консоль любит простые правила: одна команда — одна строка ответа, и желательно без сюрпризов.

Для визуального сравнения можно держать в голове такую таблицу:

Подход Что возвращают команды Что происходит в CLI
«Каждая команда как хочет» String, Boolean, null, печать внутри много специальных случаев
Единый контракт v1 CommandResult один сценарий обработки

И да, если вам кажется, что это «слишком просто», поздравляю: вы начали ценить простоту как инженерный инструмент. Это опасный путь — дальше вы начнёте подписывать переменные нормально и перестанёте писать x1, x2, x3. Обратной дороги почти нет.

8. Типичные ошибки при фиксации v1 и использовании Ok/Error

Ошибка №1: команды всё равно печатают внутри себя, а CommandResult появляется “для галочки”.
Так часто бывает: вы добавили CommandResult, но в ExpenseService всё ещё живёт println("Added!"). В итоге сообщение печатается дважды, или печатается в неожиданном месте, или формат “OK/ERROR” не соблюдается. Лечится это просто и немного больно: удаляете println из domain и storage, и заставляете себя возвращать данные/результат наружу. CLI печатает, остальные слои — нет.

Ошибка №2: when по sealed пишут с else, “чтобы компилировалось всегда”.
else выглядит как подушка безопасности, но на деле это выключатель сигнализации. Пока всё хорошо — вы не заметите. Но когда добавите новый вариант результата, компилятор уже не поможет вам найти места, где вы забыли его обработать. В v1 лучше дисциплинировать себя: если sealed, то обрабатываем варианты явно, без else.

Ошибка №3: CommandResult начинают использовать как “мешок для всего”, и туда попадает половина доменной модели.
Иногда хочется положить в Ok не строку, а список расходов, сумму, статистику… и внезапно CommandResult превращается в новый слой domain-модели, только с другим названием. В v1 держите контракт узким: message: String. Если очень хочется данных — лучше пусть domain возвращает данные, exporter делает строку, а команда возвращает строку в Ok.

Ошибка №4: обработчик команд начинает напрямую лезть в репозиторий или хранит в себе состояние, которое должно жить в domain.
Если CommandHandler вдруг начинает делать repo.items.add(...) или сам валидирует суммы, значит граница поплыла: CLI-команда стала “полу-доменом”. В v1 лучше терпеливо держать роли: handler роутит и вызывает сервис, сервис применяет правила, репозиторий хранит, exporter форматирует.

Ошибка №5: разные команды используют разные стили сообщений, потому что форматируют строки “где-нибудь по пути”.
Сегодня вы написали "OK: ..." в одном месте, завтра — "Success: ..." в другом. Пользователь видит разнобой, а вы тратите время на косметику вместо логики. В v1 решается это централизованным render() и привычкой: всё, что печатается пользователю, проходит через одну точку форматирования.

Ошибка №6: main перестаёт быть сценарием и снова обрастает логикой — только теперь “в красивых пакетах”.
Это хитрый вариант регресса: файлы разложили, интерфейсы ввели, а потом в main появилось 60 строк условий и обработок. Признак нормального v1: main читается сверху вниз как рецепт. Если рецепт превращается в роман — значит, пора возвращать ответственность обратно в handler/service/exporter.

1
Задача
Kotlin SELF, 39 уровень, 4 лекция
Недоступна
Результат команды
Результат команды
1
Задача
Kotlin SELF, 39 уровень, 4 лекция
Недоступна
Единый рендер
Единый рендер
1
Задача
Kotlin SELF, 39 уровень, 4 лекция
Недоступна
Разбор команды
Разбор команды
1
Задача
Kotlin SELF, 39 уровень, 4 лекция
Недоступна
Обработчик команд
Обработчик команд
1
Опрос
Архитектура и рефакторинг:, 39 уровень, 4 лекция
Недоступен
Архитектура и рефакторинг:
Архитектура и рефакторинг:
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ