golang

Четыре релиза без единой новой функции. Почему иногда архитектура важнее новых возможностей

  • вторник, 30 июня 2026 г. в 00:00:26
https://habr.com/ru/articles/1053470/

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

Но следующие восемь дней разработки неожиданно пошли совсем по другому пути.

За это время вышли четыре релиза: 0.9.50, 0.9.51, 0.9.52 и 0.9.53. Если открыть CHANGELOG, создаётся ощущение, что проект серьёзно продвинулся вперёд. Но если открыть сам Messenger, пользователь почти не увидит новых возможностей. Личные сообщения, группы, вложения, аватары, редактирование и удаление сообщений - всё это уже существовало раньше.

Возникает вполне логичный вопрос: чем тогда вообще были заняты эти восемь дней?

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

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

Так несколько сравнительно небольших задач постепенно превратились в пересмотр ключевых частей системы.

Когда два микросервиса продолжают жить как один

К моменту выхода предыдущей статьи Pulse уже состоял из двух самостоятельных компонентов: Pulse Messenger и Pulse Media Core. Формально задача была решена. Изображения обслуживал отдельный сервис, а Messenger обращался к нему по HTTP API.

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

Но довольно быстро выяснилось, что физического разделения недостаточно.

Messenger всё ещё слишком много знал о внутреннем устройстве Media Core. Он сам определял, какие endpoint вызывать, понимал структуру Media DTO, формировал ownership изображений, работал с metadata и постепенно начинал принимать решения, которые должны были принадлежать самому медиа-сервису.

Упрощённо это выглядело так:

MessageService
        │
        ├── POST /v1/images
        ├── GET /v1/media/meta
        ├── GET /v1/avatars
        └── ...

На первых этапах разработки такая схема казалась разумной. Оба сервиса развивались одновременно, а разработчик у них был один. Пока API менялся синхронно, проблема почти не ощущалась.

Но стоило немного изменить модель хранения изображений или контракт одного из endpoint, как Messenger тоже приходилось менять. Получалось, что внутреннее устройство одного сервиса начинает диктовать архитектуру другого.

Именно здесь стало понятно: настоящая граница между микросервисами - это не HTTP. HTTP - всего лишь транспорт. Настоящая граница - это контракт.

Если один сервис знает внутреннюю модель другого, реальной независимости между ними нет.

Поэтому следующим шагом появился отдельный MediaClient.

Сначала это выглядело как обычная обёртка над HTTP, но довольно быстро стало ясно, что его роль шире. Именно этот слой должен знать, как Messenger авторизуется перед Media Core, какие заголовки передаются, как формируются запросы, как обрабатываются ошибки и как внешний контракт Media Core превращается во внутреннюю модель Messenger.

type Client struct {
	baseURL      string
	serviceName  string
	serviceToken string
}

func NewClient(
	baseURL string,
	serviceName string,
	serviceToken string,
) *Client {
	return &Client{
		baseURL:      baseURL,
		serviceName:  serviceName,
		serviceToken: serviceToken,
	}
}

После этого MessageService перестал напрямую работать с HTTP API Media Core. Взаимодействие стало выглядеть так:

MessageService
        │
        ▼
MediaClient
        │
        ▼
Pulse Media Core

Важным оказалось не само появление дополнительного слоя, а то, какие знания в него переехали. Например, сервисная аутентификация теперь больше не размазана по бизнес-логике Messenger. Она живёт внутри клиента:

func (c *Client) applyTrustedServiceHeaders(req *http.Request) {
	if c.serviceName == "" || c.serviceToken == "" {
		return
	}

	req.Header.Set("X-Pulse-Service-Name", c.serviceName)
	req.Header.Set("X-Pulse-Service-Token", c.serviceToken)
}

На уровне архитектуры это маленькое изменение оказалось очень важным. Теперь Messenger больше не должен помнить, как именно Media Core отличает доверенный сервис от обычного пользователя. Для Messenger это просто вызов клиента. Для Media Core - полноценная service-to-service аутентификация.

Именно после этого стало проще двигаться дальше, потому что следующая проблема оказалась уже не в HTTP, а в модели владения изображениями.

Когда владелец изображения оказался выбран неправильно

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

owner_type = user
owner_id   = user_id

На первый взгляд это абсолютно логично. Пользователь загрузил файл - значит, он владелец.

Но эта логика начинает ломаться, как только изображение становится частью сообщения.

Например, пользователь отправляет изображение в групповой чат. Через несколько секунд другой участник открывает историю сообщений. Третий получает это сообщение через realtime. Позже отправитель может покинуть группу, удалить сообщение или вообще потерять доступ к диалогу.

И тут возникает вопрос: кто должен иметь право получить metadata этого изображения?

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

Проблема была не в проверках. Проблема была в неправильной сущности-владельце.

Изображение в сообщении больше не является просто файлом пользователя. Оно становится частью разговора. А значит, доступ к нему должен определяться доступом к самому диалогу.

Поэтому модель изменилась:

owner_type = conversation
owner_id   = conversation_id

И в коде это стало выглядеть уже так:

result, err := h.media.UploadImage(
	header.Filename,
	data,
	conversationID,
	media.OwnerConversation,
)

На первый взгляд изменение небольшое: вместо userID теперь передаётся conversationID, а owner type меняется на conversation.

Но архитектурно это совсем другая модель. Теперь изображение принадлежит не отправителю, а самому диалогу. Если пользователь имеет доступ к conversation, он имеет доступ и к вложениям этого conversation.

Это убрало необходимость в специальных fallback-сценариях и сделало модель доступа значительно естественнее.

Иногда архитектурная проблема решается не добавлением новых проверок, а выбором более правильной сущности в модели данных.

В этом случае такой сущностью оказался не пользователь, а диалог.

Когда проблема оказалась не в REST, а во времени

После интеграции с Media Core я был практически уверен, что самые сложные архитектурные изменения уже позади. В roadmap оставалась задача, которая выглядела довольно безобидно: немного привести в порядок загрузку истории сообщений.

На тот момент механизм уже работал. Пользователь открывал диалог, Messenger отправлял REST-запрос, сервер возвращал сообщения, история отображалась на экране. Всё выглядело вполне корректно.

Проблема проявлялась только при одном сценарии.

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

Именно здесь происходила довольно неприятная вещь.

Первый REST-запрос всё равно успешно завершался. Для backend никакой ошибки не существовало. Он честно выполнил запрос и вернул корректную историю сообщений.

Но клиент к этому моменту уже жил совершенно в другом состоянии.

Получалось, что абсолютно правильный ответ сервера становился неправильным ответом для пользователя.

Долгое время я пытался искать проблему совершенно не там. Проверял SQL-запросы, перепроверял сортировку сообщений, несколько раз пересматривал обработчики WebSocket. Всё выглядело правильно.

Пока однажды не пришёл к довольно неприятному выводу.

Я молча предполагал, что любой успешно завершившийся REST-запрос автоматически остаётся актуальным.

Это оказалось неправдой.

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

После этого сама логика загрузки истории начала строиться вокруг совершенно другого вопроса.

Актуален ли этот ответ прямо сейчас?

Именно тогда внутри клиента появилось понятие поколения запросов.

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

const generation = historyGenerationRef.current + 1;
historyGenerationRef.current = generation;

const requestConversationId = conversationId;

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

Поэтому после получения ответа Messenger больше не спешит обновлять интерфейс.

Сначала он проверяет, относится ли ответ к тому состоянию приложения, в котором этот запрос вообще был создан.

if (
  historyGenerationRef.current !== generation ||
  requestConversationId !== conversationId
) {
  return;
}

Если хотя бы одно условие перестало выполняться, история просто игнорируется.

Не потому, что сервер ошибся.

А потому, что время уже изменило состояние приложения.

Наверное, именно это изменение сильнее всего повлияло на моё понимание frontend-разработки.

Раньше мне казалось, что React-компонент - это место, где нужно красиво отобразить данные.

После этого рефакторинга стало понятно, что современный frontend - это ещё и управление временем.

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

Получается небольшая распределённая система прямо внутри браузера.

Именно поэтому следующим шагом появилась ещё одна вещь, которой раньше вообще не существовало.

Повторная загрузка истории.

Раньше любой сетевой сбой означал ошибку. Пользователю оставалось только попробовать ещё раз вручную.

После рефакторинга сама загрузка истории стала значительно устойчивее. Теперь временные проблемы сети воспринимаются как временные, а не как окончательная ошибка.

Для этого внутри runtime появился небольшой механизм повторных попыток.

async function fetchHistoryPageWithRetry(
  params: Parameters<typeof fetchHistoryPage>[0],
  attempts = 2,
): Promise<HistoryMessageDTO[]> {
  let lastError: unknown = null;

  for (let attempt = 1; attempt <= attempts; attempt += 1) {
    try {
      return await fetchHistoryPage(params);
    } catch (err) {
      lastError = err;

      if (attempt < attempts) {
        await wait(HISTORY_RETRY_DELAY_MS);
      }
    }
  }

  throw lastError;
}

Самое интересное, что после всех этих изменений useConversationHistory перестал ощущаться обычным React Hook.

У него появилось собственное внутреннее состояние:

  • Появились поколения запросов.

  • Появились курсоры.

  • Появились правила переходов между состояниями.

  • Появились ограничения, которые запрещают выполнять заведомо некорректные операции.

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

Именно тогда стало понятно, что похожая проблема постепенно начинает появляться и в других частях приложения.

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

Следующим кандидатом неожиданно оказался... сам ConversationHandler.

Почему одного ConversationService оказалось недостаточно

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

В roadmap оставалась довольно обычная задача - немного привести в порядок ConversationService и убрать несколько прямых вызовов Repository из HTTP-обработчиков.

На тот момент это выглядело как типичный рефакторинг.

Как оказалось - совершенно нет.

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

Если смотреть на каждый endpoint отдельно, всё выглядело вполне логично.

Проблема проявлялась только тогда, когда смотришь на Handler целиком.

В какой-то момент я открыл файл и понял, что HTTP-обработчик знает практически всё устройство подсистемы диалогов:

  • Он знает, какой Repository вызвать первым.

  • Какой - вторым.

  • В каком порядке проверить права доступа.

  • Когда необходимо подключить MessageService.

  • Когда нужно обратиться к ConversationPinRepository.

  • Когда следует получать metadata.

Получалось примерно вот так.

type ConversationHandler struct {
	messageRepo      *repository.MessageRepository
	messageService   *service.MessageService
	conversationRepo *repository.ConversationRepository
	pinRepo          *repository.ConversationPinRepository
	accessService    *service.ConversationAccessService
	authUserID       RequireAuthFunc
}

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

Появилась новая возможность - добавился ещё один вызов.

Понадобилась новая проверка - появился ещё один сервис.

Через несколько недель выяснилось, что HTTP-слой постепенно начал превращаться в координатора всей подсистемы диалогов.

Именно здесь стало понятно, что проблема вообще не в количестве зависимостей.

Проблема была в направлении зависимостей.

HTTP-обработчик не должен знать, каким образом строится история сообщений.

Не должен понимать, какие Repository участвуют в получении результата.

Не должен принимать решения о последовательности выполнения бизнес-логики.

Его задача значительно проще.

Получить HTTP-запрос.

Передать его соответствующему сервису.

Вернуть ответ клиенту.

Всё остальное должно происходить значительно глубже.

Так появился ConversationQueryService.

После этого ConversationHandler неожиданно стал выглядеть вот так.

type ConversationHandler struct {
	queryService *service.ConversationQueryService
	authUserID   RequireAuthFunc
}

На первый взгляд исчезло всего несколько полей структуры.

На практике изменилось значительно больше.

Теперь Handler вообще не знает, каким образом получается история сообщений.

Он не знает, требуется ли сначала проверить доступ к conversation.

Не знает, сколько Repository участвует в выполнении операции.

Не знает, нужно ли после получения сообщений дополнительно подгружать metadata изображений.

Он знает только одну вещь.

Существует сервис, который умеет вернуть историю диалога.

Раньше сам Handler принимал решения примерно так:

allowed, err := h.accessService.CanAccessConversation(
	currentUserID,
	conversationID,
)

history, err := h.messageRepo.GetHistoryByConversation(
	conversationID,
	limit,
	before,
	currentUserID,
)

history, err = h.messageService.AttachMediaToMessages(history)

После появления ConversationQueryService всё это исчезло.

Весь HTTP-обработчик свёлся к одной операции.

history, err := h.queryService.GetHistory(
	currentUserID,
	conversationID,
	limit,
	before,
)

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

Настоящая задача сервисного слоя - скрыть знания о том, как несколько компонентов превращаются в одну законченную бизнес-операцию.

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

Именно здесь стало понятно, что похожая история начинает повторяться уже не только на беке.

На фронте постепенно происходило то же самое. Только теперь слишком много начал знать уже не HTTP-обработчик. Слишком много начал знать сам React.

Когда React-компонент неожиданно начал превращаться в операционную систему

После появления ConversationQueryService backend неожиданно стал значительно проще. HTTP-обработчики похудели, а сервисный слой наконец занялся тем, ради чего вообще существует.

Я обратил внимание на другую проблему. Если бек постепенно становился проще, то на фронте происходил прямо противоположный процесс.

Основная часть логики продолжала жить внутри MessengerPage.

На первых этапах разработки это выглядело совершенно естественно. Компонент открывал диалоги, загружал историю сообщений, создавал WebSocket, подписывался на realtime-события, синхронизировал список последних переписок, обновлял непрочитанные сообщения, следил за текущим разговором и восстанавливал состояние после reconnect.

Пока возможностей было немного, подобная схема вообще не вызывала вопросов.

Но примерно к версии 0.9.50 стало происходить то же самое, что несколькими днями раньше произошло с ConversationHandler.

Практически каждая новая возможность требовала изменить MessengerPage, а именно:

  • Появилась загрузка истории - ещё один useEffect.

  • Добавились закреплённые сообщения - ещё несколько состояний.

  • Понадобилась синхронизация recent conversations - новый callback.

  • Затем появились новые устройства.

  • Восстановление после reconnect.

  • Повторная загрузка истории.

  • Обновление списка непрочитанных сообщений.

  • Каждая новая возможность выглядела абсолютно логичной.

Но однажды я открыл файл и понял, что компонент начинает знать слишком много.

Самое интересное заключалось даже не в размере файла.

Проблема была значительно глубже.

Большая часть этой логики вообще никак не связана с отображением интерфейса. React-компонент постепенно начал отвечать за жизненный цикл приложения. Именно тогда стало понятно, что проблема полностью повторяет историю с backend. Если раньше слишком много начал знать HTTP-обработчик, то теперь слишком много начал знать React.

Первым кандидатом на переезд стал процесс открытия диалогов.

До этого момента практически весь сценарий жил внутри MessengerPage:

  • Найти пользователя.

  • Получить conversation.

  • Создать его при необходимости.

  • Обновить локальное состояние.

  • Зарегистрировать диалог.

  • Открыть conversation.

  • Обновить store.

Компонент буквально пошагово управлял всей операцией.

После рефакторинга эта последовательность исчезла.

Появился отдельный ConversationService.

Теперь компоненту достаточно выполнить всего одну операцию.

const result = await openConversation(user, item);

А всё остальное происходит значительно глубже.

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

Получилось довольно интересное изменение.

Количество строк кода практически не изменилось.

Но изменилось место, где теперь живут знания о процессе открытия диалога.

Следующим кандидатом оказался список последних переписок.

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

  • Пришло новое сообщение?

  • Загружаем recent.

  • Восстановился WebSocket?

  • Загружаем recent.

  • Пользователь вернулся во вкладку?

  • Снова загружаем recent.

  • Подняли трубку?

  • Набрали номер... :)

Параллельно практически везде происходило то же самое с непрочитанными сообщениями.

Получалось, что одинаковая последовательность вызовов постепенно копировалась сразу в нескольких местах приложения.

Именно тогда появился небольшой сервис conversationSync.

По сути он делает очень простую вещь.

export async function refreshConversationsNow(token: string) {
  const [recent, unread] = await Promise.all([
    fetchRecentConversations(token),
    fetchUnreadConversations(token),
  ]);

  conversationStore.restoreRecent(recent);
  conversationStore.restoreUnread(unread);
}

На первый взгляд здесь вообще нет ничего интересного. Всего лишь два параллельных REST-запроса.

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

Любой сценарий - reconnect, возврат вкладки, новое сообщение или восстановление соединения - выполняет одну и ту же операцию.

refreshConversationsNow().

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

Один отвечает за историю сообщений.

Другой - за открытие разговоров.

Третий - за синхронизацию списка диалогов.

Четвёртый - за хранение локального состояния.

А сами React-компоненты постепенно начинают заниматься исключительно тем, ради чего React вообще создавался. Отображением интерфейса.

Именно после этого я окончательно перестал воспринимать большой компонент как проблему саму по себе.

Большой компонент - это всего лишь симптом.

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

Что в итоге изменилось за четыре релиза

Когда я только начинал писать Pulse, архитектура для меня выглядела довольно просто:

  • Есть экран.

  • Есть обработчик.

  • Есть сервис.

  • Есть база данных.

Если всё работает - значит архитектура хорошая. Последние четыре релиза полностью перевернули это представление. Я неожиданно понял, что большинство архитектурных проблем вообще не связано с количеством кода.

Они появляются значительно раньше:

  • В тот момент, когда компонент начинает знать слишком много.

  • Когда HTTP-обработчик постепенно превращается в координатора бизнес-логики.

  • Когда React-компонент начинает управлять жизненным циклом приложения.

  • Когда один микросервис знает внутреннее устройство другого.

  • Когда модель данных перестаёт соответствовать предметной области.

Самое интересное, что ни одна из этих проблем не появляется за один день.

Наоборот.

Практически все они рождаются из абсолютно правильных локальных решений:

  • Добавил ещё один Repository.

  • Добавил ещё один callback.

  • Добавил ещё одну проверку.

  • Добавил ещё один endpoint.

Каждое изменение само по себе выглядит абсолютно нормальным.

Но через несколько месяцев выясняется, что система постепенно потеряла свои границы.

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

Зато внутри проекта появились вещи, которых раньше просто не существовало.

  • MediaClient перестал быть набором HTTP-запросов и превратился в полноценную точку интеграции между двумя микросервисами.

  • ConversationQueryService забрал у HTTP-обработчиков знания о внутреннем устройстве подсистемы диалогов.

  • useConversationHistory перестал быть обычным React Hook и превратился в небольшой runtime со своими состояниями, поколениями запросов и защитой от устаревших ответов.

  • ConversationService и conversationSync постепенно вынесли из компонентов сценарии открытия диалогов и синхронизации состояния.

Сам React при этом начал знать значительно меньше. Именно это, пожалуй, стало для меня главным открытием последней недели.

Раньше мне казалось, что хороший код - это код, который работает. Теперь я всё чаще ловлю себя на совершенно другом вопросе.

Не "как реализовать эту возможность?"

А "какой компонент вообще должен знать о её существовании?"

Оказалось, что именно этот вопрос меняет архитектуру значительно сильнее, чем выбор языка программирования, библиотек или фреймворков.


Самое забавное заключается в том, что roadmap на этом совсем не заканчивается.

Следующие версии Pulse уже расписаны.

Впереди новая модель Presence, транспортная безопасность WebSocket, переработка управления сессиями, ревизия сервисного слоя и финальная стабилизация архитектуры перед следующим большим этапом развития проекта.

И, честно говоря, ещё несколько месяцев назад я бы с удовольствием сразу побежал реализовывать новые функции (хотя по большому счет так и было). Сейчас желания и рвение совершенно другие.

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

Но именно он потом определяет, насколько спокойно будут писаться следующие сто тысяч строк кода.