javascript

EventBus и CommandBus во frontend: когда прямых вызовов становится слишком много

  • четверг, 1 октября 2026 г. в 00:00:02
https://habr.com/ru/articles/1088554/

Практический разбор архитектуры, где CommandBus передаёт намерения пользователя, а EventBus распространяет изменения состояния между независимыми частями интерфейса.

В небольшом frontend‑приложении обычно нет необходимости придумывать дополнительные слои абстракции. Пользователь нажал кнопку — компонент вызвал сервис. Сервис изменил данные — интерфейс обновился. Проблема появляется позже, когда одно и то же действие можно выполнить из нескольких мест, а на одно изменение состояния должны реагировать несколько независимых частей приложения.

С такой ситуацией я столкнулся при разработке Textria — браузерного блокнота. По мере роста приложения заметку стало возможно открыть не только из списка, но и из других элементов интерфейса. Появились горячие клавиши, контекстные действия, дополнительные панели. Одновременно изменение активной заметки должно было обновлять редактор, заголовок, список заметок и другие части интерфейса. В результате я разделил два типа взаимодействий через CommandBus и EventBus. Схематично это выглядит так:

UI
 ↓
CommandBus
 ↓
Handler
 ↓
Service
 ↓
EventBus
 ├─ Editor
 ├─ Header
 ├─ NotesList
 └─ StatusBar

У этих двух шин похожая реализация, но совершенно разный смысл. CommandBus отвечает на вопрос: что нужно сделать?, а EventBus отвечает на вопрос: что уже произошло?

Зачем понадобился CommandBus

Предположим, заметку можно открыть из списка, контекстного меню и через горячую клавишу. Без дополнительного слоя каждый такой компонент может напрямую обращаться к 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, кто‑то обязан эту команду обработать.

Где появляется EventBus

После выполнения команды возникает другая задача. Допустим, 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 — после изменения состояния. Такое разделение оказалось довольно удобным, потому что поток действия можно читать почти буквально: пользователь хочет что‑то сделать → команда передаёт это намерение обработчику → сервис выполняет операцию → сервис сообщает о результате или изменении состояния → интерфейс реагирует.

Почему не использовать для всего EventBus

Технически можно отказаться от CommandBus и отправлять события вроде:

eventBus.emit("folder:create");

Но здесь быстро возникает неоднозначность. Кто обязан обработать такое событие? Что произойдёт, если обработчиков два? Как вызывающий код дождётся завершения операции? Как получить созданную папку? Как вернуть ошибку?

Можно построить поверх EventBus собственный request/response‑механизм: добавить callbacks, folder:created, folder:create:error и другие события. Но в этот момент EventBus фактически начинает выполнять роль CommandBus. Для команды гораздо естественнее написать:

const folder = await commandBus.execute("folder:create");

Вызвавший код знает, что у операции есть один обработчик и что от неё можно получить результат.

Почему не использовать для всего CommandBus

Обратный вариант тоже работает только до определённого момента. После открытия заметки можно выполнить несколько команд:

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 необходимо изолировать. Универсального ответа здесь нет — это часть контракта конкретной реализации.

await execute() не означает завершение всего процесса

Ещё одна ловушка связана с Promise. Такой код выглядит вполне однозначно:

await commandBus.execute("note:update", patch);

Но он гарантирует только завершение того Promise, который вернул зарегистрированный handler. Например, handler может изменить состояние, а фактическое сохранение поставить в debounce на 800 мс. С точки зрения CommandBus команда уже завершилась, хотя данные ещё не записаны в IndexedDB. Это важно учитывать в местах, где после await предполагается, что операция полностью завершена.

Promise легко случайно потерять

Похожая проблема появляется при регистрации обработчика:

commandBus.register("folder:delete", ({ id }) => {
  noteService.deleteFolder(id);
});

Если deleteFolder() асинхронный, его Promise здесь не возвращается. В результате внешний await commandBus.execute(...) завершится сразу.

Правильный вариант:

commandBus.register(
  "folder:delete",
  ({ id }) => noteService.deleteFolder(id),
);

Ошибка небольшая, но семантика команды меняется полностью.

Это CQRS?

Название 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. Но если одновременно появляются две характерные проблемы — одно действие вызывается из нескольких источников, а одно изменение требует нескольких независимых реакций — небольшие шины могут упростить связи между частями приложения без перехода к значительно более тяжёлой архитектуре.