JavaRush /Курсы /Kotlin SELF /Разделение ответственности

Разделение ответственности

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

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 — вы уже молодец, и код станет заметно спокойнее.

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