JavaRush /Курси /Kotlin SELF /Подієве мислення — слабка звʼязаність і контракт події

Подієве мислення — слабка звʼязаність і контракт події

Kotlin SELF
Рівень 57 , Лекція 0
Відкрита

1. Вступ

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

Коли код починає «липнути»

Якщо ви пишете навчальну консольну програму на 50 рядків, вам може здаватися, що архітектуру вигадали для нудних людей у краватках. Але є погана новина: щойно ви додаєте другий чи третій «побічний ефект» (логування, статистику, збереження у файл, сповіщення), код починає поводитися як жувальна гумка під столом. Наче й тримається — але торкатися неприємно.

Уявімо наш практичний проєкт — умовний консольний трекер витрат BudgetBuddy. У нас є команда add, яка додає витрату. Наївний варіант такий: «раз додали — значить, одразу логуємо, одразу перераховуємо звіт, одразу зберігаємо, одразу друкуємо повідомлення».

data class Expense(val id: Int, val title: String, val amount: Int)

fun addExpense(expenses: MutableList<Expense>, e: Expense) {
    expenses += e
    println("Додано: ${e.title} (${e.amount})") // Додано: Coffee (250)
    println("Аудит: витрату додано id=${e.id}") // Аудит: витрату додано id=1
    println("Статистика: потрібно перерахувати підсумки")    // Статистика: потрібно перерахувати підсумки
}

Код працює. І навіть здається милим. А потім ви додаєте «ще одну маленьку реакцію» — наприклад, автозбереження у файл — і функція addExpense перетворюється на комбайн.

Проблема не в тому, що там багато рядків. Проблема в іншому: addExpense починає знати надто багато. Хто відповідає за аудит, що таке статистика, коли зберігати, у якому форматі логувати. Це вже не «додати витрату». Це «додати витрату й зробити ще сто речей, бо життя — біль».

Подія — факт, а не команда

Коли в коді зʼявляється слово event («подія»), новачки часто сприймають це як «якусь магію, що літає в повітрі». Насправді все простіше й приземленіше: подія — це повідомлення про факт, а не наказ конкретному модулю. Різниця тонка, але саме вона радикально змінює архітектуру.

Порівняйте два формулювання:

  • «Надішли листа користувачу» — це команда: ви вже обрали хто і що має зробити.
  • «Користувача зареєстровано» — це факт; далі різні частини системи можуть реагувати по‑своєму.

У нашому BudgetBuddy «команда» виглядала б так: sendEmailToAccountant(expense) (так, бухгалтер теж людина). А «факт» звучатиме так: expenseAdded(expense).

Це і є слабка звʼязаність: частина коду, яка додає витрату, не зобовʼязана знати, що десь є аудит, звіти або автозбереження. Вона повідомляє факт: «витрату додано». А далі — хто почув, той і зреагував.

2. Модель «один → багато»: джерело і слухачі

У подієвій моделі майже завжди є одна й та сама «картинка»: є джерело події (publisher/emitter), яке публікує факт, і є слухачі (listeners/observers), які на цей факт реагують. Причому слухачів може бути один, два або двадцять — і джерело від цього не повинно нервово гикати.

Мінімальна реалізація на колекції обробників

Щоб відчути ідею на практиці, не потрібні жодні бібліотеки й фреймворки. Достатньо колекції обробників. Колекції в Kotlin якраз для цього й існують: зберігати набір елементів і дозволяти додавати чи видаляти їх. У MutableCollection є операції зміни вмісту (наприклад, add/remove) — і це фундамент для «списку підписників».

Мінімальна модель «один → багато» може виглядати так:

data class Expense(val id: Int, val title: String, val amount: Int)

val expenseAddedListeners = mutableListOf<(Expense) -> Unit>()

fun addExpense(expenses: MutableList<Expense>, e: Expense) {
    expenses += e
    for (listener in expenseAddedListeners) listener(e)
}

Це ще не «ідеальна» реалізація подій (ми поки не обговорюємо безпеку, відписку та помилки обробників). Але як навчальна картинка — чудово: додали витрату, пробіглися по слухачах, і кожен виконав свою реакцію.

Важливо: ми навмисно використовуємо mutableListOf, тому що список підписників змінюється: хтось підписався, хтось «вимкнувся». Kotlin нормально ставиться до того, що мутабельну колекцію можна зберігати в val: посилання не змінюється, а вміст — так. Це як «коробка завжди одна, але цукерки всередині то зʼявляються, то зникають».

Підʼєднуємо реакції окремо

Проговорімо, що саме стає краще, коли ми перестаємо «смикати сусідів за рукав» і починаємо публікувати події.

Уявімо, що в нас є три реакції на додавання витрати: друк у консоль, аудит і перерахунок статистики. За прямого підходу addExpense має знати про всі три. За подієвого підходу addExpense знає лише про факт додавання, а реакції живуть окремо.

fun printOnAdded(e: Expense) {
    println("Додано: ${e.title} (${e.amount})") // Додано: Coffee (250)
}

fun auditOnAdded(e: Expense) {
    println("Аудит: id витрати=${e.id}")       // Аудит: id витрати=1
}

fun statsOnAdded(e: Expense) {
    println("Статистика: перерахунок після ${e.title}")  // Статистика: перерахунок після Coffee
}

Підʼєднання слухачів виглядає так:

fun main() {
    expenseAddedListeners += ::printOnAdded
    expenseAddedListeners += ::auditOnAdded
    expenseAddedListeners += ::statsOnAdded
}

І ось тут зʼявляється приємна річ: щоб додати нову реакцію, нам не потрібно лізти в addExpense. Ми додаємо нову функцію‑слухач і підписуємо її. Програма росте, а «центр» системи не перетворюється на монстра, який знає все про все.

3. Контракт події та правила гри

Коли люди вперше чують про події, у них часто виникає відчуття: «О, круто, тепер можна просто “емітувати” що завгодно, куди завгодно, і все само запрацює». Це небезпечна ілюзія. Події добре працюють лише тоді, коли в них є контракт — тобто набір заздалегідь узгоджених правил, за якими всі грають.

Контракт — це не обовʼязково формальний документ на 20 сторінок. У навчальному проєкті це може бути просто чітка домовленість — у голові та в коді. Але ці питання варто вирішити заздалегідь. Інакше різні частини програми почнуть очікувати різної поведінки.

Що входить у контракт

Нижче — таблиця «про що домовитися», без заглиблення в реалізацію (реалізацію ми робитимемо в наступних лекціях).

Частина контракту Питання Чому це важливо
Дані події (payload) Що передаємо слухачам: рядок, число, обʼєкт? Якщо передавати «магічні рядки», легко помилитися. Окремий тип даних робить сенс явним.
Синхронність Слухачі викликаються «прямо зараз» чи «колись потім»? Якщо це синхронно, слухач може сповільнити основну операцію. Якщо асинхронно — зʼявляються питання порядку та життєвого циклу.
Помилки слухачів Якщо один слухач упав, інші мають виконатися? Без домовленості ви отримаєте випадкові падіння за принципом «то працює, то ні».
Порядок виклику Чи є гарантований порядок слухачів? Якщо порядок важливий, ви фактично будуєте конвеєр — а це вже інший стиль.
Керування підписками Чи можна підписуватися/відписуватися під час роботи? Інакше ви ризикуєте отримати «вічних слухачів», яких викликають, коли вже не треба.
Поведінка за відсутності слухачів Що робить подія, якщо ніхто не підписаний? Нормально, якщо це «нічого». Так код стає передбачуванішим.

Зверніть увагу: ми поки не говоримо, «як саме» реалізувати відписку або безпеку розсилання. Ми лише фіксуємо: це частина контракту. Без неї подієва система швидко перетворюється на містику, де все працює лише по пʼятницях.

Як називати події

З назвою події відбувається кумедна річ: вона впливає на мислення команди сильніше, ніж здається. Якщо ви назвете подію sendEmail, ви вже «зашили» в архітектуру, що «подія = лист». Це ламає ідею слабкої звʼязаності, бо джерело ніби натякає: «Я очікую, що ти надішлеш лист».

Краще називати подію як факт — зазвичай у минулому часі або у формі «що сталося»:

  • expenseAdded / ExpenseAdded
  • expenseRemoved
  • reportGenerated

Корисно мислити так: «Подія має бути правдою». Якщо подія називається expenseAdded, то в момент публікації витрату справді вже додано до списку/сховища. Якщо ви публікуєте подію до зміни стану, слухачі можуть побачити «світ до змін» і почнуть ухвалювати рішення на основі застарілих даних. Це не збій — це класична пастка проєктування.

Міні‑приклад правильного порядку: спочатку змінюємо стан, потім публікуємо факт.

fun addExpense(expenses: MutableList<Expense>, e: Expense) {
    expenses += e
    for (listener in expenseAddedListeners) listener(e)
}

Виділяємо точку факту в коді

Зараз зробимо дуже важливий крок, який виглядає майже смішно простим: відокремимо «додавання витрати» від «усіх реакцій». Не через повноцінний Event<T> (це буде пізніше), а через явну функцію‑«точку події». Це корисний проміжний стиль: ви вже дисциплінуєте код, навіть якщо у вас поки немає інфраструктури підписок.

fun emitExpenseAdded(e: Expense) {
    println("Подія: expenseAdded(id=${e.id})") // Подія: expenseAdded(id=1)
}

fun addExpense(expenses: MutableList<Expense>, e: Expense) {
    expenses += e
    emitExpenseAdded(e)
}

Чому це взагалі варто робити, якщо поки «ніхто не слухає»? Тому що ви фіксуєте архітектурний сенс: ось тут стався факт. Пізніше, коли зʼявляться слухачі, ви заміните тіло emitExpenseAdded на розсилання підписникам, а решту коду не доведеться перекопувати.

Це рівно той момент, коли система починає «розтискатися»: addExpense перестає бути місцем, куди додають «ще одну реакцію». Вона стає місцем, де відбувається доменна дія (додали витрату) і публікується факт.

4. Компроміси: ціна гнучкості й де події не потрібні

Події не безплатні. Вони дають гнучкість, але забирають частину простоти. І якщо про це не сказати вголос, то в якийсь момент ви почнете підозрювати, що «події — це зло», бо все стало неочевидно. Насправді ви просто не «оплатили рахунок»: не визначили контракт і не дисциплінували використання.

Головна ціна — у тому, що виконання стає менш лінійним. За прямого виклику ви читаєте код зверху вниз і приблизно розумієте: «спочатку додали, потім перерахували, потім зберегли». За подієвого підходу ви читаєте: «додали, а далі… десь хтось відреагував». Це нормально, але потребує звички: тепер поведінка визначається не одним місцем, а набором підписок.

Друга ціна — керування життєвим циклом слухачів. Якщо підписки живуть довше, ніж потрібно, ви отримуєте неочікувані повторні реакції. Якщо слухач упав із винятком, ви маєте розуміти, що буде з іншими. Якщо слухач підписався чи відписався під час розсилання, потрібно заздалегідь вирішити, як система має поводитися. Ми поки не розвʼязуємо ці задачі, але важливо побачити їх як реальні інженерні питання, а не як «ну це потім якось».

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

5. Типові помилки

Помилка № 1: плутати подію з командою.
Коли подію називають sendEmail, архітектура починає «просити» джерело події знати, що саме має відбутися. Це ламає слабку звʼязаність: джерело знову починає залежати від конкретних дій. Якщо хочеться подій — називайте їх як факти (userRegistered, expenseAdded) і тримайте в голові думку «подія має бути правдою».

Помилка № 2: робити «центральну функцію», яка все одно знає про всі реакції.
Іноді розробник каже: «Гаразд, у нас буде подія», але потім пише emitExpenseAdded, усередині якої вручну викликає audit(), recalcStats(), saveToFile(). Це просто переїзд тієї самої звʼязаності в інше місце. Подієва модель корисна саме тоді, коли реакції підʼєднуються ззовні, а джерело не знає, хто вони.

Помилка № 3: не проговорити контракт поведінки у разі помилок.
Якщо один обробник кидає виняток, а ви цього не передбачили, результат буде «випадковим»: інколи все відпрацювало, інколи програма впала, інколи частина реакцій не сталася. Навіть у навчальному проєкті варто заздалегідь визначитися: «один упав — інших усе одно викликаємо» або «помилка критична — усе перериваємо». У наступних лекціях ми робитимемо розсилання стійким, але сама думка має зʼявитися вже зараз.

Помилка № 4: будувати логіку, де порядок слухачів критичний, але це ніде не зафіксовано.
Якщо ви мовчки очікуєте, що «спочатку статистика, потім збереження», а хтось поміняв порядок підписок, ви отримаєте помилку, яку неможливо зрозуміти, дивлячись лише на код addExpense. Якщо порядок справді важливий, це має стати частиною контракту (і тоді це вже ближче до конвеєра/пайплайну, а не до «вільних реакцій»).

Помилка № 5: намагатися замінити подіями взагалі всі виклики функцій.
Події чудові для розширюваних реакцій «один → багато», де слухачі можуть зʼявлятися й зникати. Але якщо вам потрібен простий результат «порахуй і поверни», то події лише заважатимуть. Хороша архітектура — це не «скрізь події», а «події там, де вони справді знижують звʼязаність і роблять розширення дешевшим».

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