Когда компания растёт из одного продуктового направления в несколько, технический долг начинает выглядеть иначе. Проблема уже не в «старом коде», устаревших зависимостях или сложной поддержке legacy-системы. Долг начинает накапливаться в расхождении инженерных решений между сервисами.

Для нас в QIC digital hub это особенно заметно на фоне миграции на новый Go-бэкенд. Исторически платформа развивалась на разнородном стеке: разные части системы были написаны на разных технологиях. Сейчас мы постепенно переезжаем на Go. Часть сервисов уже в проде, часть ещё на пути. Именно в такой момент легко создать новый слой техдолга поверх старого: переписать поведение на новом языке, но оставить команды один на один с десятками одинаковых инфраструктурных задач, которые каждая решает по-своему.

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

  • go-kit — общая библиотека с переиспользуемыми инженерными решениями;

  • go-service-template — шаблон, который делает эти решения стандартным способом запуска нового сервиса;

  • shared-renovate-config — общий Renovate-конфиг с единой политикой обновления зависимостей для всех репозиториев.

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

Когда платформа растёт, цена расхождений становится заметной

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

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

Каждое новое отличие — это маленький налог на:

  • запуск нового сервиса;

  • ревью и сопровождение;

  • онбординг новых инженеров;

  • переход людей между командами;

  • переиспользование кода и практик;

  • обновление инфраструктурных подходов сразу в нескольких сервисах.

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

Миграция на Go — возможность не только переписать код, но и пересобрать практики

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

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

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

Техдолг — это ещё и расхождение инженерных решений

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

Например:

  • как поднимать healthcheck-эндпоинты и метрики;

  • как инициализировать структурированное логирование;

  • как маскировать чувствительные данные в логах;

  • как протаскивать trace ID и session ID между сервисами;

  • как строить цепочку gRPC interceptors;

  • как работать с Kafka producer/consumer;

  • как описывать транспортные границы;

  • как запускать миграции;

  • как организовать кодогенерацию;

  • как оформлять интеграционные тесты;

  • как обновлять зависимости предсказуемо и без накопления уязвимостей.

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

Именно здесь общая библиотека, шаблон сервиса и единые конфигурации работают не как «любовь к стандартам», а как инструмент управления долгом.

Что мы вынесли в общую библиотеку

go-kit — это не фреймворк и не попытка спрятать отличающуюся бизнес-логику за универсальными абстракциями. Это набор переиспользуемых инженерных примитивов для типовых задач вокруг сервиса.

В README go-kit описано 15+ документированных пакетов и подпакетов. Среди них:

  • essentials — инициализация логирования и мониторинга;

  • log — структурированное логирование с маскированием данных;

  • trace — OpenTelemetry и распространение трейсов;

  • session и grpc/session — передача session ID;

  • grpc/logging, grpc/validation, grpc/errors, grpc/s2sauth — gRPC-граница;

  • kafka — producer/consumer с трейсингом и логированием;

  • http/errors, http/metrics, http/retry, http/util — утилиты для HTTP-клиентов;

  • db- и test-утилиты, включая утилиты для тестового Postgres.

Важно: речь не об «единой архитектуре всего». Наша цель — собрать повторяющийся инженерный слой в одном месте.

Хороший пример — essentials. Этот пакет берёт на себя раннюю инициализацию сервиса: поднимает логирование и HTTP-сервер мониторинга с метриками и healthcheck-эндпоинтами. В шаблоне он вызывается в самом начале main, и тот же паттерн используется в реальных сервисах.

Другой показательный пакет — log. Это не просто обёртка над slog. Он отвечает за настройку формата и уровня логирования, маскирование payload-ов, добавление trace ID и session ID в логи, содержит адаптеры для Resty и kafka-go.

Отдельный подпакет log/masking_openapi строит правила маскирования по OpenAPI-схемам и предоставляет middleware для HTTP-логирования с замаскированными телами запросов и ответов. Когда маскировка и корреляция запросов работают одинаково в нескольких сервисах, это снижает риск случайных расхождений на границах.

То же касается трейсинга. В trace вынесены инициализация OpenTelemetry, работа со span lifecycle и утилиты для стандартного trace ID. В trace/kafka и kafka — распространение контекста трейсинга через Kafka-заголовки. В session и grpc/session — единый способ протаскивать session ID через HTTP и gRPC.s

Наконец, прикладной слой: утилиты для HTTP-клиента, retry, клиентские метрики, Postgres-утилиты и testing/postgres для интеграционных тестов. Это как раз тот код, который очень легко копировать из сервиса в сервис, а потом годами расплачиваться за десяток слегка разных реализаций.

Что фиксирует шаблон Go-сервиса

Если go-kit отвечает на вопрос «что вынести в общую библиотеку», то go-service-template — «как сделать так, чтобы эти решения попадали в новый сервис по умолчанию».

Шаблон задаёт не только стартовую структуру проекта, но и конкретную форму сборки сервиса.

По структуре он выглядит так:

  • cmd/service — точка входа;

  • internal/app — инициализация и внедрение зависимостей;

  • internal/domain — доменная модель и доменные ошибки;

  • internal/services — бизнес-логика;

  • internal/repositories — доступ к данным;

  • internal/transport/grpc и internal/transport/kafka — транспортные границы;

  • migrations — SQL-миграции;

  • schema — proto-схемы и Buf;

  • pkg/db/psql — слой, генерируемый SQLC.

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

На уровне запуска шаблон задаёт типовую инициализацию: загрузка конфига, запуск essentials, инициализация трейсера, подключение к Postgres, прогон миграций, инициализация репозиториев, сервисов, Kafka consumer-ов и gRPC-сервера.

В транспортном слое это видно особенно хорошо. В gRPC-сервере шаблона цепочка unary interceptors выглядит так:

grpc.ChainUnaryInterceptor(
    s2sauth.NewInterceptor(cfg.AuthApiKeys),
    metrics.UnaryServerInterceptor(...),
    logging.UnaryServerInterceptor(...),
    validateInterceptor,
    recovery.UnaryServerInterceptor(...),
)

В шаблоне уже зафиксированы как минимум пять шагов на границе gRPC-вызова: S2S-аутентификация, метрики, логирование, валидация и recovery. Для нового сервиса это не список пожеланий в Wiki, а готовый дефолт.

То же касается и protobuf-слоя. В шаблоне используется buf.validate, а сами proto-сообщения содержат ограничения прямо на полях:

message GetItemRequest {
  string id = 1 [(buf.validate.field).string.uuid = true];
}

message ListItemsRequest {
  int32 limit = 1 [(buf.validate.field).int32.gt = 0];
  int32 offset = 2 [(buf.validate.field).int32.gte = 0];
}

Важная деталь: часть правил переносится из кода на границу контракта.

Шаблон фиксирует и сопутствующие инструменты. В Makefile есть стандартные команды для локального запуска, кодогенерации, линтинга, тестов и проверки покрытия. make gen объединяет несколько шагов: SQLC, go generate, buf lint, buf generate. Для тестов предусмотрена проверка покрытия с порогом 70%.

Инструменты не предполагаются установленными «где-то на машине». И шаблон, и документация к нему используют go tool, чтобы версии инструментов были зафиксированы рядом с проектом.

В шаблонном репозитории интеграционные тесты поднимают реальный Postgres через утилиты testing/postgres из go-kit, прогоняют миграции и только после этого проверяют поведение. То есть даже тестовая инфраструктура не начинается с чистого листа.

Почему одной библиотеки недостаточно

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

  • как организовать структуру проекта;

  • в каком порядке поднимать инфраструктуру;

  • как собрать цепочку interceptors;

  • где хранить миграции и сгенерированный код;

  • как договориться о тестах;

  • какие команды считать обязательными;

  • какой путь считать «нормальным» для нового сервиса.

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

Что стандартизирует общий Renovate-config

Есть ещё одна область, где разброс накапливается почти незаметно, — управление зависимостями.

Когда у каждого репозитория свой renovate.json (или его нет вовсе), политика обновлений начинает расходиться. Где-то патч-релизы мержатся автоматически, где-то требуют ручного подтверждения, где-то патчи безопасности теряются среди обычного PR-шума.

Поэтому мы развиваем shared-renovate-config — общий Renovate-конфиг для всех Go-сервисов. Он пока в стадии отладки, но идея та же: зафиксировать разумную политику обновлений один раз, а не воспроизводить её с вариациями в каждом репозитории. Подключение — одна строка:

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": [
    "local>core-services/shared-renovate-config"
  ]
}

Конфигурация разделяет обновления по уровню риска:

  • патч-обновления Go-модулей мержатся автоматически;

  • минорные обновления требуют ручного мержа;

  • мажорные — явного подтверждения через Dependency Dashboard;

  • уязвимости из OSV создаются без задержки вне зависимости от расписания;

  • indirect-зависимости Renovate не трогает — ими управляет go mod tidy в рамках еженедельного lockFileMaintenance

Пакеты одной экосистемы обновляются в едином PR, чтобы избежать конфликтов версий: OpenTelemetry, gRPC, Google Cloud, Prometheus и pgx обновляются отдельными группами.

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

Та же логика применима и к другим shared-конфигам. Общие компоненты GitLab CI, конфиги линтеров, шаблоны Dockerfile — всё это места, где расхождения накапливаются незаметно, а их стоимость растёт с каждым новым репозиторием. Чем больше таких решений зафиксировано один раз и подключается одной строкой, тем меньше у команд поводов изобретать одно и то же заново.

Что это даёт скорости разработки

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

  • инициализация мониторинга и логирования;

  • трейсинг и типовая gRPC-граница;

  • структура слоёв;

  • миграции;

  • кодогенерация через Buf и SQLC;

  • моки через go generate;

  • линтинг и тестовые команды;

  • проверка покрытия;

  • примеры Kafka consumer-ов и gRPC-хендлеров.

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

В README go-kit описано более 15 документированных пакетов. Это не один вспомогательный файл, а заметный общий слой.

В шаблоне используется более 50 правил линтера в .golangci.yml. Число само по себе не гарантирует качество, но показывает, что стандарт касается не только структуры каталогов, но и повседневной инженерной гигиены.

make gen объединяет несколько шагов генерации и проверки: SQLC, go generate, buf lint, buf generate. Это тоже ускорение, ведь у сервиса сразу есть предсказуемая рутина.

И шаблон, и реальные сервисы используют порог покрытия 70% в verify-coverage. Это не повод для громких выводов о качестве кода, но конкретный пример того, как инженерные ожидания фиксируются не только словами.

Скорость появляется не из-за одной оптимизации, а из-за того, что у команды меньше инфраструктурной неопределённости.

Что это даёт обмену знаниями и сопровождению

Стандартизацию легко принять за бюрократию. Однако в правильных границах она удешевляет передачу знаний.

Когда два сервиса устроены похоже, инженер быстрее находит ответы на базовые вопросы:

  • где транспортный слой;

  • где происходит внедрение зависимостей;

  • как оформлены репозитории;

  • как сервис работает с трейсами и метриками;

  • как поднимаются миграции;

  • где находится сгенерированный код;

  • как устроены интеграционные тесты.

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

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

В этом смысле стандартизация помогает сделать платформу организационно менее  хрупкой.

Как подход работает в реальных сервисах

Чтобы это не выглядело только историей про README и шаблон, посмотрим на два живых сервиса.

travel-service

travel-service близко следует паттернам из шаблона.

В internal/app/app.go — тот же путь: загрузка конфига, essentials.Run(ctx), инициализация трейсера через trace.InitTracerFromConfig, подключение к Postgres, прогон миграций и запуск gRPC-сервера.

В gRPC-сервере — почти та же цепочка interceptors: Prometheus-метрики, recovery, otelgrpc, logging interceptor из go-kit, S2S-аутентификация и валидация.

Для внешних HTTP-вызовов сервис использует утилиты из go-kit: http/metrics, http/retry, http/errors, http/util.

Тестовый слой тоже идёт по тому же пути: интеграционные тесты репозиториев и app-тесты используют testing/postgres из go-kit, а Makefile повторяет знакомую схему с go tool, SQLC, моками и порогом покрытия 70%.

Для travel-service подход подтверждается напрямую: общий инженерный слой реально встроен в повседневную работу.

car-sale-service

car-sale-service интересен тем, что он заметно сложнее и меньше похож на шаблон. И это как раз полезное подтверждение.

Это не механическая копия шаблона: тут и gRPC, и HTTP API на chi, и OpenAPI-спецификация, и несколько Kafka consumer-ов, и более сложное внедрение зависимостей.

Но при этом сервис опирается на те же платформенные блоки:

  • essentials — инициализация мониторинга;

  • trace — инициализация трейсера;

  • log — настройка логирования;

  • grpc/logging, grpc/s2sauth, grpc/session — gRPC-граница;

  • kafka — producer/consumer;

  • Prometheus middleware;

  • log/masking_openapi — HTTP-логирование с маскированием по OpenAPI.

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

Поэтому важно сочетание библиотеки и шаблона, а не ставка на что-то одно. Шаблон задаёт стартовую форму. Библиотека продолжает работать, когда сервис перерастает эту форму.

Где у такого подхода границы

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

Более того, стандартизировать вообще всё — плохая идея. Сервисам в разных доменах всё равно нужны свои модели, границы, интеграции и компромиссы.

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

  • инициализация сервиса;

  • наблюдаемость;

  • транспортные границы;

  • базовая структура сервиса;

  • кодогенерация;

  • инструментарий;

  • тестовая инфраструктура;

  • общие утилиты для типовых интеграционных задач;

  • управление зависимостями.

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

Вместо вывода

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

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

Поэтому работа с техдолгом во время миграции на новый стек — это не только расчистка legacy. Это попытка выстроить новую платформу так, чтобы новый долг накапливался медленнее.

Общая библиотека выносит повторяющийся инженерный слой в одно место. Шаблон Go-сервиса делает этот слой частью дефолтного пути для новых сервисов. Общий Renovate-конфиг закрывает ещё один угол — единую политику обновления зависимостей, которую любой репозиторий получает одной строкой. Рассмотренные сервисы показывают, что подход не остаётся абстракцией: где-то он воспроизводится буквально, где-то — через переиспользуемые платформенные блоки внутри более сложного приложения.

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