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("Added: ${e.title} (${e.amount})") // Added: Coffee (250)
    println("Audit: expense added id=${e.id}") // Audit: expense added id=1
    println("Stats: need to recalc totals")    // Stats: need to recalc totals
}

Код работает. И даже кажется милым. А потом вы добавляете «ещё одну маленькую реакцию» — например, авто‑сохранение в файл — и функция 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("Added: ${e.title} (${e.amount})") // Added: Coffee (250)
}

fun auditOnAdded(e: Expense) {
    println("Audit: expense id=${e.id}")       // Audit: expense id=1
}

fun statsOnAdded(e: Expense) {
    println("Stats: recalc after ${e.title}")  // Stats: recalc after 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("Event: expenseAdded(id=${e.id})") // Event: 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: пытаться заменить событиями вообще все вызовы функций.
События прекрасны для расширяемых реакций «один → много», где слушатели могут появляться и исчезать. Но если вам нужен простой результат «посчитай и верни», то события будут только мешать. Хорошая архитектура — это не «везде события», а «события там, где они реально снижают связанность и делают расширение дешевле».

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