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("Додано: $title") // Додано: Coffee
        } else if (line == "list") {
            println(expenses.joinToString())
        } else {
            println("Невідома команда")
        }
    }
}

Спочатку здається: «ну ок, читається». А потім ви захочете, наприклад, щоб у витрати була сума, щоб сума була додатною, щоб пробіли в назві прибиралися однаково, щоб вивід був охайнішим, щоб можна було порахувати підсумок. І раптом зʼясовується, що в нас:

  • введення змішане з правилами (валідацією/нормалізацією),
  • правила змішані зі зберіганням (ми напряму ліземо в 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()) { "Назва не повинна бути порожньою" }
        require(amount > 0) { "Сума має бути > 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("Невідома команда: ${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: додано") // OK: додано
    } catch (e: IllegalArgumentException) {
        println("ПОМИЛКА: ${e.message}") // ПОМИЛКА: Сума має бути > 0
    }
}

Ми спеціально ловимо IllegalArgumentException, тому що require(...) кидає саме її. Це «мʼяка» обробка: програма не падає, а пояснює користувачеві, що не так.

handleList:

fun handleList(service: ExpenseService) {
    val items = service.listExpenses()
    if (items.isEmpty()) {
        println("(порожньо)") // (порожньо)
        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, — ви вже молодець, і код стане помітно спокійнішим.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ