
Привет, меня зовут Константин, последние пять лет я возглавляю команду RnD. Мы развиваем нашу внутреннюю Frontend-платформу, а до этого я занимался разработкой сервисов для нашего БЮ, Почты и Облака. В статье описан типичный пример моей задачи по созданию продукта: как мы с нуля создавали сервис VK Доска.
Это не про историю успеха, да и не про Доску как таковую. Мы не собирались делать сервис в том виде, в котором он существует сейчас. Это рассказ о том, как запустить, по сути, стартап внутри большой корпорации, когда у тебя сжатые сроки, ограниченные ресурсы, дедлайн, который невозможно перенести, и потребители, которые зависят от тебя.
Расскажу, какой путь мы прошли с нуля до готового продукта. Почему ресёрч важнее кода, как мы выбирали технологии, строили архитектуру, способную пережить внезапную смену бизнес-требований, с какими проблемами столкнулись мы и точно столкнётесь вы при создании сервисов с коллаборацией.
Начало
Всё началось с того, что пришёл коллега и сказал: «Костя, нам нужна доска». К тому моменту давно назрела потребность в своей интерактивной доске для множества образовательных проектов и для VK Workspace. Последней каплей стал запуск параллельного стартапа внутри компании, которому доска нужна была как часть их продукта. У них был жёсткий дедлайн — 1 сентября. А на дворе стоял март. Опыта создания подобных сервисов у меня и команды не было. Последний раз я «трогал» Canvas лет десять назад. Провалиться было нельзя, потому что от нас зависел успех не только нашего начинания, но и соседней команды.
Как подступиться к задаче? Не паниковать, а начать со сбора требований, раскрыть, что бизнес подразумевает под «Нам нужна доска». Копнув, мы получили три базовый направления, на которых и строились все наши дальнейшие решения:

«Доска». Базовая функциональность: фигуры, текст, коннекторы, загрузка файлов и прочее. Это ядро, без которого ничто не заработает.
Совместное редактирование. Пользователи должны работать с доской одновременно. А требования от Workspace накладывали ещё одно условие: решение должно быть local-first.
Интеграция. Сначала бизнес хотел не самостоятельный продукт, а сервис, который можно легко и быстро встроить в любое другое приложение.
С этим минимальным набором требований можно было двигаться дальше.
Два пути

Вы делали электронную доску? Думаю, большинство ответит «нет», поэтому, даже с учётом размеров VK, собрать (в короткие сроки) команду с релевантным опытом будет непросто. И хоть мы ребята не глупые, надо было трезво оценивать свои возможности: in-house разработка — тернистый путь, слишком много рисков. Поэтому обратились к open source. На рынке оказалось достаточно много решений, но для нас я выделил три:

Не буду детально их описывать, любой GPT расскажет лучше, главное — правильно задавайте вопросы :]. Упомяну лишь, что нельзя выбирать такие решения по звёздочкам на GitHub, иначе «ваш» выбор — ExcaliDraw. Это самый популярный инструмент, но и самый не расширяемый, настоящая «чёрная коробка»: максимум, что можно сделать, это перекрасить CSS. Нам же нужно было ядро, которое мы сможем развивать.
Помните: решения такого уровня — не просто какая-то библиотека, это будет часть вашей кодовой базы. Форк, который вы будете развивать.
Поэтому мы потратили около полутора месяцев на создание полноценного Proof of Concept на основе ExcaliDraw, Tldraw и Blocksuite. Мы буквально прощупывали код, пытались добавить свои фичи, оценивали производительность. Например, у TLDraw рендер был на SVG, и по нашим бенчмаркам он работал не очень быстро, поэтому мы даже прорабатывали отдельный трек по переписыванию его на Canvas.
Параллельно провели UX-исследование: собрали продакт-менеджеров и дали им «потрогать» каждый из прототипов, чтобы они оценили, как оно работает из коробки с точки зрения пользователя. Ведь каждая доработка потребует времени.
Всё это только про редактор, а как дела с оффлайн-работой? Коллаборацией? Бэкендом?
-
ExcaliDraw:
Коллаборация. Классическая клиент-серверная схема через WebSocket [1], когда клиент вносит изменения и отправляет на сервер либо всю обновленную сцену, либо дельту изменений. Сервер транслирует эти данные всем остальным участникам сессии. Конфликты разрешаются по принципу «last-write-wins» (кто последний, тот и прав).
Бэкенд. Официального, готового к промышленной эксплуатации бэкенда не было. Сообщество и документация предлагали референсные реализации с посылом «разбирайтесь и хостите сами».
-
Tldraw:
Коллаборация. Официальное решение для совместной работы — их собственная библиотека @tldraw/sync.
Бэкенд. Официальное решение предлагает Docker-образ, предназначенный для развёртывания на Cloudflare Durable Objects.
-
Blocksuite:
Коллаборация. Авторы позиционируют разработку как фреймворк, ядро которого спроектировано вокруг CRDT с использованием библиотеки Yjs. Поэтому Blocksuite по своей природе является local-first и априори поддерживает офлайн-режим.
Бэкенд. Официальный бэкенд для синхронизации написан на Rust. Но внутри используется Yjs, и дальше я расскажу, что из этого следует :)
Мы увидели, что и ExcaliDraw, и Tldraw, по сути, предлагают проприетарные решения для коллаборации. Это означало для нас огромный риск: если что-то пойдёт не так, нам придётся со всем этим разбираться, и помощи ждать будет не от кого.
В то же время Blocksuite создан на основе Yjs. Это не просто ещё одна библиотека. Это, де-факто, стандарт в индустрии для построения коллаборативных сервисов. Вокруг него сложилась целая экосистема: сообщество, готовые решения, тонны статей и примеров. И самое главное: на рынке нет разработчиков под ExcaliDraw или Tldraw, но специалисты с опытом работы с CRDT и Yjs — есть.
Поэтому наш выбор был не просто в пользу технологии (CRDT), а в пользу зрелой, стандартизированной экосистемы. Это было решение в пользу управляемых рисков и предсказуемости. Для команды, которая должна была гарантировать результат в сжатые сроки, это было решающим фактором.
✍️ВАЖНО: На самом деле у ExcaliDraw и Tldraw есть примеры интеграции с Yjs, но это не официальный решения, а фича «сбоку».
CRDT... CRDT... Что это вообще такое?
CRDT (Conflict-free Replicated Data Type) — это не конкретная библиотека, а набор структур данных, которые спроектированы так, чтобы гарантировать итоговую сходимость данных у всех клиентов. Что это даёт на практике?
Без CRDT мы неизбежно сталкиваемся с целым набором проблем:
конфликты и потеря данных (гонки);
неэффективная работа в реальном времени;
сложность отмены операций;
сложность реализации offline-режима;
плохой пользовательский опыт;
«стоимость» разработки.

Если вы разрабатываете продукт с поддержкой совместной работы и оффлайна, но без использования CRDT, то готовьтесь к росту архитектурной сложности и хрупкости. Сначала кажется, что всё под контролем: есть локальное состояние приложения, вы загружаете и отображаете данные. Взаимодействие линейное. Но как только возникает необходимость в синхронизации между пользователями, так сразу всё усложняется. Разработчики начинают добавлять WebSocket, вводят блокировки, и состояние становится нестабильным. А когда доходит до оффлайна, получается совсем печально.
В итоге появляются ошибки и гонки, данные пропадают и перезатираются, пользовательский опыт страдает. Это уже прямое нарушение бизнес-ожиданий. Если вашему продукту предстоит коллаборация и оффлайн, то без CRDT будет крайне сложно.
Достоинства CRDT:
автоматическое разрешение конфликтов;
независимость от порядка изменений;
эффективная работа как в реальном времени, так и offline;
отмена операций;
гибкость и масштабируемость.

Большинство современных CRDT-реализаций (Yjs, Logo и другие) работают по принципу local-first. Пользователь работает локально, а когда появляется связь — изменения синхронизируются, причём в любом порядке, и сливаются в единое состояние с автоматическим разрешением конфликтов.
На первый взгляд, это та самая серебряная пуля, о которой все мечтают, верно?
Это не так.
Да, CRDT решает огромный пласт проблем с синхронизацией, но взамен требует совершенно иного подхода к проектированию состояния. Нельзя просто начать использовать его, как очередной state-менеджер, как мы привыкли во фронтенде (но, о том, как не выстрелить себе в ногу, я подробно расскажу ниже).
Итак, потратив почти полтора месяца на ресёрч, мы выбрали:
«доска» — есть, Canvas API;
«совместное редактирование» — есть, CRDT (Yjs).
Казалось бы, всё отлично. Но официальный бэкенд для Blocksuite написан на Rust.

Наша команда умеет в Go и Node.js, даже в Perl, но с Rust мы не работали. Притащить в прод технологию, в которой ни у кого нет глубокой экспертизы, было бы равносильно закладке мины замедленного действия.
И вот тут ставка на экосистему Yjs очередной раз оправдывает себя. Мы не привязаны к реализации бэкенда от Blocksuite. Мы свободны выбирать альтернативу внутри экосистемы.
Нашим выбором стал Hocuspocus — готовый open-source бэкенд для Yjs от команды Tiptap (крутой блочный редактор), а он уже написан на NodeJS, в котором у нас есть экспертиза.
Hocuspocus из коробки предоставлял как серверную часть, так и клиентский провайдер для WebSocket, полностью закрывая наши требования к синхронизации. Но он не является полноценным HTTP-фреймворком. Нам по-прежнему нужно было обрабатывать обычные HTTP-запросы. Поэтому мы добавили в наш стек третий ключевой компонент — легковесный и очень быстрый HTTP-роутер find-my-way, который лежит в основе таких популярных решений как Fastify и Restify. Он не тащил за собой ничего лишнего и идеально дополнил Hocuspocus.

Вот теперь пазл сложился окончательно. У нас был:
Клиент: Blocksuite (Canvas + CRDT/Yjs) + Hocuspocus Provider.
Бэкенд: Hocuspocus Server (WebSocket + Yjs) + find-my-way (HTTP-роутинг).
Интеграция, или Как доставлять?
Изначально наша цель была сделать сервис, который смогут использовать внешние потребители. Это означало ряд жёстких требований:
Интеграция со сторонними сервисами. Потребителей у нас было несколько, и у каждого — своя инфраструктура, свои задачи и свои релизные циклы.
Быстрая доставка фич и фиксов. Если мы нашли баг или выкатили новую фичу, то она должна была появляться у всех потребителей моментально, «по воздуху». Мы не могли себе позволить бегать за каждой командой с просьбой: «Ребята, обновите, пожалуйста, наш NPM-пакетик».
Независимость от авторизации. У каждого потребителя своя система аутентификации. Мы должны были уметь работать с любой из них, встраиваясь в их мир, а не заставляя их переходить на наш.
Масштабируемость и безопасность. Вся головная боль по поддержке нагрузки, обеспечению отказоустойчивости и безопасности должна была остаться на нашей стороне. Потребитель не должен был об этом даже задумываться.
Имея на руках эти требования, мы начали рассматривать варианты доставки нашего продукта.
NPM-библиотека. Простой, но не жизнеспособный вариант. Он полностью противоречил требованию о быстрой доставке фиксов. Версионирование стало бы нашим кошмаром.
Self-hosted. Отдать исходники и сказать «разбирайтесь» — значит переложить на потребителя все проблемы с инфраструктурой, эксплуатацией и безопасностью. Это прямо противоположно тому, чего мы хотели добиться.
PaaS (Platform as a Service). Это был бы выстрел из пушки по воробьям. Мы не делали платформу для создания досок, мы делали конкретный сервис.
SaaS (Software as a Service). И вот этот вариант идеально ложился на все наши требования. Но что это означало для нас на практике?
Мы решили, что наш сервис будет поставляться в виде BoardSDK — небольшого JavaScript-пакета. Внешний потребитель просто подключает этот SDK, а тот рендерит iframe, внутри которого и живёт вся «магия» нашей доски. Но это не просто скрипт рендеринга. В саму концепцию SDK мы заложили философию, которая была критически важна для успеха интеграции: не потребитель подстраивается под нас, а мы под него. Кроме этого, SDK давал позволял работать не только с доской, но и с другими частями интерфейса (например генерировать список досок).

Такой подход давал нам полную изоляцию, строгие CSP (Content Security Policy) и, самое главное, контроль. Мы могли обновлять наш сервис хоть по десять раз на дню, и потребители бы этого даже не замечали, получая все улучшения мгновенно.
Сам SDK предоставлял максимально простой, высокоуровневый API:
render(el, opts)
— вывести доску для просмотра или редактирования;renderList(el, opts)
— вывести список досок;и другие методы для интеграции частей интерфейса в проект-потребитель (чтобы они не тратили время на его реализацию)
И минимальный набор низкоуровневого API: create
, get
, edit
, delete
, share
— методы для работы с досками.
Более того, через конфигурацию SDK потребитель мог:
управлять доступными фичами, включая или отключая их для своих пользователей;
кастомизировать внешний вид всего интерфейса через дизайн-токены, чтобы доска органично вписывалась в их продукт.
В итоге вся сложность — редактор, BFF для синхронизации, бэкенд на Go, базы данных — оставалась скрытой внутри нашего контура.

Мы отвечали за масштабируемость и безопасность, а потребители получали готовый продукт, который можно было встроить в свой сервис за считанные минуты.
Ну, теперь-то разрабатываем?
Итак:
требования собраны;
ядро выбрано (Blocksuite, Hocuspocus, find-my-way);
модель доставки определена (SaaS через умный SDK).
Кажется, все основные блоки на месте. Можно, наконец, выдохнуть и открыть IDE?
Давайте ещё раз посмотрим на схему, которая у нас вырисовывалась:

Как видите, наш будущий продукт — не монолит. Это, как минимум, три отдельные, хоть и тесно связанные, части:
BoardSDK — легковесный NPM-пакет для интеграции;
Application — основное приложение доски, которое живет внутри iframe;
BFF (Backend for Frontend) — наш сервер на Node.js, отвечающий за синхронизацию и API.
Всё это предстояло разрабатывать самостоятельно и одновременно. Но у нас не было трёх отдельных команд, которые могли бы параллельно пилить каждый компонент. Мы не могли себе позволить писать одну и ту же логику (например, для работы с данными доски или профилем пользователя) три раза для трёх разных частей нашего продукта, да и смысла в этом мало.
Нам нужен был подход, который позволял бы писать переиспользуемый код, подчиняющийся общим правилам и эффективно решающий задачи в рамках NPM-библиотеки, BFF и редактора.
Выбор архитектурного паттерна
Как фронтендеры, первым делом мы посмотрели в сторону FSD (Feature-Sliced Design). Давайте мысленно «натянем» его на нашу структуру:
BoardSDK? Это NPM-библиотека, там нет ни «страниц», ни «фич» в классическом понимании.
Что делать с Application? Да, это большой фронтенд-проект, но в нём тоже нет привычных «страниц». Это, по сути, один большой редактор.
И уж тем более было непонятно, что делать с нашим BFF на Node.js.
Говорят, что есть способы адаптировать FSD и для бэкенда, но это выглядело как попытка прогнуть нашу реальность под правила, которые для неё не предназначались. На мой взгляд, прогибаться должна методология, а не разработчики.

Поэтому мы вернулись к первоисточнику — Clean Architecture. Но мы не собирались слепо следовать каждому её канону, а взяли за основу ключевые принципы «чистой архитектуры»:
Разделение на слои.
Принцип инверсии зависимостей (Dependency Inversion).
Независимость от фреймворков, интерфейса и баз данных.
Ещё один важный момент — LLM. Берём всю информацию о проекте — требования, компетенции команды, технические аспекты и прочее (например, всё, что мы обсуждали до этого) — и скармливаем это ИИ, чтобы «обстучать» и продумать, найти слабые места и точки роста. В общем, именно то, что лучше всего подходит именно нам. ИИ даёт возможность использовать весь накопленный опыт человечества, вы не ограничены жёстким набором правил, и легко можете выбирать между любыми возможными. Только мой вам совет, держите ИИ в суперпозиции: просите быть беспристрастным и не делать выбор в пользу кого-то. Это универсальное правило, иначе KV Cache вас заборет.

На схеме:
Entities — бизнес-сущности (пользователь, доска и т. д.).
Shared — это не просто папка-помойка, а общий изоморфный код: утилиты, сервисы, инфраструктурные компоненты, которые не зависят от среды выполнения.
Проекты-сателлиты (BoardSDK, Application, BFF), которые используют код из Shared и добавляют свою, специфичную для их среды, логику (например, UI-компоненты в Application или роутинг в BFF).
Этот подход, основанный на принципах Clean Architecture, но адаптированный под наши нужды, и стал фундаментом, который позволил нам эффективно и параллельно разрабатывать все части нашего сервиса.
Как это работает?
Выбрав архитектуру «по мотивам» Clean Architecture, мы выбрали не просто красивый buzzword и правильное расположение файлов (как раскладывать файлики, решать вам), а принципы, на которых будем строить все части нашего проекта — инверсию зависимостей (DI) и независимость от фреймворков.
Пример №1: один сервис, два транспорта (HTTP и gRPC)
У нас есть BoardService, который отвечает за работу с досками (создание, получение и т. д.). Он нужен и в Application (на клиенте, чтобы ходить в бэкенд по HTTP), и в BFF (на сервере, чтобы общаться с основным бэкендом по gRPC).

Как написать его один раз? Мы использовали инверсию зависимостей.
В shared-слое создали BoardService. Он ничего не знает ни про HTTP, ни про gRPC. Он просто знает, что ему нужен некий API-provider, чтобы выполнить запрос.
Эта зависимость объявляется через токен — простой объект, который служит ключом в DI-контейнере.
-
А дальше начинается DI-магия:
В точке входа клиентского приложения мы говорим DI-контейнеру: «Когда кто-то попросит
BOARD_API_PROVIDER_TOKEN
, выдай емуBoardHttpProvider
».А в точке входа нашего BFF на Node.js мы говорим: «А здесь для того же токена нужно выдать
BoardGrpcProvider
».


В итоге, BoardService остаётся чистым и изоморфным. Мы меняем его поведение, просто подменяя зависимости в соответствии со средой. Это дало нам невероятную гибкость, позволив даже начинать разработку с провайдером-заглушкой (mock), пока реальные ещё не были готовы.
Пример №2: Право на ошибку, или Как архитектура спасает от… себя же
Поделюсь самой показательной историей из нашего проекта. Она о том, как правильно заложенные архитектурные принципы позволяют ошибаться. Потому что страшна не ошибка, а невозможность или непомерная стоимость её исправления.
Выбрав Blocksuite, мы получили и его технологический стек: веб-компоненты на Lit. Для ядра редактора это было нормально. Но мы понимали, что сервис будет обрастать фичами, интерфейс которых не связан напрямую с редактором. Для них у нас в компании используется VKUI, который построен на React. Возникает вопрос: «Как нам написать общую бизнес-логику один раз, но затем "подключить" её к Lit-компоненту и React-компоненту? Как сделать так, чтобы у разработчика был единый, понятный опыт работы, вне зависимости от того, для какого View-библиотеки он пишет UI?»
Тут я вспомнил про Signia, которую заметил при анализе TLDraw. Это сигнальная/реактивная библиотека для управления состоянием. Она отличалась от большинства подобных тем, что это clock-based реактивная система с глобальными часами и эпохами. Такой подход может быть очень эффективен в сложных сценариях с высокочастотными обновлениями — как раз наш случай с интерактивной доской. И библиотека была полностью фреймворк-независимой, что идеально подходило для нашей задачи связать Lit и React.
Особое доверие вызывало то, что Signia разрабатывали создатели Tldraw специально для своей доски. Задачи и проблемы, скорее всего, у нас будут те же :)
Однако тащить в проект незнакомый инструмент — это огромный риск. Нужен был план «Б». Поэтому мы добавили ещё один слой абстракции над Signia в виде Reactive API. Никаких лишних операторов и сложной магии:

Наш фасад предоставлял всего несколько основных строительных блоков:
Реактивные ячейки (reactive) для хранения атомарных значений.
Вычисляемые ячейки (reactive.computed) для производных данных.
Реактивные запросы (reactive.query) для обработки асинхронных операций, вроде запросов к серверу.
Декораторы для Lit и React, чтобы «подружить» нашу логику с View-слоем.
Все компоненты в проекте должны были работать только с этим API. Под капотом фасада жила Signia. Разработчикам даже не нужно было знать о её существовании.
На практике это выглядело примерно так:
// Ленивое начальное значение, вычисляется при первом обращении
export const username = reactive('username', () => '');
// Вычисляемая ячейка, которая зависит от username
export const greeting = reactive.computed(() => {
const name = username();
return name ? Hi, ${name}! : '';
});
Как это интегрировалось с нашими View-слоями:
Для React
Мы делали компонент «наблюдаемым» с помощью декоратора reactiveObserver
. Затем через хук useBoardContext
получали доступ к нашему DI-контейнеру и забирали из него нужные сценарии с бизнес-логикой.
// 1. Делаем компонент реактивным
export const ShareAttentionWidget = reactiveObserver(() => {
// 2. Получаем доступ к DI-контейнеру
const { useCases: { board } } = useBoardContext();
// 3. Читаем реактивное состояние
if (!board.currentHasPublicAccess) {
return null;
}
return (
<Card mode="shadow" className={cn.host}>
Доступ к этой Доске есть у всех пользователей
</Card>
);
});
Для Lit
Компонент оборачивали в декоратор @reactiveCustomElement
, а доступ к DI-контейнеру и сценариям получали точно так же.
// 1. Делаем компонент реактивным
@reactiveCustomElement(VK_COMMENTS_WIDGET)
export class VkCommentsWidget extends BlocksuiteWidgetElement {
// 2. Получаем доступ к DI-контейнеру
private boardThreads = useBoardContext(this).useCases.boardThread();
override render() {
// 3. Выполняем запрос и читаем состояние
const { data: threads } = this.boardThreads.getList();
const { draft, activeThreadId } = this.boardThreads;
// Рендерим UI с помощью html-литерала Lit
return html`
<div class="comments-container">
${_someRenderLogic(draft, activeThreadId, threads)}
</div>
`;
}
}
Как видите, логика работы с состоянием для разработчика была абсолютно одинаковой, вне зависимости от того, для React или Lit он писал компонент.
И самое главное — мы получили страховку, которая пригодилась. С Signia у нас, что называется, «не срослось»: в некоторых специфичных случаях она начала сбоить, поэтому я перешёл на план «Б».

Если бы я использовал Signia напрямую, то пришлось бы потратить дни или недели, вычищая её использование из десятков компонентов, переписывая логику, которая уже была бы тесно с ней связана. А так исправление моей же ошибки заняло буквально пару минут (не считая написания нового реактивного бэкенда ?).
И даже если бы Reatom нам не подошёл, у нас был наготове план «В»: проверенный временем и индустрией MobX.
Ценность архитектуры — в прагматичности. Мы не пытаемся предугадать всё и сразу. Вместо этого ищем точки роста и, особенно, риска: те места, где могут измениться бизнес-требования, или где мы не до конца уверены в выбранной технологии. Там мы сознательно закладываем возможности для расширения — будь то токен для Dependency Injection или фасад перед выбранной нами зависимостью (например, npm-библиотекой).
Всё это позволяет строить архитектуру инкрементально. Мы можем начать с простой реализации или даже «заглушки», а остальную часть системы спокойно строить вокруг неё. А когда придёт время, поменять только то, что находится за фасадом.
Контроль целостности архитектуры
Как убедиться, что вся команда будет следовать выбранной архитектуре?
Здесь важно уточнить: мы сознательно не стали строить «монорепозиторий» в том виде, который сейчас популярен во фронтенде: с PNPM/NPM-workspaces, NX и другими подобными инструментами, так как всё это — усложнение и дополнительный слой инфраструктуры, наличие которой можно обосновать только «модой». Мы выбрали простую плоскую структуру папок.

Но у этой простоты есть цена. Ключом к быстрой разработке была изоморфность кода. Нам было критически важно, чтобы код, предназначенный только для бэкенда, случайно не «протёк» в клиентское приложение, и наоборот. В лучшем случае это упало бы на стадии сборки. В худшем — всё бы разлетелось уже в бою.
Эта проблема напрямую связана с неконтролируемыми импортами. В большинстве проектов никто не следит за тем, откуда и что импортируется, пока что-то не сломается. Мы не могли себе этого позволить. Здесь надо отдать должное FSD, там эта проблема решена из «коробки» с помощью архитектурного линтера.
Наш подход более радикален: мы написали своё eslint-правило, которое по умолчанию запрещает любые импорты (локальные и npm deps). Чтобы что-то импортировать, это нужно явно разрешить.
Сначала мы определяем глобальные правила: что можно импортировать откуда угодно. Например, UUID или общие сущности из src/entities:
{
"target": "*",
"allow": ["uuid", "src/entities/*", "src/shared/*", "..."]
}
Дальше мы накладываем правила на взаимодействие слоев. Например, мы контролируем направление зависимостей, гарантируя, что низкоуровневые модули (такие как util) ничего не знают о существовании высокоуровневых (вроде framework или service). Это и есть принудительное соблюдение правила зависимостей (enforcing the dependency rule) на уровне кода, а не просто набор запретов в виде документации и битья по рукам на code review.
// Правило для всего нашего shared-слоя
{
"target": "src/shared/*",
"allow": [...], // Общие разрешения для shared
"restricts": [
{
// ЗАПРЕЩАЕМ импортировать "frameworks"...
"target": "src/shared/frameworks/*",
// ...из "utils"
"from": "src/shared/utils/*"
},
{
// ЗАПРЕЩАЕМ импортировать "services"...
"target": "src/shared/services/*",
// ...из "utils"
"from": "src/shared/utils/*"
}
]
}
Мы получили автоматизированного стража. Наша архитектура описана не в Confluence, а в коде, который проверяется на каждом коммите. Это гарантирует, что код остаётся изоморфным, а слои — изолированными.
Я считаю, что архитектурный линтер — это крайне недооценённый инструмент, причём как во фронтенде, так и на бэкенде. И что самое приятное, вам не обязательно писать это с нуля, есть готовые решения:
eslint-plugin-boundaries — мощнейший инструмент для контроля логики слоёв (кто кого может импортировать);
eslint-plugin-project-structure — для жёсткого контроля размещения файлов по папкам и их именования.
Кстати, на Хабре есть отличная статья, где подробно разобраны эти подходы, рекомендую.
Так получилась Доска v1.

Великий поворот
Первая версия сервиса получилась такой, какой мы её и проектировали: невидимой для конечного пользователя, без собственного «лица» или сайта, предназначенной для интеграции через SDK в другие продукты. Мы были довольны проделанной работой, всё шло по плану.
А потом, внезапно…

…компания на букву «М» объявила об уходе из России.
К нам прибежала куча людей из разных команд с одним и тем же вопросом:
— Вы же доску делаете! Где она? Как нам теперь ретро проводить?
Мы:
— Так вот же, у нас есть SDK, берите, интегрируйте.
— Не-не, какой SDK? Мы хотим готовый сайт, куда можно зайти и работать. Прямо сейчас.
Это стало проверкой на прочность всего, что мы сделали.
Напомню, что:
Мы не отдавали низкоуровневый API. Наш SDK уже умел рендерить не только доску, но и готовые куски интерфейса, вроде списка досок.
Мы не имели своей авторизации. Изначально спроектировали систему так, чтобы она могла работать с любой внешней авторизацией.
В результате мы сами стали потребителем своего же сервиса. Взяли BoardSDK, «прикрутили» VK ID в качестве внешнего провайдера авторизации, подняли домены (это заняло больше времени, чем сама разработка) и развернули самостоятельный продукт на основе функциональности BoardSDK.

Буквально за несколько дней превратились из SaaS-сервиса в полноценную, пользовательскую VK Доску.
Ресёрч нужен не для того, чтобы выбрать самую модную технологию, а чтобы ваш продукт был готов к трансформации. Чтобы, когда бизнес-требования внезапно изменяться, вам не пришлось переписывать всё с нуля, а можно было просто собрать из уже существующих кубиков то, что нужно рынку прямо сейчас.
Нюансы коллаборации
Мы спроектировали гибкую, контролируемую архитектуру. Но одно дело — спроектировать, и совсем другое — столкнуться с реальными проблемами, которые подкидывает сама природа совместной работы. Если вы создаёте подобные сервисы, то обязательно с ними столкнётесь.
Локально тестировать сервис с коллаборацией — это как играть в шахматы с самим собой. Причём один из вас периодически уходит в офлайн, двигает там фигуры и возвращается. Поэтому первое, что нужно сделать, — организовать стенд, который умеет мультиплицироваться, чтобы симулировать работу нескольких клиентов. Мучаться с разными браузерами и вкладками — прямой путь к потере времени и нервов.

Второй нюанс — подход к разработке. Ваш сервис должен быть построен по принципу local-first: любое действие пользователя сначала применяется локально, давая ему мгновенный отклик.
Изменения сохраняются в IndexedDB. Благо, CRDT-библиотеки вроде Yjs хранят все данные в бинарном виде, который идеально для этого подходит.
Между открытыми вкладками одного браузера изменения синхронизируются через BroadcastChannel.
И только потом, когда есть соединение, эти изменения улетают на сервер через WebSocket для синхронизации с другими участниками.

Весь ваш интерфейс должен быть построен на оптимистичных обновлениях.

Но самый важный нюанс — это работа с состояниями. У нас их четыре. Скорее всего, у вас будет столько же.
AppState. Это классическое состояние интерфейса конкретного клиента. Открыта ли боковая панель, какая вкладка активна — всё это здесь. Состояние ни с кем не синхронизируется (почти :)).
SessionState. Мы его ещё называем Global State. Это состояние, общее для всей сессии коллаборации: запущен ли таймер, какой фон у доски, включён ли режим readOnly. Состояние хранится в Redis, клиент не может менять его напрямую. Изменения проходят только через бэкенд после проверки прав.
Y.Doc. Это CRDT-состояние. Все фигуры, стрелки, текст, фреймы — всё содержимое, которое совместно редактируется. Сердце нашей «Доски».
Y.Awareness. Это состояние не пользователей, а клиентов (один пользователь может открыть 10 вкладок — это будет 10 клиентов). Здесь хранится «неважная» информация: координаты курсоров, имена над ними, текущие выделения элементов. Если эти данные пропадут, ничего страшного не произойдёт. Они нужны исключительно для улучшения пользовательского опыта.

Все эти четыре состояния хитро переплетены и синхронизируются через наш BFF, который выступает в роли дирижёра, обеспечивая целостность данных и их своевременное обновление.
Как приготовить CRDT и не прострелить себе колено
Можно расслабиться и писать код, как мы привыкли, верно? Увы, как я сказал ранее, CRDT не серебрянная пуля, поэтому нельзя работать с CRDT-состоянием так же, как вы привыкли в обычном фронтенде. Это прямой путь к потере данных и негативному пользовательскому опыту.
Вложенные структуры или «исчезающие» данные
Первое, что хочется сделать по привычке, — это создавать вложенные структуры. Например, для mind map сделать объект с полем children
, в котором будет массив дочерних узлов.

А теперь представьте ситуацию. Пользователь «А» уходит в офлайн и целый час усердно редактирует дочерние узлы (children). В это время пользователь «Б», который находится в онлайне, просто удаляет родительский узел «Holy». Пользователь «А» возвращается в онлайн и его данные исчезают, всё что он делал потерялось ?
Что произошло?
С точки зрения CRDT, всё честно. Изменения пользователя «А» будут корректно применены к объектам, которые были в children
, никаких конфликтов. Но родительская структура удалена.
С точки зрения пользователя, ваш сервис — го… эээ, плохой: работа просто исчезла с доски. И человек будет абсолютно прав.
Проблема не в CRDT, а в неправильно спроектированной структуре данных.
Решение: всегда старайтесь использовать плоские структуры. Вместо вложенности — ссылки по ID. Кроме того, всегда думайте не состояниями, а сценариями. Что будет, когда элемент, на который ссылаемся, исчезнет? А что будет когда он снова появится (undo/redo)? Всё это надо учесть в работе интерфейса, чтобы он тупо не сломался в неожиданном месте.
Работать с Y.Array сложно
В Yjs (и других решениях) есть тип Y.Array
, и очень велик соблазн использовать его там, где в обычном JavaScript вы бы использовали массив. Не делайте этого.
Работать с индексами в распределённой среде, где несколько клиентов (онлайн и оффлайн) могут одновременно добавлять и удалять элементы, — это ад. Вы никогда не можете быть уверены, что индекс, с которым вы работаете, всё ещё указывает на тот же элемент. Это приводит к совершенно непредсказуемому для всех поведению.

Решение: используйте этот тип только для самых простых, append-only сценариев. Во всех остальных случаях ваши лучшие друзья — это Y.Map
или Y.KeyVal
. Работать с уникальными ключами гораздо безопаснее и предсказуемее, чем с плавающими индексами.
CRDT-документ — это не только состояние, но и история
CRDT ничего не забывает. Каждое ваше изменение, каждое движение, если вы его записываете, остаётся в истории документа. Это нужно для разрешения конфликтов и истории.
Но из-за этого документ начинает неумолимо «пухнуть». Сначала это килобайты, потом мегабайты, потом сотни мегабайтов. В Issues на GitHub для Yjs висит реальный вопрос от человека, у которого документ разросся до гигабайта, а ведь нужно не только загрузить документ на клиент, но и на бэкенде держать в памяти, причем на всех подах/экземплярах.
Решение: минимизируйте и оптимизируйте изменения.
Группируйте изменения в транзакции. Любые множественные операции обёртывайте в
doc.transact(() => { ... })
. Это запишет их в историю как одно изменение.Throttle и Debounce ваши лучшие друзья. Например, не нужно записывать в CRDT каждое событие
mousemove
при перетаскивании элемента. Записывайте только финальную позицию (если нужно — ещё и промежуточные шаги).Оптимизируйте хранимые значения. Не храните значения по умолчанию, применяйте их в коде. Скорее всего, не нужна вся точность для чисел с плавающей запятой, хватит 2-3 знаков. Каждое такое решение уменьшает итоговый размер документа.
Будьте проще. Чем проще ваша структура данных, тем меньше она будет весить и тем меньше в ней будет потенциальных конфликтов и проблем.

Итого:
Структура данных должна быть описана полностью.
Используйте плоские структуры данных.
Старайтесь избегать связей между данным.
Избегайте
Y.Array
, используйтеYMap
иYKeyVal
.Группируйте изменения в транзакции.
Ограничивайте частоту изменений.
Оптимизируйте хранимые значения (например, числа с плавающей запятой).
Будьте проще ??♂️
Проблемы с башкой backend
После того, как мы превратились из встраиваемого сервиса в полноценную VK Доску, количество клиентов увеличилось кратно. И однажды мы увидели это:

У нас была телеметрия, но на всех графиках в какой-то момент начался бурный рост, выявить источник не удавалось. По классике, мы добавили ещё метрик, и стали ещё меньше понимать, что же происходит.
Было ясно, что проблема где-то в логике коллаборации, но локально мы её воспроизвести не могли, а стресс-тесты не показывали аномалий. Оставался последний, самый проверенный способ: включать профилировщик прямо в бою.
Однако проблема воспроизводилась только в моменты пиковой нагрузки, поэтому профиль получались огромным. Тот, кто уже занимался профилированием Node.js, наверняка знает, в чём здесь подвох. А для тех, кто не сталкивался, поясню: стандартные инструменты, включая Chrome DevTools, просто не «едят» большие профили. Как только размер файла переваливает за условные 500 мегабайтов, инструменты либо виснут намертво, либо падают.
Нашим спасением стал cpupro от Ромы Дворнова. Это офигенно крутой инструмент, который ест профили любого размера, даже битые.


Проблема оказалась не в коде, а в недосмотре. Мы выбрали Hocuspocus в том числе потому, что у него было расширение для масштабирования через Redis, но оказалось, что именно этот механизм работал плохо в условиях нашей реальной нагрузки.
Допустим, где-то начинается большое ретро. Команда приходит на одну доску. Kubernetes для балансирования нагрузки «размазывает» всех ровненько по всей ферме.

Дальше:
Человек на поде №1 начинает печатать. Его под получает обновление, применяет его к документу и публикует новое состояние в Redis.
Redis, работая как шина событий (event bus), рассылает это обновление всем остальным подам (№2, №3, №4...), потому что они все подписаны на этот канал на случай, если у них тоже есть клиенты этой доски.
Теперь человек на поде №2 делает то же самое. И цикл повторяется в обратную сторону.
И так происходило с каждым пользователем. Один отправил — все остальные получили. Нагрузка начала стремиться к квадратичной.
Получилась абсурдная ситуация: мы увеличивали количество подов, а становилось только хуже. По сути, мы сами себя DDoS-ли.
Вишенкой на торте была нагрузка от синхронизации Y.Awareness
(движения курсоров). Эти высокочастотные, но некритичные обновления тоже гонялись через Redis, заставляя каждый «под» постоянно кодировать и декодировать бинарные пакеты, создавать JS-объекты и напрягать сборщик мусора.
Решение напрашивалось само собой: а зачем вообще размазывать пользователей по ферме? Если все пришли на одну доску, то пусть они и соберутся на одном поде ??♂️.
Собираем всех вместе
Для этого существует элегантный и проверенный временем алгоритм — Consistent Hashing Ring.

Представьте себе циферблат часов, где вместо чисел — поды. Когда пользователь подключается к доске с определённым UID, мы вычисляем хеш от этого UID, смотрим, в какую точку на циферблате он попадает, и отправляем пользователя на ближайший под по часовой стрелке. Все пользователи с одним и тем же UID доски гарантированно попадут на один и тот же под. Проблему квадратичной нагрузки решили.
Но тут же возникла другая. Что происходит, когда ферма обновляется при развёртывании? Поды уходят, приходят новые, «циферблат» перестраивается, и в этот момент пользователей всё равно может временно раскидать по разным экземплярам. Возвращается старая проблема.
Мы придумали очень простое решение. Когда новый под поднимается после развёртывания, он пишет в Redis простое событие: «Я поднялся». Все остальные, уже работающие поды, слушают эти события. Получив такое сообщение, поды говорят своим клиентам через WebSocket: «Переподключитесь, пожалуйста, через ХХ минут».
Когда вся ферма стабилизируется, клиенты, получив отложенную команду, один раз переподключатся и, благодаря Consistent Hashing, соберутся в рамках нужного пода. Да, какое-то время они будут размазаны, но в итоге система сама себя сбалансирует.

Великое разделение
Вторую проблему — огромную нагрузку от синхронизации курсоров (Y.Awareness
) — мы решили ещё проще: вынесли её обработку на отдельную, «неважную» ферму.

Теперь каждый клиент устанавливает два WebSocket-соединения:
одно — с основной, «важной» фермой, которая обслуживает только сам документ (
Y.Doc
);второе — с фермой для
Awareness
, которая занимается только курсорами и выделениями.
Если вторая ферма упадёт под нагрузкой, то пользователи просто на время перестанут видеть курсоры друг друга. Главное, что документ будет продолжать редактироваться и сохраняться. Это классический паттерн изоляции, который защищает ядро системы от некритичных, но высоконагруженных компонентов.
Альтернативным решением могло бы быть соединение клиентов по WebRTC для обмена Awareness-данными напрямую, но готовых open-source решений на тот момент мы не нашли, а писать своё было некогда.
В итоге наш план выглядел так:
Выбираем под по UID с помощью Consistent Hashing.
Выносим обработку
YAwareness
в отдельную ферму.(В перспективе) Заменяем Redis pub/sub на Redis Streams, чтобы поды могли читать изменения с последней известной им позиции, а не получать всё подряд.
Тротлим и дебаунсим всё, что можем, на всех уровнях.
Вместо заключения
Мы потратили на ресёрч почти полтора месяца не потому, что у нас было много времени, а потому, что его было мало, и у нас не было права на ошибку.
Но ошибки — это не страшно. Страшно не иметь возможности их быстро исправить. Поэтому ресёрч — это, в первую очередь, менеджмент рисков и закладывание абстракций там, где они помогут вам в будущем. Это то, на чём действительно стоит сосредоточить свое внимание.
Как гласит народная мудрость:
«Сначала реши проблему, потом пиши код».