Привет! Никита Щербо на связи, backend-разработчик и тимлид в Битрикс24.

В этой статье расскажу, как мы переписывали один из самых старых и загруженных кусков продукта. Про архитектуру, обратную совместимость, миграцию миллионов записей, первый боевой запуск и про то, что происходит с командой в длинных проектах.

Как устроены задачи в Битрикс24

В Битрикс24 колоссальный объем процессов: 1500+ серверов, миллионы зарегистрированных компаний и 1,4+ млрд хитов в сутки. Задачи занимают в продукте первое или второе место по числу использования на корпоративных порталах. Такая популярность инструмента сильно усложняет изменение сервиса, ведь оно отражается на работе тысяч пользователей.

Сама старая карточка выглядела ровно так, как вы можете себе представить интерфейс 2010 года. Полноэкранная форма создания, редактор с тулбаром как в Word’е, комментарии внизу на манер форума, большие зеленые кнопки «Начать выполнение» и «Завершить». Под капотом: где-то POST-формы, где-то уже Ajax, верстка прямо в PHP-файлах, бизнес-логика плотно перемешана с представлением.

Почему мы решили поменять интерфейс

За 15 лет модель общения и в личной жизни, и на работе серьезно изменилась: люди привыкли решать вопросы через мессенджеры. Обсуждение задач в Битрикс24 было устроено по форумной модели, где пользователь пишет комментарий и закрывает карточку, и в лучшем случае получает уведомление об ответе через час. Для рабочих вопросов, которые нужно решать быстро, такой формат уже не подходит.

Чтобы изменить эту модель общения, нужно было кардинально переработать весь интерфейс сервиса. В новой карточке задачи слева остается сама задача с привычными чек-листами, диаграммой Ганта, связанными задачами, REST-интеграциями. А справа располагается чат, через который теперь идет вся коммуникация. От комментариев мы полностью отказались: их роль взяли на себя сообщения в чате.

Помимо продуктовых причин, была и чисто техническая: добавление любого нового функционала в старый код превратилось в сложную операцию с непредсказуемыми последствиями: зависимости нигде не задокументированы, тестов нет, логика размазана по десяткам файлов. Когда стоимость поддержки существующего кода превысила стоимость разработки нового, это стало главным аргументом в пользу полного переосмысления архитектуры.

Масштаб и таймлайн проекта

С идеей новой карточки продакты пришли к нам в конце января 2025 года. Релизы у нас проводятся два раза в год: в мае и в ноябре. Первоначальная цель была зарелизиться в мае 2025.

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

  • Малая форма — чистый поп-ап для быстрого создания задачи. По сравнению с формами, которым 15 лет, кажется, что из интерфейса убрали вообще все. Но в этом и была задумка: оставить минимум необходимых полей, чтобы быстрее переходить к работе.

  • Полная форма — полная карточка с чатом и другими функциями: чек-листы, связанные задачи, диаграмма Ганта, учет времени, пользовательские поля, подзадачи, REST-интеграции. Слева задача, справа чат.

Мы хотели выпустить обе формы в мае и даже планировали заранее показать большую карточку ключевым партнерам, чтобы они успели адаптировать свои решения. Но, реалистично оценив объем, от этой идеи отказались. Итоговый план звучал так: в мае выходит только малая форма, а релиз полной переносится на ноябрь.

Архитектура

Битрикс24 — это модульный монолит. Несмотря на 20 лет разработки, модули неплохо изолированы друг от друга. Самое интересное начинается на границах модулей, и в нашей задаче таких границ было достаточно много.

Наша задача формулировалась так: сохранить чистоту там, где она уже была, и принести ее туда, где ее никогда не было.

Два уровня событий

Один из способов общаться между модулями независимо — система событий. У нас получилось два уровня.

Межмодульные события работают через стандартный Main\EventManager. Классический Bitrix-механизм: модуль отправляет событие строкой с payload, другие модули подписываются через registerEventHandler в своем инсталляторе. Механизм обеспечивает прозрачность на границах модулей и через него на задачи реагируют другие модули и REST-приложения.

// tasks отправляет событие
$event = new \Bitrix\Main\Event(moduleId: 'tasks', type: 'OnTaskAdd', parameters: [
	'taskId' => $taskId,
]);
$event->send();

// модуль календаря подписывается в install/index.php
$eventManager->registerEventHandler(
	fromModuleId: 'tasks',
	eventType: 'OnTaskAdd',
	toModuleId: 'calendar',
	toClass: '\Bitrix\Calendar\Integration\TasksHandler',
	toMethod: 'onTaskAdd'
);

Внутримодульные события — наш собственный типизированный V2 EventDispatcher. Поработав внутри, мы пришли к выводу, что не весь процесс должен быть синхронным. Часть можно вынести в фон или очередь. Строковые ключи были снаружи, а внутри остались классы событий, DI и три режима диспатча: синхронный, фоновый и через очередь. Подписка объявляется атрибутом прямо на классе события, что дает хорошую читаемость:

// Событие объявляет своего слушателя прямо на себе
#[ListenBy(OnTaskMuted\ChatSync::class)]
class OnTaskMutedEvent extends Event
{
	public function __construct(
		public readonly Entity\Task $task,
		public readonly Entity\User $user,
	) {}
}

Обратная совместимость: главная проблема всего проекта

Довольно быстро мы поняли, что главная сложность всего проекта сосредоточена в сторонних интеграциях, которые нужно было сохранить в рабочем состоянии. Чтобы объяснить, почему это непросто, нужно пояснить, как устроен Битрикс24.

Продукт существует в двух форматах: облако и коробка. Коробку клиенты разворачивают у себя сами и могут делать с исходниками что угодно (кроме изменения файлов ядра). В итоге, минуя публичный API и систему событий, сложилась практика обращения напрямую к методам ядра, иногда очень старого, написанного еще тогда, когда систем контроля версий не было. Так сказать, заходили в любую открытую дверь.

Просто взять и переписать все «по красоте» — нельзя. Придут недовольные клиенты, у которых годами работало какое-то кастомное решение, которое мы внезапно сломали. Поэтому весь рефакторинг строился на принципе проксирования: старые публичные классы и точки входа сохраняют прежние сигнатуры, но внутри сохраняется только маппинг и вызов новых сервисов.

Маппинг, кстати, иногда был очень веселым. Входной параметр — это массив, черный ящик. Туда могут приходить ключи, которые даже не задекларированы в документации, но транзитивно используются через десяток событий где-то в глубине, и если их не передать, почему-то создаются две задачи. Почему так — тайна, которую хранит история без коммитов. 

public function add(array $fields): TaskObject
{
	$this->reset();

	$config = new AddConfig(
		userId: $this->userId,
	// ...
	);

	$mapper = Container::getInstance()->getOrmTaskMapper();
	$service = Container::getInstance()->getAddTaskService();

	$entity = $mapper->mapToEntity($fields, $this->skipTimeZoneFields);

	$entity = $service->add(
		task:   $entity,
		config: $config,
	);

	return $mapper->mapToObject($entity);
}

В итоге контракт не нарушен: принимаем то, что принимали, возвращаем то, что возвращали. А в середине добавлен вызов нового слоя. Весь поток выполнения кода ведет в новое API. Партнеры не ломают голову, да и мы тоже.

Три слоя модуля

Параллельно в компании разрабатывалась типовая архитектура модуля. Ее задача — стандартизировать то, как разные команды пишут код, чтобы разработчик, перейдя из одной команды в другую, примерно понимал, что где лежит.

Модуль делится на три слоя:

  • Infrastructure — внешний слой: контроллеры, компоненты, REST, агенты. Обрабатывают запросы из внешнего мира.

  • Public — публичные классы, к которым обращаются Infrastructure-слой и другие модули.

  • Internal — внутренняя бизнес-логика, доступная только из Public-слоя.

Правило простое: каждый слой может обращаться только вглубь. Контроллер вызывает Public, Public вызывает Internal и никак не наоборот. Благодаря этому бизнес-логика в Internal понятия не имеет, откуда пришел запрос: из веба, из консоли или из очереди. Это позволяет держать код в порядке и переиспользовать одну бизнес-логику из любой точки входа.

Так выглядел полный путь запроса на примере обновления задачи:

// Infrastructure/Controller/Task.php — точка входа
public function updateAction(
	#[Permission\Update]   // проверка прав (об этом дальше)
	Entity\Task $task,
): ?Arrayable
{
	// ...
	return (new UpdateTaskCommand($task, $config))->run();
}

// Public/Command/Task/UpdateTaskCommand.php
class UpdateTaskCommand extends AbstractCommand
{
	public function __construct(
		#[Validatable]             // валидация (об этом тоже дальше)
		public readonly Entity\Task $task,
	) {}

	protected function execute(): Result
	{
		// ...
		return $result;
	}
}

Каждый элемент цепочки отвечает за свою часть: контроллер принимает запрос, команда передает данные хендлеру, хендлер оркестрирует сервисы с бизнес-логикой, репозиторий сохраняет результат в БД.

Архитектура вдохновлена идеями CQRS, которые мы адаптировали под реалии 15-летнего монолита. В итоге важнее любых паттернов оказалось то, что все разработчики пишут код в одном стиле.

Валидация через атрибуты

Валидация в старом коде была рутинной проблемой: добавил эндпоинт, забыл проверить входные данные, получил неприятную ошибку в лог, потому что клиент отправил мусор через REST. 

В новой архитектуре валидация строится на атрибутах. Декларативно, переиспользуемо, и если нет подходящего валидатора — легко написать свой:

class Task extends AbstractEntity
{
	public function __construct(
		#[NotEmpty(allowZero: true)]
		public readonly ?string $title = null,
		#[NotEmpty]
		#[Validatable] // валидирует вложенный объект
		public readonly ?User $creator = null,
		#[Validatable]
		public readonly ?UserCollection $accomplices = null,
		#[NewFiles] // кастомный валидатор
		public readonly array $fileIds,
	) {}
}

Права доступа через атрибуты

Похожая история с правами. В задачах это особенно критично: очень неприятно, когда кто-то видит задачи, которые не должен был видеть. Раньше каждый метод контроллера начинался одинаково: достать объект задачи (а точек входа в его получение было огромное количество), проверить права, бросить исключение. Copy-paste с вариациями.

// Было
public function updateAction(int $taskId): array
{
	$task = Task::getById($taskId);
	if (!$this->accessController->check(
		ActionDictionary::ACTION_TASK_UPDATE, $task
	)) {
		throw new AccessDeniedException();
	}
	// ... бизнес-логика
}

Теперь права проверяются до входа в метод, а при отсутствии доступа до бизнес-логики не доходим вообще. Получилось целое семейство атрибутов: Permission\Read, Permission\Update, Permission\Delete, Deadline\Permission\Update, Attachment\Permission\Attach, Status\Permission\Complete и другие.

// Стало — атрибут на параметре контроллера
public function updateAction(
	#[Permission\Update]
	Entity\Task $task,
): ?Arrayable { /* только бизнес-логика */ }

public function getAction(
	#[Permission\Read(returnTrueIfNotExist: true)]
	Entity\Task $task,
): ?Entity\Task { /* ... */ }

public function deleteAction(
	#[Permission\Delete]
	Entity\Task $task,
): ?bool { /* ... */ }

Service Locator: ближе к DI

Стандартный Bitrix Service Locator работает через строковые ключи. Мы его доработали и почти дотянули до DI: ключом служит интерфейс, класс собирается рекурсивно. Каждый модуль хранит свою конфигурацию в services.php:

TaskRepositoryInterface::class => [
	'className' => InMemoryTaskRepository::class,
],

// Условная резолюция по feature-флагу
ProjectCollector::class => [
	'constructor' => fn() => FormV2Feature::isOn()
		? new V2\ProjectCollector()
		: new Legacy\ProjectCollector(),
],

В итоге IDE видит типы, рефакторинг обходится без магических строк, реализации легко подменяются через feature-флаги, а зависимости задокументированы прямо в коде.

Однако пока мы разбирались с архитектурой, параллельно нужно было решить еще одну задачу, которая никуда не девалась: что делать с миллионами старых задач, у которых чатов не было.

Миграция комментариев в чаты

С данными самой задачи все в порядке — название, описание, исполнитель остаются на месте. Главная проблема была в другом: у старых задач никогда не было чата, и его нужно было создать и привязать к каждой задаче в момент, когда ее кто-то открывает.

Требование от бизнеса было простым: пользователь не должен видеть никаких индикаторов загрузки или сообщений о конвертации данных. Все должно происходить незаметно, без остановки сервиса. Аптайм у нас высокий, и мы этим дорожим.

Новые задачи

Для новых задач флоу линейный. Сначала мы на своей стороне создаем задачу, а затем передаем управление в IM-модуль через addUniqueChat, который гарантирует идемпотентность: если чат уже есть, новый не будет создаваться. Потом сохраняем связь в отдельную таблицу.

Handler::__invoke() → Chat::addChatByTaskId → ChatRepository::save

// Chat::addChatByTaskId — создаем чат через IM-модуль
ChatFactory::getInstance()->addUniqueChat([
	'TITLE' => $task->title,
	'ENTITY_TYPE' => 'TASKS_TASK',
	'ENTITY_ID' => $task->getId(),
	'USERS' => $task->getMemberIds(),
	'AUTHOR_ID' => $task->creator->id,
]);
// Сохраняем связь: INSERT INTO b_tasks_task_chat (TASK_ID, CHAT_ID)

Старые задачи: прогрев и ленивая конвертация

Для старых задач мы сделали два механизма.

Прогрев. Перед включением фичи на портале запускался фоновый джоб MigrateRecentTaskBackgroundJob. При заходе пользователя он брал 10 последних задач по активности (ACTIVITY_DATE) и принудительно создавал для них чаты через очередь. Почему именно 10? По нашей статистике, возвращаясь к работе, пользователь с высокой долей вероятности откроет одну из этих десяти задач. Тогда создание нового чата в момент запроса не происходит.

public function __invoke(int $userId, int $limit = 10): void
{
	if ($this->hasEnoughTasksWithChat($userId, $limit))
	{
		$this->markAsProcessed($userId);

		return;
	}

	foreach ($this->getTasks($userId, $limit) as $task)
	{
		if (!empty($task['TASKS_INTERNALS_TASK_CHAT_TASK_CHAT_ID']))
		{
			continue;
		}

		$this->sendMessageToQueue((int)$task['ID']);
	}

	$this->markAsProcessed($userId);
}

Ленивая конвертация. У большинства пользователей задач намного больше десяти. Для всех остальных случаев логика живет прямо в TaskProvider::prepareTask(). При каждом обращении к задаче из любого источника в синхронном режиме проверяется наличие чата и при необходимости создается новый.

// TaskProvider::prepareTask()
if ($task->chatId === null)
{
	return $this->...->createChatForExistingTask($task);
}

Синхронно именно потому, что чат — главная часть новых задач. Туда идут системные сообщения и обновления, пока пользователь видит просто открытую задачу.

Ветвление в старом коде

Долгое время в продакшене жили два API параллельно. В старом коде — ветвления по feature-флагу. Пример с добавлением комментариев: если фича включена, комментарий отправляется как сообщение в IM-чат, если нет — сохраняется как форумный комментарий.

// classes/general/commentitem.php
if (FormV2Feature::isOn('', $task->group?->id))
{
	return self::addCommentInChat($taskParams, $arFields);  // V2: сообщение в IM-чат
}

return self::add($task, $arFields); // Legacy: форумный комментарий

Это вынужденная избыточность. Многие кастомные решения зависели от комментариев задачи, и если бы мы убрали поддержку без переходного периода, многие пользователи потеряли бы важную информацию. Когда раскатились на всех и переходный период закончился, стало легче.

Первый боевой тест: корпоративный портал

Мы сами используем задачи, чаты и встречи в Битрикс24 внутри компании, поэтому в качестве аудитории для первого боевого теста были выбраны сотрудники нашей компании. Самое неприятное в таком тесте то, что все недовольные точно знают, где тебя найти.

Назвать этот запуск идеальным язык не повернется. Половина задач не открывалась и возвращала 502 Bad Gateway. У остальных карточка открывалась пустой: задача могла быть в работе месяцами, а в чате не было ничего, история общения просто исчезла.

Проблемы пользователей делились на два типа. Часть была связана с качеством реальных данных: мы и сами иногда были удивлены тем, что хранилось в таблицах нашего корпоративного портала. Другая часть упиралась в концепцию и бизнес-логику: смотришь на ошибку и понимаешь, что если залатать ее наспех, через несколько лет другая команда столкнется с теми же граблями. Поэтому мы стали фундаментально исправлять все проблемные моменты, даже если это занимало больше времени.

Раскатка на клиентов

После корпоративного портала начали включать карточку клиентам: порционно, по тарифным группам и внутри каждой группы небольшими процентами. Сначала 5% Enterprise-клиентов, потом 10% профессиональных тарифов и так далее. После каждого шага заходили в мониторинг, смотрели на ошибки и производительность, получали обратную связь, исправляли и двигались дальше.

Когда изменения дошли до широкой аудитории, начался всплеск обращений в поддержку. Самый частый вопрос первых двух недель звучал так: «Здравствуйте, как вернуть старый дизайн задач?». Конечно, это было невозможно.

Огромную работу здесь сделали коллеги из техподдержки, продукта, маркетинга и QA: они объясняли концепцию и помогали пользователям понять, что интерфейс не стал хуже, он просто стал другим. Через два месяца это подтвердилось на практике: люди привыкли, количество обращений снизилось, и стало понятно, что общаться через чат действительно удобнее, чем ждать форумных комментариев.

Системные интеграторы и кастомные решения

Была еще одна группа пользователей, которая столкнулась с рядом сложностей раньше остальных: интеграторы с кастомными решениями на коробочной версии. Часть из них использовала публичный API и систему событий, как и предполагалось, однако другие обращались напрямую к таблицам базы данных, минуя любые все протоколы.

Как делали

Как надо было

SELECT * FROM b_tasks WHERE ID = 123

$taskProvider->get(123);

// через публичный API

Когда мы начали менять структуру хранения данных, это ударило по их решениям. Формально это их код и их ответственность, но оставлять людей разбираться в одиночку было плохой идеей. Поэтому мы постарались максимально облегчить их работу и помогали по всем направлениям: писали документацию, отвечали на вопросы, объясняли, как теперь правильно получать данные через API.

Мы в очередной раз убедились, что даже хорошо написанный код не существует в вакууме: если вокруг продукта сложилась экосистема интеграций, она становится частью твоей ответственности.

Команда и 9 месяцев разработки

Заниматься одним проектом 9 месяцев бывает тяжело. В сервисе все это время используется старая версия, на нее прилетают баги, и пользователи даже не знают о разработке новой. В такие моменты внутри команды периодически возникает вопрос: что мы вообще делаем и зачем?

Конфликтов у нас не было, никто не ушел, но давление ощущалось всеми. Чтобы не выгореть в угли, у нас был ряд форматов, на которых можно было обсудить все что мешает работе и найти решение, так сказать три кита нашей продуктивности.

Ретроспектива. В длинных проектах она перестает быть формальностью и становится рабочим инструментом. Это единственный способ вовремя услышать, что у людей болит и что мешает в процессах, пока напряжение не накопилось критически.

Встречи один на один. Мы проводили их раз в две недели, индивидуально с каждым участником команды. Говорить о своем состоянии на общих встречах готовы не все, и в длинных проектах важно замечать усталость раньше, чем человек сам об этом скажет.

Рабочие синхронизации. Регулярные встречи помогали быстро реагировать на проблемы: на них обсуждали, что конкретно мешает работать, что блокирует и чем можно помочь прямо сейчас.

Отдельно стоит рассказать про распределение задач. У каждого разработчика есть свое чувство прекрасного, и все устают от поддержки старого кода. Если человек долго занимается тем, что ему неинтересно, и не видит результата своей работы, мотивация неизбежно снижается. Иногда достаточно просто спросить, какую задачу разработчик хотел бы взять. Звучит банально, но если человек просто возьмет в работу скучную задачу, через месяц это выльется в профессиональную усталость, а в таких проектах это обходится очень дорого.

Итог

Мы переписали один из самых старых и загруженных кусков продукта, внедрили типовую архитектуру модуля, которой теперь пользуются другие команды, зарелизились в срок и пережили волну негатива от пользователей.

Если коротко про технические решения, которые это сделали возможным: типизированные события с классами вместо строковых ключей, трехслойная архитектура с явными границами между слоями, валидация и проверка прав доступа через атрибуты, проксирование публичного API поверх новых сервисов, а также прогрев и ленивая конвертация для миграции данных без остановки сервиса.

Если у вас есть вопросы по архитектурным решениям или по тому, как мы организовали миграцию — пишите в комментарии, будем обсуждать!