EventBus и CommandBus во frontend: когда прямых вызовов становится слишком много
- четверг, 1 октября 2026 г. в 00:00:02
Практический разбор архитектуры, где CommandBus передаёт намерения пользователя, а EventBus распространяет изменения состояния между независимыми частями интерфейса.
В небольшом frontend‑приложении обычно нет необходимости придумывать дополнительные слои абстракции. Пользователь нажал кнопку — компонент вызвал сервис. Сервис изменил данные — интерфейс обновился. Проблема появляется позже, когда одно и то же действие можно выполнить из нескольких мест, а на одно изменение состояния должны реагировать несколько независимых частей приложения.
С такой ситуацией я столкнулся при разработке Textria — браузерного блокнота. По мере роста приложения заметку стало возможно открыть не только из списка, но и из других элементов интерфейса. Появились горячие клавиши, контекстные действия, дополнительные панели. Одновременно изменение активной заметки должно было обновлять редактор, заголовок, список заметок и другие части интерфейса. В результате я разделил два типа взаимодействий через CommandBus и EventBus. Схематично это выглядит так:
UI ↓ CommandBus ↓ Handler ↓ Service ↓ EventBus ├─ Editor ├─ Header ├─ NotesList └─ StatusBar
У этих двух шин похожая реализация, но совершенно разный смысл. CommandBus отвечает на вопрос: что нужно сделать?, а EventBus отвечает на вопрос: что уже произошло?
Предположим, заметку можно открыть из списка, контекстного меню и через горячую клавишу. Без дополнительного слоя каждый такой компонент может напрямую обращаться к NoteService:
noteService.openNote(id);
На небольшом проекте это нормально. Но со временем UI начинает знать всё больше о внутреннем устройстве приложения. В моём случае я захотел оставить источнику действия только одну обязанность: сообщить, какое действие пользователь собирается выполнить. Получился примерно такой вызов:
commandBus.execute("note:open", { id });
А связь между командой и сервисом регистрируется отдельно:
commandBus.register( "note:open", ({ id }) => noteService.openNote(id), );
Теперь кнопка, меню и hotkey знают только название команды и её параметры. Сам NoteService можно заменить или изменить, не проходя по всем источникам действия. При этом я не использую отдельные классы команд. Для моего случая команда — это просто идентификатор и payload.
Button ───────┐ Menu ─────────┼─> note:open ─> Handler ─> NoteService Shortcut ─────┘
Важно, что у команды есть конкретный исполнитель. Если вызывается note:open, кто‑то обязан эту команду обработать.
После выполнения команды возникает другая задача. Допустим, NoteService открыл заметку. Теперь новое состояние нужно показать в нескольких местах интерфейса. Можно явно вызвать каждый компонент:
editor.setContent(note.content); header.setTitle(note.title); notesList.select(note.id);
Но тогда сервису придётся знать, какие элементы интерфейса существуют и какие действия им нужно выполнить. Вместо этого сервис сообщает о произошедшем изменении:
eventBus.emit("note:state", state);
А заинтересованные части приложения подписываются на него самостоятельно. В итоге NoteService не знает, есть ли у события два подписчика или десять. Он сообщает факт изменения состояния, а дальнейшие реакции остаются независимыми.
┌─> Editor NoteService ───┼─> Header ├─> NotesList └─> StatusBar
Для меня это и стало главным различием между двумя механизмами: команда означает «открой эту заметку», а событие — «активная заметка изменилась».
На практике типичный сценарий выглядит так:
Клик по заметке ↓ CommandBus.execute("note:open") ↓ Handler ↓ NoteService.openNote() ↓ изменение состояния ↓ EventBus.emit("note:state") ↓ Editor / Header / NotesList
CommandBus используется на входе в бизнес‑операцию, а EventBus — после изменения состояния. Такое разделение оказалось довольно удобным, потому что поток действия можно читать почти буквально: пользователь хочет что‑то сделать → команда передаёт это намерение обработчику → сервис выполняет операцию → сервис сообщает о результате или изменении состояния → интерфейс реагирует.
Технически можно отказаться от CommandBus и отправлять события вроде:
eventBus.emit("folder:create");
Но здесь быстро возникает неоднозначность. Кто обязан обработать такое событие? Что произойдёт, если обработчиков два? Как вызывающий код дождётся завершения операции? Как получить созданную папку? Как вернуть ошибку?
Можно построить поверх EventBus собственный request/response‑механизм: добавить callbacks, folder:created, folder:create:error и другие события. Но в этот момент EventBus фактически начинает выполнять роль CommandBus. Для команды гораздо естественнее написать:
const folder = await commandBus.execute("folder:create");
Вызвавший код знает, что у операции есть один обработчик и что от неё можно получить результат.
Обратный вариант тоже работает только до определённого момента. После открытия заметки можно выполнить несколько команд:
editor:set-content header:set-title notes-list:select status-bar:update
Но тогда код, открывающий заметку, должен знать полный список последствий этой операции. Если завтра появится ещё один компонент, его нужно будет добавить в этот список.
EventBus снимает эту зависимость. Новый компонент просто подписывается на уже существующее событие. Поэтому CommandBus хорошо подходит для адресного действия с одним исполнителем, а EventBus — для распространения информации между несколькими независимыми потребителями.
Сами реализации EventBus и CommandBus у меня очень небольшие. Самое интересное началось не внутри этих классов, а вокруг их использования.
Одна из очевидных проблем EventBus — жизненный цикл подписчиков. Если контроллер подписался на событие, а затем был уничтожен, listener всё ещё может остаться внутри EventBus. В долгоживущей SPA это постепенно превращается и в утечку памяти, и в источник неожиданных вызовов.
Поэтому on() у меня возвращает функцию отписки, а контроллер сохраняет её и вызывает при уничтожении. Это мелочь, которую легко пропустить, пока приложение небольшое.
Есть менее очевидная проблема. Предположим, сервис успешно записал заметку в IndexedDB и после этого отправил событие note:saved. Если один UI‑listener внутри emit() бросит исключение, ошибка может подняться обратно в код сервиса. В результате catch, предназначенный для обработки ошибки сохранения, сработает несмотря на то, что сама запись уже прошла успешно.
Получается смешивание двух разных ситуаций: либо действительно не удалось сохранить данные, либо один из потребителей события не смог обновить UI. Поэтому при проектировании EventBus стоит заранее решить, должны ли исключения подписчиков распространяться наружу или каждый listener необходимо изолировать. Универсального ответа здесь нет — это часть контракта конкретной реализации.
Ещё одна ловушка связана с Promise. Такой код выглядит вполне однозначно:
await commandBus.execute("note:update", patch);
Но он гарантирует только завершение того Promise, который вернул зарегистрированный handler. Например, handler может изменить состояние, а фактическое сохранение поставить в debounce на 800 мс. С точки зрения CommandBus команда уже завершилась, хотя данные ещё не записаны в IndexedDB. Это важно учитывать в местах, где после await предполагается, что операция полностью завершена.
Похожая проблема появляется при регистрации обработчика:
commandBus.register("folder:delete", ({ id }) => { noteService.deleteFolder(id); });
Если deleteFolder() асинхронный, его Promise здесь не возвращается. В результате внешний await commandBus.execute(...) завершится сразу.
Правильный вариант:
commandBus.register( "folder:delete", ({ id }) => noteService.deleteFolder(id), );
Ошибка небольшая, но семантика команды меняется полностью.
Название CommandBus легко наводит на мысль о CQRS, но само наличие шины команд ещё ничего такого не означает. В моём случае один NoteService может и получать заметку, и изменять её. Для чтения и записи используется одна модель данных. Отдельной query‑модели нет.
Поэтому я бы не называл такую архитектуру CQRS. Фактически здесь используются две довольно простые идеи: command dispatch и in‑memory pub/sub. И этого для задачи вполне достаточно.
После нескольких итераций у меня сформировалось довольно простое правило. CommandBus становится полезным, когда одна операция вызывается из нескольких независимых источников:
Button Menu Shortcut ↓ CommandBus
EventBus имеет смысл, когда одно изменение действительно интересно нескольким независимым потребителям:
note:state note:saved file-transfer:state
А если взаимодействие по‑прежнему выглядит так:
Component ↓ Service
то я предпочитаю оставить прямой вызов.
await noteService.save();
Bus не должен становиться обязательным промежуточным слоем просто ради архитектурной симметрии.
Сейчас общая схема взаимодействия выглядит примерно так:
UI Button / Menu / Hotkey ↓ CommandBus ↓ Handler ↓ Service ↙ ↘ Storage EventBus ↓ UI-подписчики
CommandBus передаёт намерение выполнить действие, а EventBus распространяет факт произошедшего изменения. При этом сама реализация обеих шин занимает совсем немного кода. Основные вопросы появляются на уровне контрактов: кто отвечает за Promise, как распространяются ошибки, кто управляет жизненным циклом подписок и где проходит граница между состоянием приложения и событиями.
Поэтому я бы не начинал новый frontend‑проект с обязательного EventBus и CommandBus. Но если одновременно появляются две характерные проблемы — одно действие вызывается из нескольких источников, а одно изменение требует нескольких независимых реакций — небольшие шины могут упростить связи между частями приложения без перехода к значительно более тяжёлой архитектуре.