Перейти к содержимому

Паттерны проектирования и прикладная архитектура

Вопросы этой подтемы делятся на три слоя, и путать их нельзя. Первый слой — паттерны уровня кода (GoF): Strategy, Decorator, Factory, Builder, Observer, Adapter, Singleton. Это способы организовать зависимости между несколькими типами внутри одного процесса. Второй слой — паттерны организации приложения: слои, порты и адаптеры, use case-центричная структура, репозитории, анемичная и богатая доменная модель, доменные события, CQRS, event sourcing. Третий слой — паттерны устойчивости распределённой системы: таймауты, retry с backoff, circuit breaker, bulkhead, rate limiting, идемпотентность, graceful degradation. На собеседовании интервьюер обычно скачет между слоями, и сильный ответ — это когда вы явно говорите, о каком уровне речь.

Модель в голове, которая закрывает большинство вопросов: бизнес-логика не должна знать, откуда пришёл запрос и куда уходят данные. Внутри — сущности и use case’ы, которые оперируют доменными типами и объявляют интерфейсы (порты) на то, что им нужно снаружи: «сохрани заказ», «отправь письмо», «спиши деньги». Снаружи — адаптеры, реализующие эти интерфейсы: gRPC-хендлер, HTTP-хендлер, Kafka-консьюмер с одной стороны (driving/входящие адаптеры) и PostgreSQL-репозиторий, HTTP-клиент платёжки, продюсер в брокер с другой (driven/исходящие). Зависимости во время компиляции всегда направлены внутрь, к домену; наружу они разворачиваются во время выполнения через DI в main. Именно это правило превращает почти все «а как переиспользовать логику для другого транспорта», «а как подменить БД», «а как это протестировать» в один и тот же ответ.

В Go эта схема выражается особенно дёшево, потому что интерфейсы неявные. Не нужно писать implements, не нужен фреймворк DI, не нужны абстрактные фабрики ради полиморфизма. Идиоматично интерфейс объявляется на стороне потребителя и содержит ровно те методы, которые потребителю нужны, а реализация живёт в пакете инфраструктуры и про интерфейс ничего не знает. Половина классических GoF-паттернов в Go схлопывается: Strategy — это поле-интерфейс или поле-функция, Decorator — функция func(T) T, Template Method — функция высшего порядка, Iterator с Go 1.23 — это iter.Seq и range-over-func, Singleton — пакетная переменная плюс sync.Once.

Третий слой — устойчивость — держится на другой мысли: любой сетевой вызов может зависнуть, упасть или выполниться дважды. Отсюда обязательный дедлайн на каждый вызов и его распространение через context.Context, отсюда retry только для идемпотентных операций, отсюда idempotency key на записи, отсюда circuit breaker, чтобы не добивать упавшего соседа, и graceful degradation, чтобы отказ некритичного соседа не ронял весь сценарий. Хорошее правило: сначала таймауты и дедлайны, потом идемпотентность, потом retry, и только потом circuit breaker — в обратном порядке это не работает.

Какую архитектурную гибкость дает использование интерфейсов между слоями приложения?

Заголовок раздела «Какую архитектурную гибкость дает использование интерфейсов между слоями приложения?»

Коротко. Интерфейс на границе слоёв разрывает компиляционную зависимость: верхний слой знает только контракт, а не конкретную реализацию. Это даёт подмену реализаций без правки бизнес-логики (Postgres → Mongo, реальный платёжный шлюз → мок в тестах), возможность иметь несколько реализаций одновременно (кеширующая обёртка поверх репозитория), независимую компиляцию и тестируемость слоёв, а также инверсию зависимостей — домен перестаёт зависеть от инфраструктуры.

Глубже. Ключевая мысль — не «интерфейс ради интерфейса», а направление зависимости. Без интерфейса пакет usecase импортирует postgres, и граф зависимостей смотрит наружу: любое изменение схемы или драйвера просачивается в логику. С интерфейсом, объявленным в usecase, стрелка компиляции разворачивается — postgres импортирует usecase, чтобы удовлетворить контракт, а домен не импортирует ничего инфраструктурного. Это буква D в SOLID. В Go это особенно дёшево: реализация неявная, поэтому инфраструктурный пакет может вообще не знать про существование интерфейса.

Практические выгоды, которые стоит назвать вслух: (1) тесты use case’а становятся юнит-тестами без БД и сети; (2) появляется место для сквозной функциональности — обёртки с логированием, метриками, ретраями, кешем реализуют тот же интерфейс и вставляются в цепочку в main (Decorator); (3) можно распараллелить работу команд по контракту; (4) миграция инфраструктуры локализуется в одном пакете. Цена: лишний уровень косвенности, сложнее навигация по коду, девиртуализация не всегда срабатывает (вызов через интерфейс — это косвенный вызов через itab, и инлайнинг обычно теряется). Поэтому в Go принято: интерфейс маленький (1–3 метода), объявлен у потребителя, и вводится тогда, когда есть реальная вторая реализация или тест, а не «на будущее».

// Пакет usecase: контракт объявляет потребитель.
package usecase
type OrderRepository interface {
Save(ctx context.Context, o *domain.Order) error
ByID(ctx context.Context, id domain.OrderID) (*domain.Order, error)
}
type CreateOrder struct{ repo OrderRepository }
func NewCreateOrder(r OrderRepository) *CreateOrder { return &CreateOrder{repo: r} }

Что такое асинхронное API. Для чего нужно и как реализуется?

Заголовок раздела «Что такое асинхронное API. Для чего нужно и как реализуется?»

Коротко. Асинхронное API — это контракт, в котором ответ на запрос не содержит результата работы: сервер принимает задачу, возвращает её идентификатор (обычно 202 Accepted + ссылку на статус), а результат клиент получает позже — опросом статуса, вебхуком, SSE/WebSocket или сообщением в брокере. Нужно, когда работа заведомо дольше разумного HTTP-таймаута, когда нагрузка неравномерна и её надо сгладить очередью, или когда операция должна пережить перезапуск сервиса.

Глубже. Типовая реализация: хендлер валидирует запрос, создаёт запись задачи в БД в статусе pending, публикует сообщение (лучше через transactional outbox, чтобы не потерять его при откате транзакции), отдаёт 202 с Location: /v1/tasks/{id}. Воркер забирает задачу, выполняет, пишет результат и переводит статус в done/failed. Клиент опрашивает GET /v1/tasks/{id} (long polling или с Retry-After) либо получает callback на зарегистрированный URL. В gRPC каноничный вариант — long-running operations (google.longrunning.Operation) или серверный стрим со статусами.

Что почти всегда спрашивают дальше: (1) идемпотентность создания — клиент повторит POST при таймауте, поэтому нужен Idempotency-Key и уникальный индекс, иначе задача продублируется; (2) семантика доставки — брокеры дают at-least-once, значит воркер обязан быть идемпотентным; (3) наблюдаемость — как клиенту узнать про ошибку и как её ретраить; (4) TTL и уборка — результаты не хранятся вечно; (5) вебхуки требуют ретраев с backoff и подписи (HMAC), иначе это ненадёжный и небезопасный канал. Отдельно стоит различать «асинхронное API» (контракт наружу) и «асинхронность внутри» (горутины под капотом синхронного эндпоинта) — это разные вещи, и путаница здесь частая.

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

Глубже. Важно понимать не только «как», но и «почему это спорно». Синглтон — это глобальное изменяемое состояние с неявной зависимостью: функция, которая внутри дёргает db.Get(), не сообщает об этом в сигнатуре, её нельзя протестировать без реальной инициализации, а параллельные тесты начинают влиять друг на друга. Поэтому в Go современная практика — создавать единственный экземпляр в main и прокидывать его явно (DI), а собственно синглтон-паттерн оставлять для случаев, где глобальность неизбежна (реестр prometheus.DefaultRegisterer, http.DefaultClient). Подробнее про корректную реализацию в Go — см. вопрос «Что такое паттерн „синглтон“? …» ниже.

Как на техническом языке называется «матрешка»?

Заголовок раздела «Как на техническом языке называется «матрешка»?»

Коротко. Смотря что имеют в виду, но в 90% случаев на собеседовании «матрёшка» — это декоратор (Decorator / Wrapper): объект той же формы оборачивает другой объект того же интерфейса, добавляя поведение. В сетевом коде тот же приём называется middleware/chain (цепочка обёрток), а в Go про вложенные структуры говорят «встраивание» (embedding), про ошибки — «обёртывание» (error wrapping через %w).

Глубже. Проговорите разницу: Decorator сохраняет интерфейс и добавляет поведение (логирование, метрики, кеш, ретраи); Chain of Responsibility передаёт запрос по цепочке до первого обработчика; Proxy контролирует доступ к объекту; Adapter меняет интерфейс. Все четыре «выглядят как матрёшка», но решают разные задачи. В Go канонические примеры вложенности — http.Handler-middleware, http.RoundTripper-обёртки над транспортом, gRPC-интерсепторы и fmt.Errorf("...: %w", err) с раскруткой через errors.Is/errors.As/errors.Unwrap.

func WithLogging(next http.Handler, log *slog.Logger) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Info("request", "path", r.URL.Path, "dur", time.Since(start))
})
}

Какие архитектурные паттерны вы использовали в своей практике? Приходилось ли вам реализовывать их самостоятельно и какие задачи они решали?

Заголовок раздела «Какие архитектурные паттерны вы использовали в своей практике? Приходилось ли вам реализовывать их самостоятельно и какие задачи они решали?»

Коротко. Вопрос про личный опыт: интервьюер хочет услышать не список названий, а связку «была вот такая проблема → выбрали вот такой паттерн → получили вот такой измеримый эффект → вот чем заплатили». Отвечайте 2–3 конкретными историями, а не перечислением из книжки.

Глубже. Рабочий каркас ответа: (1) контекст — что за сервис, нагрузка, команда, ограничения; (2) проблема в терминах симптома («дублировались платежи при ретраях клиента», «падение соседнего сервиса уводило нас в таймауты и выжигало пул соединений», «бизнес-логика была вкомпилена в gRPC-хендлеры и её нельзя было переиспользовать»); (3) решение и почему именно оно, какие альтернативы отбросили; (4) как реализовали — своими руками или библиотекой, и почему; (5) результат в числах и что оказалось неудобным потом.

Хорошие кандидаты для историй: transactional outbox (чтобы не терять события при откате транзакции), idempotency key на запись, circuit breaker перед деградировавшим соседом, ports & adapters при добавлении второго транспорта, saga для распределённой отмены заказа, CQRS с отдельной read-моделью для тяжёлой аналитики. Типичные ошибки: называть паттерны, которых не трогали руками; говорить «мы использовали чистую архитектуру» без единой детали; не уметь назвать цену решения. Про «реализовывали ли сами» — честный сильный ответ звучит так: «circuit breaker брали sony/gobreaker, потому что своя реализация — это ещё и метрики, и half-open, и тесты на гонки; а вот outbox писали сами, потому что он завязан на нашу схему и на транзакции».

Какие подходы к организации кода используете в проектах?

Заголовок раздела «Какие подходы к организации кода используете в проектах?»

Коротко. Базово: cmd/ для точек входа, internal/ для всего, что не должно импортироваться извне, разделение на транспорт / приложение (use cases) / домен / инфраструктуру, интерфейсы объявляются у потребителя, сборка графа зависимостей — только в main. Пакеты именуются по предметной области (order, billing), а не по техническому слою (utils, models, helpers).

Глубже. На практике выбирают между двумя осями нарезки. По слоям (handler/, service/, repository/) — просто и привычно, но изменение одной фичи размазывается по всем каталогам, а пакеты становятся свалками. По фичам/доменам (internal/order/{handler,usecase,repo}.go) — изменение локализовано, границы видны, легче потом выделить сервис; минус — на старте кажется избыточным. Здоровый компромисс для среднего Go-сервиса: верхний уровень — по доменам, внутри домена — по ролям.

Что ещё стоит упомянуть: internal/ — единственный механизм в Go, который жёстко ограничивает импорт (компилятор запретит импортировать internal извне модуля/каталога-родителя); пакет pkg/ не даёт ничего, кроме лишнего уровня, и в 2024–2025 его скорее избегают; циклические импорты в Go запрещены, и это полезный детектор плохих границ — цикл почти всегда означает, что интерфейс объявлен не там; go:generate + mockgen/moq для моков по интерфейсам; DI руками или через google/wire (кодогенерация, ошибки на этапе компиляции) вместо рефлексивных контейнеров. Полезная проверка архитектуры в CI — линтер depguard/go-arch-lint, запрещающий домену импортировать инфраструктуру.

Коротко. Event sourcing — способ хранения состояния, при котором источником истины является не текущий снимок, а упорядоченный неизменяемый журнал произошедших событий. Текущее состояние агрегата получают, проигрывая (replay) его события с начала или с последнего снапшота.

Глубже. Схема такая: команда приходит в агрегат, агрегат проверяет инварианты по своему текущему состоянию и порождает событие (OrderPlaced, OrderPaid, OrderCancelled); событие апендится в event store с версией агрегата; оптимистическая блокировка по версии (expected_version) решает конкурентную запись. Для чтения строят проекции — денормализованные read-модели, обновляемые подписчиками журнала; отсюда естественная связка с CQRS. Чтобы не проигрывать тысячи событий, периодически сохраняют snapshot состояния и стартуют replay с него.

Что даёт: полный аудит «кто, что и когда» бесплатно; возможность восстановить состояние на любой момент времени (time travel) и построить новую проекцию задним числом по историческим данным; естественная интеграция через события. Чем платят: сильно сложнее, чем CRUD; версионирование схемы событий — событие живёт вечно, и старые версии придётся уметь читать (upcasting); нельзя просто «поправить строку руками» — исправление делается компенсирующим событием; запросы «по любому полю» невозможны без проекций; GDPR-удаление персональных данных из неизменяемого журнала — отдельная боль (обычно решают crypto-shredding). Практический совет для собеседования: сказать, что event sourcing уместен точечно, для агрегатов, где история сама по себе является требованием бизнеса (деньги, документооборот, статусы заказа), а не для всей системы разом.

Коротко. CQRS (Command Query Responsibility Segregation) — разделение модели на командную (изменяет состояние, ничего содержательного не возвращает) и запросную (только читает, ничего не меняет). Минимальная форма — два разных набора типов и сервисов в одном приложении; максимальная — физически разные хранилища: нормализованное для записи и денормализованные проекции для чтения, синхронизируемые асинхронно.

Глубже. Мотивация: у чтения и записи разные требования. Запись нуждается в инвариантах, транзакциях и нормализации; чтение — в скорости, конкретных формах ответа и масштабировании репликами. Когда обе стороны обслуживает одна ORM-модель, она вырождается: либо тормозят выборки, либо ломаются инварианты. CQRS позволяет, например, писать в PostgreSQL, а читать ленту из Elasticsearch или из материализованных представлений.

Цена — eventual consistency: между командой и обновлением проекции есть лаг, и UI обязан это учитывать (read-your-writes через чтение с мастера, версия/ETag, оптимистичное обновление на клиенте). Плюс удвоение кода и необходимость уметь пересобирать проекции с нуля. Частая ошибка на собеседовании — считать, что CQRS обязательно требует event sourcing и брокера: нет, это ортогональные вещи, CQRS прекрасно живёт как «два интерфейса и два набора DTO над одной базой», и в 80% проектов именно это и нужно.

// Команда: меняет состояние, возвращает только ошибку/идентификатор.
type PlaceOrder struct{ UserID, SKU string; Qty int }
func (h *OrderCommands) Handle(ctx context.Context, c PlaceOrder) (OrderID, error)
// Запрос: читает готовую проекцию, домен не задействован.
type OrderFeedItem struct{ ID, Title, Status string }
func (q *OrderQueries) Feed(ctx context.Context, userID string, limit int) ([]OrderFeedItem, error)

Как решается проблема дубликатов входящих запросов?

Заголовок раздела «Как решается проблема дубликатов входящих запросов?»

Коротко. Идемпотентностью. Клиент присылает уникальный ключ (Idempotency-Key в заголовке или бизнес-ключ вроде order_id), сервер сохраняет его в таблицу с уникальным индексом в той же транзакции, что и сам эффект; при повторе вставка нарушает уникальность, и сервер возвращает ранее сохранённый ответ вместо повторного выполнения.

Глубже. Разберём по слоям. Уникальный индекс в БД — самый надёжный барьер: INSERT ... ON CONFLICT (idempotency_key) DO NOTHING и проверка RowsAffected. Хранить нужно не только факт, но и результат (HTTP-код, тело, ID созданной сущности), иначе клиент на повторе получит непонятный ответ; плюс TTL на записи (обычно от часов до суток) и уборку. Природная идемпотентность: если операция формулируется как «установить состояние в X» (PUT, UPDATE ... SET status='paid' WHERE id=? AND status='pending'), дубликат безвреден сам по себе — это лучший вариант, к нему стоит стремиться при проектировании API.

Для потребителей брокеров всё то же самое, но ключ — это бизнес-идентификатор сообщения (message_id, event_id), а не offset: брокеры дают at-least-once, и ребаланс/перезапуск гарантированно приведут к повторной доставке. Kafka-транзакции и enable.idempotence защищают только от дублей продюсера внутри Kafka и не спасают побочные эффекты во внешних системах. Redis с SET key val NX EX ttl как дедупликатор допустим для некритичных сценариев, но это кеш — он может потерять ключ, и тогда дубль пройдёт; для денег барьер должен быть в той же транзакционной БД, что и эффект. И отдельно: дедупликация не заменяет блокировку от гонок — два одновременных дубля надо развести либо уникальным индексом (второй упадёт), либо SELECT ... FOR UPDATE, либо advisory lock.

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

Глубже. Каркас ответа. Сначала классифицируйте зависимости на критичные (без них ответ бессмыслен — например, БД с самим заказом) и некритичные (рекомендации, бейджи, счётчики, персонализация). Для некритичных — короткий отдельный таймаут, errgroup без прерывания основного сценария и fallback: последнее удачное значение из кеша (stale-while-revalidate), пустой список, статичный дефолт. Для критичных деградация означает уже другое: read-only режим, отказ от записи с понятным 503 Retry-After, отключение тяжёлых фич флагом, load shedding (отбрасывание части трафика на входе, чтобы не умереть от очередей целиком), приоритизация трафика.

Механика, которую стоит назвать: feature flags/kill switch для быстрого отключения фичи без релиза; circuit breaker, который сам переводит вызов в fallback, когда сосед лежит; отдельные пулы соединений и семафоры (bulkhead), чтобы медленный сосед не выел все воркеры; дефолтные значения, зафиксированные продуктом, а не выдуманные разработчиком. Типичные ошибки: считать, что деградация — это if err != nil { return nil } без метрики (потом никто не знает, что фича полгода не работает); деградировать то, что деградировать нельзя (списание денег); не проверять деградацию — её надо регулярно учить через chaos-эксперименты или хотя бы интеграционные тесты с падающим моком.

Рассказать про SOLID, и др. архитектурные паттерны.

Заголовок раздела «Рассказать про SOLID, и др. архитектурные паттерны.»

Коротко. SOLID — пять принципов ООП-дизайна: SRP (у модуля одна причина для изменения), OCP (открыт для расширения, закрыт для изменения), LSP (наследник/реализация должна быть подставима вместо базового типа без нарушения контракта), ISP (много узких интерфейсов лучше одного широкого), DIP (модули зависят от абстракций, детали зависят от абстракций, не наоборот). Рядом обычно упоминают DRY, KISS, YAGNI и структурные подходы: слоистую архитектуру, порты и адаптеры (гексагональную), чистую архитектуру, DDD.

Глубже. В Go SOLID читается своеобразно. SRP — про пакет и тип: пакет order не должен одновременно рендерить HTML, ходить в Kafka и считать НДС. OCP достигается не наследованием (его нет), а интерфейсами и композицией: добавление новой стратегии — это новый тип, реализующий интерфейс, плюс одна строка регистрации, а не правка switch. LSP — про соблюдение контракта интерфейса: реализация, которая возвращает ErrNotImplemented на половине методов, ломает подстановку (классический пример из stdlib — типы, у которых Write не соблюдает контракт io.Writer возвращать n < len(p) только вместе с ошибкой). ISP — прямо про идиому Go: io.Reader, io.Writer, io.Closer — по одному методу; «толстый» Repository на 30 методов делает моки в тестах невыносимыми. DIP — интерфейс объявляется в пакете-потребителе, реализация в инфраструктуре, сборка в main.

Из «других архитектурных паттернов» полезно назвать три группы и по одному примеру: организация приложения (layered, hexagonal, clean, modular monolith), распределённые (saga, outbox, CQRS, event sourcing, API gateway, BFF, sidecar, strangler fig для миграции легаси), устойчивость (retry с backoff, circuit breaker, bulkhead, rate limit, throttling). И обязательно добавьте отрезвляющую ремарку: SOLID — это эвристики против связанности, а не закон; в Go «расширяемость про запас» через фабрики фабрик почти всегда проигрывает простому коду, который потом честно рефакторят (YAGNI).

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

Заголовок раздела «Доп. вопросы: как можно хранить историю изменений чтобы была возможность отматывать назад.»

Коротко. Три рабочих подхода по возрастанию сложности: (1) аудит-лог / append-only таблица изменений — на каждое изменение пишем строку с diff или полным снимком, версией и метаданными (кто, когда, откуда); откат = применить обратное изменение; (2) temporal / versioned rows — храним историю версий записи с интервалом валидности (valid_from, valid_to), текущая версия — та, у которой valid_to IS NULL; (3) event sourcing — источник истины это журнал событий, любое состояние восстанавливается replay’ем.

Глубже. Практические детали. Аудит проще всего сделать триггером в БД (или расширением вроде pgaudit/temporal_tables, или логической репликацией/CDC через Debezium) — плюс в том, что ни одно изменение не проскочит мимо, минус — в базе нет бизнес-смысла операции, только «поле сменилось с A на B». Если история нужна продукту («покажи, кто отменил заказ и почему»), пишите её из кода как доменные события с намерением, а не как diff строк.

Про «отматывать назад» важно сказать честно: технический откат к прошлой версии почти никогда не является бизнес-откатом. Если по заказу уже ушло письмо и списаны деньги, вернуть строку в старое состояние недостаточно — нужны компенсирующие действия (паттерн Saga/Compensating Transaction, паттерн Memento на уровне кода — только для состояния в памяти). Поэтому корректная формулировка отката: «применяем компенсирующее событие, которое приводит состояние к желаемому и само попадает в историю», а не «удаляем последние записи». Ещё три вещи, которые оценят: снапшоты, чтобы не проигрывать всю историю; ретеншн и партиционирование истории по времени (она растёт быстрее основной таблицы); версионирование схемы записи истории, потому что читать её придётся спустя годы.

Что такое сервисы/хэндлеры/репозитории/слои/адаптеры и юзкейсы?

Заголовок раздела «Что такое сервисы/хэндлеры/репозитории/слои/адаптеры и юзкейсы?»

Коротко. Это словарь слоистой/гексагональной архитектуры. Хендлер — тонкий вход транспорта (gRPC/HTTP/консьюмер): распарсить, провалидировать формат, вызвать use case, замапить результат и ошибки в коды ответа. Use case (интерактор) — один бизнес-сценарий целиком: оркестрация домена и портов, границы транзакции. Сервис — размытый термин: доменный сервис (логика, не принадлежащая одной сущности) либо прикладной сервис (фактически синоним use case). Репозиторий — порт доступа к коллекции агрегатов: Save, ByID, FindBy..., скрывающий SQL. Адаптер — реализация порта поверх конкретной технологии либо, наоборот, входная точка транспорта. Слой — группа модулей с одинаковым уровнем абстракции и правилом «зависимости смотрят внутрь».

Глубже. Частые ошибки именно здесь: хендлер, в котором лежит бизнес-логика (тогда её нельзя переиспользовать для второго транспорта); репозиторий, который возвращает не доменные сущности, а строки БД или ORM-модели (тогда инфраструктура протекает в домен); «сервис» как свалка на 3000 строк, который вызывает сам себя; репозиторий на каждую таблицу вместо репозитория на агрегат. Полезное различение: репозиторий — это про коллекцию агрегатов (OrderRepository, а не OrderItemRepository), потому что агрегат — граница транзакционной согласованности.

Отдельный вопрос, который любят задавать следом, — где живёт транзакция. Она не может жить в репозитории (репозиториев в сценарии несколько), и не должна течь в хендлер. Идиоматичный для Go вариант — порт «единица работы»: type TxManager interface { Do(ctx context.Context, fn func(ctx context.Context) error) error }, реализация кладёт *sql.Tx в контекст, репозитории достают его оттуда, а use case видит только замыкание.

usecase/cancel_order.go
type CancelOrder struct {
tx TxManager
orders OrderRepository
events EventPublisher
}
func (u *CancelOrder) Handle(ctx context.Context, id domain.OrderID, reason string) error {
return u.tx.Do(ctx, func(ctx context.Context) error {
o, err := u.orders.ByID(ctx, id)
if err != nil {
return err
}
if err := o.Cancel(reason); err != nil { // инвариант проверяет домен
return err
}
if err := u.orders.Save(ctx, o); err != nil {
return err
}
return u.events.Publish(ctx, o.PullEvents()...) // в ту же транзакцию (outbox)
})
}

Назови принципы SOLID и антипаттерн для каждого из них.

Заголовок раздела «Назови принципы SOLID и антипаттерн для каждого из них.»

Коротко. SRP → God Object / God Function (класс или функция, которая делает всё). OCP → растущий switch/if по типу, куда лезут править при каждой новой сущности (shotgun surgery). LSP → наследник, который сужает контракт: бросает ErrNotImplemented, ужесточает предусловия или нарушает инварианты базового типа (канонический пример — Square/Rectangle). ISP → «жирный» интерфейс на 20 методов, который заставляет реализовать ненужное (fat interface). DIP → бизнес-логика, напрямую импортирующая database/sql, конкретный драйвер или HTTP-клиент (concrete dependency / вкомпилированная инфраструктура).

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

LSP чаще всего нарушают не через наследование (его в Go нет), а через интерфейсы: реализация io.Writer, которая молча отбрасывает часть данных; мок, который ведёт себя не так, как настоящий репозиторий (не возвращает ErrNotFound); обёртка, теряющая контекст или проглатывающая ошибку. ISP в Go нарушается «монстро-репозиториями»: практический тест — попробуйте написать мок вручную, и если это больно, интерфейс слишком широк. DIP нарушают не только импортом драйвера — ещё и утечкой типов инфраструктуры в сигнатуры домена: func (s *Service) Get(ctx context.Context) (*sqlc.OrderRow, error) формально зависит от интерфейса, но фактически привязывает домен к схеме БД.

Какие виды порождающих паттернов существуют?

Заголовок раздела «Какие виды порождающих паттернов существуют?»

Коротко. Пять по GoF: Singleton (единственный экземпляр), Factory Method (метод-фабрика, подклассы/реализации решают, какой конкретный объект создать), Abstract Factory (фабрика семейств связанных объектов), Builder (пошаговая сборка сложного объекта), Prototype (создание копированием существующего экземпляра). Часто к ним добавляют Object Pool.

Глубже. В Go половина из них выглядит иначе. Factory Method — обычно просто функция New..., возвращающая интерфейс, либо map[string]func(...) T — реестр конструкторов. Abstract Factory встречается редко: типичный живой пример — интерфейс StorageProvider, который отдаёт согласованный набор (Blobs(), Queues()) для конкретного облака. Builder в идиоматичном Go почти всегда заменяется функциональными опциями (func(*Server)), потому что у них лучше читаемость и совместимость при добавлении полей. Prototype — это либо метод Clone(), либо аккуратное копирование структуры, и здесь надо помнить, что присваивание структуры в Go даёт поверхностную копию: слайсы, мапы и указатели останутся общими. Object Pool в stdlib — sync.Pool, но у него особая семантика: это кеш временных объектов, который GC может опустошить в любой момент, поэтому для соединений он не годится (там database/sql со своим пулом).

// Функциональные опции вместо Builder.
type Server struct{ addr string; timeout time.Duration }
type Option func(*Server)
func WithTimeout(d time.Duration) Option { return func(s *Server) { s.timeout = d } }
func NewServer(addr string, opts ...Option) *Server {
s := &Server{addr: addr, timeout: 5 * time.Second}
for _, o := range opts {
o(s)
}
return s
}

Какие можете перечислить 3 любых шаблона проектирования (уровня кода)?

Заголовок раздела «Какие можете перечислить 3 любых шаблона проектирования (уровня кода)?»

Коротко. Например: Strategy — поведение выносится за интерфейс и подменяется в рантайме (разные алгоритмы расчёта скидки, разные провайдеры оплаты); Decorator — обёртка того же интерфейса, добавляющая логирование/метрики/ретраи/кеш; Factory — функция-конструктор, скрывающая выбор конкретной реализации за интерфейсом.

Глубже. Отвечая, сразу покажите, как это выглядит в Go, — это ценится больше, чем определение из книги. Strategy: поле-интерфейс или прямо поле-функция (type Discount func(Order) Money — функциональный тип часто уместнее интерфейса с одним методом). Decorator: func NewCachedRepo(next Repository, c Cache) Repository, и в main собираем NewRetrying(NewMetered(NewCachedRepo(pg, cache))). Factory: func NewNotifier(kind string) (Notifier, error). Ещё три, которые хорошо звучат в Go-контексте: функциональные опции (вариант Builder), middleware-цепочка (Chain of Responsibility), Observer через каналы или через список подписчиков-функций.

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

Заголовок раздела «Каких паттернов и архитектурных подходов вы придерживались?»

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

Глубже. Что назвать как «то, чего придерживались»: единая структура сервисов (cmd/, internal/, порты у потребителя, DI в main), обязательный дедлайн и context.Context первым аргументом во всех сетевых вызовах, идемпотентность на любой записывающий эндпоинт, outbox для публикации событий, contract-first (proto/OpenAPI как источник истины, кодогенерация), ADR на каждое значимое решение, обязательные метрики RED на каждый хендлер и каждый исходящий вызов. Такой ответ показывает инженерную зрелость сильнее, чем список GoF-паттернов.

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

Заголовок раздела «Этот функционал реализован только для gRPC-сервера. Как бы вы перестроили архитектуру, чтобы ту же бизнес-логику можно было переиспользовать для HTTP-эндпоинтов и для консьюмера событий без дублирования кода?»

Коротко. Вынести логику из gRPC-хендлера в transport-agnostic слой use case’ов, который принимает и возвращает свои доменные типы, ничего не знает про proto, http.Request и kafka.Message, и объявляет нужную ему инфраструктуру интерфейсами. После этого gRPC-хендлер, HTTP-хендлер и консьюмер становятся тремя тонкими адаптерами: каждый только мапит вход в команду use case’а и мапит результат/ошибку в свой формат ответа.

Глубже. Пошагово, как это делать на живом коде без большого рефакторинга сразу: (1) создать пакет internal/<domain>/usecase и перенести туда тело метода gRPC-сервера как есть; (2) заменить в нём все *pb.XxxRequest на собственную структуру команды, а *pb.XxxResponse — на доменный результат; (3) заменить прямые обращения к *pgxpool.Pool, HTTP-клиентам и продюсеру на интерфейсы, объявленные тут же; (4) gRPC-метод свести к трём строкам: маппинг → вызов → маппинг; (5) добавить HTTP-хендлер и консьюмер поверх того же use case’а.

Ключевые тонкости, которые интервьюер ждёт. Ошибки: use case возвращает доменные ошибки (ErrNotFound, ErrConflict, ошибка валидации), и каждый адаптер сам транслирует их — gRPC в codes.NotFound/codes.FailedPrecondition, HTTP в 404/409, консьюмер — в решение «retry или в DLQ». Никаких status.Error внутри бизнес-логики. Валидация делится на две: синтаксическая (в адаптере, по схеме) и бизнес-инварианты (в домене). Контекст и дедлайны прокидываются одинаково, а вот у консьюмера дедлайн задаёт он сам, потому что клиента нет. Идемпотентность обязательна именно для консьюмера: доставка at-least-once, а gRPC-клиент может ретраить, значит ключ идемпотентности лучше сделать частью команды use case’а, а не транспортного слоя. Аутентификация и авторизация: identity извлекается в адаптере и передаётся в команду явным полем, а не через metadata в контексте, иначе консьюмер не сможет её заполнить. Наконец, стоит сказать про проверку: после такого разделения юнит-тесты пишутся на use case без сети, а на адаптеры остаются тонкие тесты маппинга.

// usecase — ничего не знает про транспорт
type CreateOrderCmd struct {
IdempotencyKey string
UserID domain.UserID
Items []domain.Item
}
type CreateOrderResult struct{ ID domain.OrderID }
type CreateOrderHandler interface {
Handle(ctx context.Context, cmd CreateOrderCmd) (CreateOrderResult, error)
}
// grpc adapter
func (s *OrderServer) CreateOrder(ctx context.Context, req *pb.CreateOrderRequest) (*pb.CreateOrderResponse, error) {
cmd, err := toCmd(req)
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
res, err := s.uc.Handle(ctx, cmd)
if err != nil {
return nil, toGRPCError(err)
}
return &pb.CreateOrderResponse{Id: string(res.ID)}, nil
}

Что делать, если нижестоящий сервис начал пятисотить?

Заголовок раздела «Что делать, если нижестоящий сервис начал пятисотить?»

Коротко. Первым делом — не добить его: ограничить ретраи (retry budget, только идемпотентные вызовы, экспоненциальный backoff с джиттером), включить circuit breaker, чтобы после порога ошибок вызовы отсекались быстро, и перевести сценарий в деградацию — кеш, дефолт, отключение необязательной части. Параллельно — короткие таймауты и bulkhead, чтобы зависший сосед не выел пул горутин и соединений, метрики/алерт и эскалация владельцам сервиса.

Глубже. Разложим на «что происходит в рантайме» и «что делает человек». В рантайме опасность не в самих 500, а в retry storm и в исчерпании ресурсов: наивные три ретрая на каждом из четырёх звеньев цепочки дают 81-кратное усиление нагрузки и превращают частичную деградацию в каскадный отказ. Поэтому: ретраить только идемпотентное и только по «ретраибельным» кодам (503, 429 с Retry-After, Unavailable, таймаут соединения), но не 400/403/409; ограничивать общий бюджет ретраев (например, не более 10% от основного трафика); обязательно джиттер; уважать оставшийся дедлайн — не начинать попытку, если ctx истекает раньше.

Ещё три вещи, которые отличают сильный ответ. Отличайте 500 от 429/503: первый — это баг или перегрузка на их стороне, второй — явный сигнал «притормози», и на него нужно снижать частоту, а не ретраить агрессивнее. Load shedding у себя: если сосед недоступен и очередь запросов растёт, честнее быстро вернуть ошибку своим клиентам, чем копить запросы и умереть по памяти/дедлайнам. Что делать человеку: посмотреть, глобально ли это (их дашборд, их алерты) или только у нас (наш деплой, наши данные, наш пул соединений, DNS, mTLS-сертификат); проверить, не мы ли источник нагрузки; включить kill switch на фичу; завести инцидент. И постмортем: почему отказ соседа привёл к нашей деградации — обычно ответ «не было таймаута» или «не было fallback».

Как вы реализовывали retry, backoff, circuit breaker при вызовах между сервисами?

Заголовок раздела «Как вы реализовывали retry, backoff, circuit breaker при вызовах между сервисами?»

Коротко. Ретраи и брейкер лучше вешать не в каждый вызов руками, а на уровень транспорта: для HTTP — обёртка http.RoundTripper, для gRPC — UnaryClientInterceptor (либо встроенный retry-policy в grpc-go через service config). Backoff — экспоненциальный с полным джиттером и потолком, ретрай — только идемпотентных операций и только по ретраибельным ошибкам, с учётом оставшегося дедлайна из ctx. Circuit breaker — обычно sony/gobreaker или failsafe-go, с состояниями closed/open/half-open и метриками на переходы.

Глубже. Что важно проговорить про каждую часть. Retry: перед повтором обязательно проверить ctx.Err(); тело HTTP-запроса должно быть перечитываемым (req.GetBody), иначе повтор отправит пустое тело; на 429/503 уважать Retry-After; ограничить число попыток (2–3) и общий бюджет. Backoff: sleep = min(cap, base * 2^attempt) и затем случайное значение в [0, sleep] — полный джиттер, иначе тысячи клиентов синхронно ударят в сервис одновременно (thundering herd). Circuit breaker: считает долю ошибок в скользящем окне; при превышении порога переходит в open и мгновенно возвращает ошибку без сетевого вызова; через таймаут переходит в half-open и пропускает пробные запросы; успех — обратно в closed. Брейкер должен быть на зависимость (а лучше на пару «зависимость + инстанс/шард»), а не один на весь сервис, и в него нельзя записывать бизнес-ошибки (404, валидация) как сбои — иначе он откроется на нормальном трафике.

Порядок обёрток тоже имеет значение: сначала брейкер, внутри него ретрай, внутри — таймаут одной попытки. Тогда открытый брейкер отсекает и ретраи тоже. Обязательные метрики: количество попыток, распределение по attempt, доля успехов после ретрая, состояние брейкера, число «быстрых отказов».

type retryTransport struct {
base http.RoundTripper
max int
baseDur time.Duration
}
func (t *retryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
var lastErr error
for attempt := 0; attempt <= t.max; attempt++ {
if attempt > 0 {
backoff := t.baseDur << (attempt - 1)
if backoff > 2*time.Second {
backoff = 2 * time.Second
}
jitter := time.Duration(rand.Int63n(int64(backoff))) // полный джиттер
select {
case <-req.Context().Done():
return nil, req.Context().Err()
case <-time.After(jitter):
}
}
resp, err := t.base.RoundTrip(req.Clone(req.Context()))
if err != nil {
lastErr = err
continue
}
if resp.StatusCode == http.StatusServiceUnavailable || resp.StatusCode == http.StatusTooManyRequests {
resp.Body.Close()
lastErr = fmt.Errorf("retryable status %d", resp.StatusCode)
continue
}
return resp, nil
}
return nil, lastErr
}

Как вы определяете оптимальные тайм-ауты для серверных вызовов? Какие стратегии применяете на клиенте?

Заголовок раздела «Как вы определяете оптимальные тайм-ауты для серверных вызовов? Какие стратегии применяете на клиенте?»

Коротко. Таймаут выводится из измеренной латентности зависимости и из SLO вызывающего: берут высокий перцентиль (обычно p99 или p99.9) нормального времени ответа и добавляют запас, но никогда не превышают оставшийся бюджет запроса. Ключевой принцип — timeout budget: у входящего запроса есть общий дедлайн, он кладётся в context, распространяется вниз по цепочке, и каждый следующий вызов получает не свой абстрактный таймаут, а min(свой лимит, остаток дедлайна).

Глубже. Правила, которые стоит назвать. Таймаут клиента должен быть меньше, чем у сервера, иначе клиент отваливается, а сервер продолжает работать вхолостую. Таймаут должен быть больше, чем p99 зависимости, иначе вы сами создаёте ошибки на здоровом трафике; но не в разы больше, иначе при деградации соседа у вас копятся зависшие запросы. Разделяйте таймауты: на установление соединения (сотни миллисекунд), на TLS-handshake, на весь запрос, на чтение тела. В Go: http.Client{Timeout: ...} покрывает весь цикл включая тело; тонкие настройки — в http.Transport (DialContext, TLSHandshakeTimeout, ResponseHeaderTimeout, IdleConnTimeout). На сервере — http.Server{ReadHeaderTimeout, ReadTimeout, WriteTimeout, IdleTimeout}, причём ReadHeaderTimeout обязателен как защита от Slowloris. В gRPC дедлайн передаётся по проводу метаданными и виден серверу — это большое преимущество перед голым HTTP.

Клиентские стратегии сверх таймаута: экспоненциальный backoff с джиттером; circuit breaker; hedged requests (после p95 отправить дублирующий запрос второму инстансу и взять первый ответ — сокращает хвост, но повышает нагрузку и допустим только для идемпотентного чтения); bulkhead — отдельный семафор/пул на каждую зависимость; лимит конкурентности (Transport.MaxConnsPerHost, MaxIdleConnsPerHost — дефолтные 2 idle-соединения на хост в Go часто становятся неожиданным узким местом); адаптивные таймауты по скользящему перцентилю для сервисов с плавающей латентностью. И обязательно: таймаут без отмены работы бесполезен — код обязан слушать ctx.Done(), иначе горутина живёт дальше и течёт.

Какую стратегию пагинации вы бы выбрали для ленты с бесконечной прокруткой?

Заголовок раздела «Какую стратегию пагинации вы бы выбрали для ленты с бесконечной прокруткой?»

Коротко. Keyset-пагинацию (она же cursor / seek method): вместо LIMIT ... OFFSET ... запрашивать «следующие N записей после известного курсора» — WHERE (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC LIMIT 20. Курсор отдаётся клиенту в непрозрачном виде (base64 от пары значений), клиент возвращает его в следующем запросе.

Глубже. Почему не offset: во-первых, OFFSET 100000 заставляет БД реально прочитать и отбросить 100 000 строк — стоимость растёт линейно с глубиной прокрутки; во-вторых, лента постоянно пополняется, и при вставке новых элементов между запросами страницы «съезжают» — пользователь видит дубликаты или пропуски. Keyset решает и то, и другое: он стабилен относительно вставок и выполняется за один index seek, если есть индекс по тому же составному ключу, что и ORDER BY.

Детали, которые ценятся. Сортировочный ключ должен быть уникальнымcreated_at не уникален, поэтому в кортеж добавляют id как tie-breaker, иначе на границе страницы записи будут теряться или дублироваться. Индекс должен точно соответствовать порядку сортировки ((created_at DESC, id DESC)), тогда в PostgreSQL кортежное сравнение (created_at, id) < ($1, $2) использует index scan. Курсор надо делать непрозрачным (base64/JSON, при желании с подписью), чтобы не фиксировать внутреннее устройство в публичном API и иметь возможность его поменять. Дополнительно: обычно берут LIMIT n+1, чтобы понять, есть ли следующая страница, и вернуть has_more; общее количество (total) в бесконечной ленте не считают вообще — это дорогой полный подсчёт, и продукту он не нужен. Если лента персонализированная и порядок задаёт ранжирующая модель, чистый keyset по БД не работает: тогда ранжированный список материализуют в кеш (Redis, feed store) на сессию и курсором становится позиция в этом снимке. Offset-пагинация остаётся уместной там, где нужны нумерованные страницы и переход «на страницу 7» — то есть в админках, а не в лентах.

Коротко. Да: God Function (и родственный God Object) — функция, которая делает всё сразу: парсит вход, ходит в базу, считает бизнес-правила, дёргает соседние сервисы, форматирует ответ и логирует. Признаки — сотни строк, десяток параметров, глубокая вложенность, высокая цикломатическая сложность, множество причин для изменения. Это прямое нарушение SRP.

Глубже. Чем конкретно плохо: такую функцию невозможно протестировать иначе, чем интеграционно (нужны все зависимости сразу); любое изменение рискует задеть несвязанную часть; её нельзя переиспользовать по частям; она становится точкой конфликтов в git; читать её приходится целиком, чтобы понять любую деталь. В Go к этому добавляется характерная форма: гигантский switch в хендлере и функции с сигнатурой на 8 аргументов, где половина — булевы флаги (doValidate bool, sendEmail bool — верный признак, что внутри живут несколько разных функций).

Как лечить: выделить чистые (без побочных эффектов) куски в отдельные функции и покрыть их тестами; вынести ввод-вывод за интерфейсы; заменить флаги-параметры на отдельные функции или на стратегию; разбить по этапам сценария (валидация → загрузка → решение → сохранение → публикация события); «пирамиду» вложенных if развернуть в early return. Контролировать можно линтерами: gocyclo/cyclop (цикломатическая сложность), funlen, gocognit, nestif в golangci-lint. Важная оговорка для собеседования: длина сама по себе не приговор — бывают длинные, но линейные и понятные функции (конфигурация, маппинг). Приговор — количество причин для изменения и число зависимостей.

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

Глубже. Алгоритмы: token bucket — самый распространённый, задаёт среднюю скорость r и допускает всплеск размером с ёмкость ведра b; leaky bucket — сглаживает трафик до постоянной скорости, всплески буферизует или отбрасывает; fixed window — простой счётчик за интервал, но на стыке окон пропускает двойной всплеск; sliding window log/counter — точнее, дороже по памяти. В Go стандартный выбор — golang.org/x/time/rate (rate.NewLimiter(r, b), методы Allow, Wait(ctx), Reserve); Wait подходит для клиентской стороны (притормозить себя), Allow — для серверной (быстро отказать).

Что отличает вдумчивый ответ. Ключ лимитирования: глобальный, на IP, на пользователя, на API-ключ, на эндпоинт — обычно нужна комбинация, и IP плох за NAT/CDN. Распределённость: локальный лимитер в каждом поде даёт суммарный лимит × число подов; для честного общего лимита нужен Redis (INCR + EXPIRE или Lua-скрипт с token bucket) либо распределение квоты по подам, и это сразу компромисс между точностью и латентностью. Ответ клиенту: 429 Too Many Requests с Retry-After и заголовками RateLimit-Limit/RateLimit-Remaining/RateLimit-Reset — без них клиент не может вести себя корректно. И различайте rate limiting (ограничение частоты) от throttling/backpressure (замедление) и от concurrency limit (ограничение числа одновременных операций семафором) — при защите от медленных операций второе часто важнее первого, потому что 10 rps по 30 секунд каждая — это 300 одновременных запросов.

Какие есть стратегии джойнов (не про left, right). Как оптимально сджойнить маленькую и большую таблицу? (переключить стратегию джойнов).

Заголовок раздела «Какие есть стратегии джойнов (не про left, right). Как оптимально сджойнить маленькую и большую таблицу? (переключить стратегию джойнов).»

Коротко. Три физических алгоритма: nested loop join (для каждой строки внешней таблицы ищем совпадения во внутренней; хорош, когда внешняя выборка мала и по ключу внутренней есть индекс), hash join (по меньшей таблице строится хеш-таблица в памяти, большая проходится один раз и пробивается по ней; хорош для больших несортированных наборов и только для эквиджойнов), merge join (обе стороны сортируются по ключу и сливаются за один проход; хорош, когда данные уже отсортированы, например берутся по индексу). Для «маленькая + большая» оптимально hash join с маленькой таблицей на стороне build, а в распределённых движках — broadcast join, то есть рассылка маленькой таблицы на все узлы, чтобы избежать шафла большой.

Глубже. Планировщик выбирает стратегию сам по статистике, поэтому «переключить» на практике означает: (1) обновить статистику — ANALYZE, поднять default_statistics_target для перекошенных столбцов; иначе оценка кардинальности врёт и PostgreSQL берёт nested loop там, где нужен hash; (2) дать памяти — hash join деградирует в multi-batch с записью на диск, если хеш-таблица не влезает в work_mem (это видно в EXPLAIN (ANALYZE, BUFFERS) как Batches: N и Disk Usage); (3) создать подходящий индекс, чтобы стал возможен index nested loop или merge join по индексному порядку; (4) в крайнем случае — сессионные флаги enable_hashjoin/enable_mergejoin/enable_nestloop (в PostgreSQL это инструмент диагностики, а не продакшн-решение) или расширение pg_hint_plan; в MySQL/Oracle есть настоящие хинты (JOIN_ORDER, BNL, HASH_JOIN).

Практический алгоритм разбора медленного джойна: посмотреть EXPLAIN ANALYZE, сравнить rows оценённые и фактические — большое расхождение означает проблему статистики; проверить, не крутится ли nested loop по миллионам строк без индекса; проверить, не уходит ли hash на диск; проверить, не мешает ли функция или приведение типа над колонкой использовать индекс (WHERE lower(email) = ... без функционального индекса). В распределённых системах (Spark, ClickHouse, Trino) отдельно важны broadcast vs shuffle/partitioned join и перекос ключа (data skew), когда одно значение ключа собирает непропорционально много строк и один воркер становится узким местом — лечится salting или broadcast’ом.

Какие есть основные стратегии масштабирования баз данных?

Заголовок раздела «Какие есть основные стратегии масштабирования баз данных?»

Коротко. По возрастанию сложности: вертикальное масштабирование (больше CPU/RAM/NVMe), оптимизация (индексы, переписывание запросов, пулинг соединений через PgBouncer), кеширование (Redis, материализованные представления), реплики для чтения, партиционирование внутри одного узла, шардирование (горизонтальное разбиение по узлам), разделение нагрузок (OLTP отдельно, OLAP/поиск в специализированном хранилище), и в пределе — смена модели данных.

Глубже. По каждому пункту — что важно сказать. Реплики масштабируют только чтение и приносят репликационный лаг: сразу после записи чтение с реплики может вернуть старые данные, поэтому нужны правила маршрутизации (критичное чтение — с мастера, read-your-writes через закрепление сессии или ожидание LSN). Партиционирование (в PostgreSQL — декларативное, по диапазону/списку/хешу) не увеличивает суммарную мощность, но даёт partition pruning, дешёвое удаление старых данных через DROP PARTITION, меньшие индексы и меньше блоат; типичный кейс — секции по времени.

Шардирование — самый мощный и самый дорогой шаг. Обсудить надо: выбор ключа шардирования (он определяет, какие запросы будут дешёвыми, а какие превратятся в scatter-gather по всем шардам), перекос данных и «горячие» шарды, невозможность обычных кросс-шардовых транзакций и джойнов, ресорсинг при добавлении шарда (отсюда consistent hashing или каталог диапазонов вместо hash % N), уникальность идентификаторов (UUIDv7/Snowflake вместо автоинкремента), и то, что операционная сложность растёт нелинейно. Готовые варианты — Citus, Vitess, YDB/CockroachDB/Yugabyte, или шардирование в коде приложения.

Отдельно стоит назвать вещи, которые часто дают больший эффект дешевле шардирования: убрать N+1-запросы, вынести тяжёлую аналитику в ClickHouse, включить пулер соединений (PostgreSQL плохо переносит тысячи соединений — каждое это процесс), архивировать холодные данные, использовать COPY/батчи вместо построчных вставок, вынести blob’ы в объектное хранилище. И честная ремарка: почти всегда «база не тянет» лечится не архитектурой, а планом конкретных запросов.

Коротко. «Банда четырёх» описала 23 паттерна в трёх группах: порождающие (Singleton, Factory Method, Abstract Factory, Builder, Prototype), структурные (Adapter, Bridge, Composite, Decorator, Facade, Flyweight, Proxy) и поведенческие (Chain of Responsibility, Command, Interpreter, Iterator, Mediator, Memento, Observer, State, Strategy, Template Method, Visitor). Это каталог типовых решений повторяющихся задач проектирования и общий словарь для обсуждения дизайна.

Глубже. Важнее списка — понимание, что каталог написан для языков с классовым наследованием (C++, Smalltalk), и в Go часть паттернов вырождается или заменяется языковыми средствами. Template Method без наследования делают функцией высшего порядка или полем-функцией в структуре. Iterator с Go 1.23 стал частью языка: iter.Seq[V]/iter.Seq2[K,V] и range-over-func, плюс пакет slices/maps с функциями slices.Values, maps.Keys. Visitor нужен редко и обычно заменяется type switch по интерфейсу. Abstract Factory чаще всего избыточна. Flyweight в Go отчасти закрывается sync.Pool и интернированием строк (unique.Make в Go 1.23). Реально живут и активно используются: Strategy, Decorator, Adapter, Facade, Factory, Observer, Command, State, Proxy, Singleton.

Хорошая финальная фраза для собеседования: паттерны — это словарь, а не цель. Их ценность в том, что можно сказать «сделаем декоратор поверх репозитория» вместо трёх минут объяснений; попытка же натянуть каталог на код (AbstractOrderFactoryProvider) — верный способ получить сложность вместо гибкости.

Коротко. См. выше вопрос «Что такое CQRS?». Кратко повторю: разделение операций на команды (изменяют состояние) и запросы (только читают), вплоть до разных моделей и разных хранилищ; выигрыш — независимая оптимизация и масштабирование чтения; цена — eventual consistency между записью и проекциями и удвоение кода.

Глубже. Здесь уместно добавить то, чего не было в первом ответе: CQRS вырос из принципа CQS Бертрана Мейера (метод либо меняет состояние, либо возвращает значение, но не оба сразу) и от него отличается тем, что разделяет не методы, а модели целиком. Практическая градация внедрения: (1) разделить только типы DTO и интерфейсы в коде — почти бесплатно и уже полезно; (2) читать из реплик, писать в мастер; (3) завести отдельную денормализованную read-модель (материализованное представление, Elasticsearch, Redis), обновляемую подписчиком; (4) полный CQRS + event sourcing. Начинать нужно с (1) и подниматься только по нужде. И повторю главный тезис: CQRS и event sourcing независимы — можно иметь одно без другого.

Коротко. Анемичная (anemic) доменная модель — это когда доменные объекты представляют собой структуры из полей с геттерами/сеттерами и без поведения, а вся бизнес-логика вынесена в «сервисы», которые эти структуры перекладывают. Мартин Фаулер называет это антипаттерном, потому что объект теряет способность защищать свои инварианты: изменить состояние может кто угодно и как угодно.

Глубже. Симптом — код вида if order.Status == "new" { order.Status = "paid"; order.PaidAt = now; repo.Save(order) }, разбросанный по трём сервисам, где каждый по-своему помнит правила переходов. Богатая модель вместо этого прячет поля и даёт методы-намерения: order.Pay(now) внутри проверяет статус, проставляет всё нужное и порождает доменное событие; невалидное состояние просто невозможно собрать. Инструменты в Go: неэкспортируемые поля, конструктор-валидатор (NewOrder(...) (*Order, error)), value objects (type Money struct{ amount int64; currency string } вместо float64), методы с бизнес-именами.

Важная нюансировка, которая отличает зрелый ответ от догматичного: анемичная модель — не всегда зло. Для CRUD-сервиса, ETL-конвейера, тонкого API-шлюза или прослойки над чужой системой она полностью адекватна, и попытка натянуть DDD туда добавит слоёв без пользы. Богатая модель окупается там, где логики много и она меняется: биллинг, скидки, тарифы, статусные машины. Отдельно: в Go богатая модель ограничена отсутствием наследования и тем, что ORM-подобные библиотеки любят экспортируемые поля и теги; типичное решение — держать доменную структуру чистой, а в репозитории мапить её в отдельную персистентную модель.

Коротко. Доменное событие — факт, произошедший в домене, выраженный в прошедшем времени (OrderPaid, UserRegistered) и содержащий минимум данных для реакции. Агрегат порождает событие при изменении состояния, слой приложения после успешной фиксации транзакции публикует его, а подписчики (внутри процесса или в другом сервисе) реагируют. Вопрос про опыт — отвечать надо конкретным сценарием.

Глубже. Ключевая техническая проблема, ради которой этот вопрос и задают, — атомарность записи состояния и публикации события. Если сохранить заказ в БД, а потом отправить сообщение в Kafka, то падение между этими шагами теряет событие; если наоборот — событие уходит про изменение, которого не было. Каноническое решение — transactional outbox: событие пишется в таблицу outbox в той же транзакции, что и изменение, а отдельный релей (по опросу таблицы или через CDC/Debezium из WAL) публикует его в брокер с гарантией at-least-once. Отсюда следствие: у потребителей обязана быть идемпотентность и дедупликация по event_id.

Что ещё стоит проговорить. Различайте доменное событие (внутреннее, богатое доменными типами, часть модели) и интеграционное событие (публичный контракт наружу, версионируемый, со стабильной схемой) — смешивать их вредно, потому что внутренний рефакторинг тогда ломает потребителей. Событие в прошедшем времени и неизменяемо; в нём должны быть event_id, aggregate_id, версия схемы, время. Обработчики внутри той же транзакции — плохая идея (растёт время блокировки, отказ подписчика откатывает основную операцию); лучше «сохранили → закоммитили → опубликовали». Технически в Go агрегат обычно накапливает события в приватном слайсе и отдаёт их методом PullEvents(), а use case пишет их в outbox.

Коротко. Вопрос-«добивка» про опыт. Хороший ответ перечисляет не паттерны, а инженерные практики: contract-first (proto/OpenAPI как источник истины плюс кодогенерация и проверка обратной совместимости), ADR на значимые решения, feature flags и постепенная раскатка, идемпотентность и outbox по умолчанию для всего, что пишет, обязательные дедлайны и распространение контекста, RED/USE-метрики и трассировка на каждый сценарий, тесты на архитектурные границы.

Глубже. Развернуть можно так: contract-first — схема описывается до кода, ломающие изменения ловятся линтером (buf breaking), клиенты генерируются, документация не расходится с реальностью. ADR — короткий документ «контекст, варианты, решение, последствия», который через год объясняет, почему выбрали Kafka, а не SQS; практика особенно ценна при ротации людей. Strangler fig — стратегия постепенной замены легаси: новый код перехватывает часть трафика через прокси, старый постепенно отмирает, без «большого переписывания». Тесты на границы — линтер, который падает, если domain импортирует infrastructure; это единственный способ, чтобы архитектура не размылась за год. Модульный монолит как осознанная альтернатива микросервисам на старте: границы модулей соблюдаются в коде, но развёртывание одно, и выделять сервисы можно по мере надобности. Из более мелкого, но полезного: errors.Is/errors.As и типизированные доменные ошибки вместо строк, structured logging (log/slog), graceful shutdown с дренированием соединений, health/readiness-пробы, миграции как код.

Какие паттерны проектирования использовал?

Заголовок раздела «Какие паттерны проектирования использовал?»

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

Глубже. Сильный формат ответа — по одному предложению на паттерн, каждый с местом применения: «Strategy — интерфейс PaymentProvider с реализациями для трёх шлюзов, выбор по конфигу; Decorator — обёртки репозитория с метриками и кешем, собираются в main; функциональные опции — во всех наших конструкторах клиентов; Observer через каналы — внутренняя шина уведомлений с fan-out на подписчиков; Adapter — обёртка над чужим SDK, чтобы наш домен не зависел от его типов; Singleton через sync.Once — только для пула соединений и реестра метрик». Дальше интервьюер обычно выберет один и попросит подробностей, поэтому не называйте того, чего не сможете раскрыть.

Что такое паттерн «синглтон»? Для чего он нужен и как его правильно реализовать в Go?

Заголовок раздела «Что такое паттерн «синглтон»? Для чего он нужен и как его правильно реализовать в Go?»

Коротко. Синглтон гарантирует единственный экземпляр на процесс и точку доступа к нему; нужен для ресурсов, которые дорого или бессмысленно создавать повторно, — пул соединений с БД, HTTP-клиент, логгер, реестр метрик, загруженный конфиг. В Go правильная ленивая реализация — пакетная переменная плюс sync.Once; если инициализация может завершиться ошибкой, нужен sync.OnceValues (Go 1.21+) или хранение ошибки рядом с экземпляром.

Глубже. sync.Once гарантирует, что переданная функция выполнится ровно один раз, и — что важнее — что все горутины, вышедшие из Do, увидят результат этой функции: внутри стоит атомарная проверка done с acquire-семантикой и мьютекс на медленном пути, так что happens-before между инициализацией и последующими чтениями обеспечен. Наивный вариант if instance == nil { instance = &T{} } без синхронизации — это гонка данных: в модели памяти Go другая горутина может увидеть ненулевой указатель на ещё не проинициализированный объект, и go test -race это поймает.

Альтернативы и когда они уместны. Инициализация в init() или прямо в объявлении переменной (var client = newClient()) — проще всего, но исполняется всегда, даже если объект не понадобился, и не умеет возвращать ошибку иначе как паникой; порядок init между пакетами определён (по зависимостям), но полагаться на него для сложной логики не стоит. sync.OnceValue/sync.OnceValues/sync.OnceFunc из Go 1.21 делают код короче. Главная же рекомендация: если экземпляр не обязан быть глобальным, создайте его в main и передайте явно — тестируемость и понятность зависимостей важнее удобства глобального доступа. Отдельно помните, что sync.Once запоминает и неудачу: если функция вернула ошибку, повторной попытки не будет, и для «переинициализировать после сбоя» нужен другой механизм.

package db
import (
"database/sql"
"sync"
)
var connect = sync.OnceValues(func() (*sql.DB, error) {
d, err := sql.Open("pgx", dsn)
if err != nil {
return nil, err
}
d.SetMaxOpenConns(20)
return d, nil
})
func DB() (*sql.DB, error) { return connect() }

Предположим, sync.Once отсутствует в стандартной библиотеке. Как вы реализуете потокобезопасный синглтон с ленивой инициализацией, используя другие примитивы из пакета sync ?

Заголовок раздела «Предположим, sync.Once отсутствует в стандартной библиотеке. Как вы реализуете потокобезопасный синглтон с ленивой инициализацией, используя другие примитивы из пакета sync ?»

Коротко. Самый простой корректный вариант — sync.Mutex вокруг проверки и создания: захватить мьютекс, проверить, что экземпляр ещё не создан, создать, отпустить. Если хочется избежать блокировки на горячем пути, применяется double-checked locking, но обязательно с атомарным чтением/записью указателя (sync/atomic, идеально atomic.Pointer[T] из Go 1.19), иначе это гонка данных, а не оптимизация.

Глубже. Разберём почему. Вариант с мьютексом на каждый вызов корректен всегда и на практике достаточен: незанятый мьютекс — это одна атомарная операция, десятки наносекунд. Наивный DCL с обычной переменной (if instance != nil { return instance } вне лока) некорректен: в модели памяти Go нет happens-before между записью указателя в одной горутине и его чтением в другой без синхронизации, поэтому читатель может получить указатель на объект, поля которого ещё не видны. Замена обычного чтения на atomic.Pointer.Load() и записи на Store() даёт нужные release/acquire-гарантии — модель памяти Go с Go 1.19 явно это документирует.

Ещё варианты из пакета sync: sync.RWMutex (RLock на чтение, полный Lock на создание с повторной проверкой) — выглядит логично, но при высокой конкуренции обычно не лучше обычного мьютекса и точно не лучше атомика; sync.Map с LoadOrStore — годится, если синглтонов много и они по ключу, но LoadOrStore требует уже созданное значение, поэтому при дорогой инициализации нужен трюк с хранением «ленивого» контейнера; sync.WaitGroup тут неприменим. Про поведение при ошибке: как и sync.Once, ваша реализация должна явно решить, кешировать ли неудачу; если нет — сбрасывать флаг под локом, чтобы следующая горутина попробовала снова, но тогда нужно защититься от «стада» одновременных повторов.

package cfg
import (
"sync"
"sync/atomic"
)
type Config struct{ Addr string }
var (
mu sync.Mutex
inst atomic.Pointer[Config] // Go 1.19+
)
// Get — double-checked locking, корректный за счёт атомарного указателя.
func Get() *Config {
if c := inst.Load(); c != nil { // быстрый путь без блокировки
return c
}
mu.Lock()
defer mu.Unlock()
if c := inst.Load(); c != nil { // вторая проверка под локом
return c
}
c := &Config{Addr: "localhost:8080"}
inst.Store(c)
return c
}
// GetSimple — вариант без атомиков: проще и всегда корректен.
var simple *Config
func GetSimple() *Config {
mu.Lock()
defer mu.Unlock()
if simple == nil {
simple = &Config{Addr: "localhost:8080"}
}
return simple
}

Какие паттерны проектирования из классической книги «банды четырех» вы применяли на практике в Go? Приведите примеры.

Заголовок раздела «Какие паттерны проектирования из классической книги «банды четырех» вы применяли на практике в Go? Приведите примеры.»

Коротко. Реально работающий в Go набор: Strategy (интерфейс или функциональный тип для подменяемого алгоритма), Decorator (middleware и обёртки над http.RoundTripper/репозиторием), Adapter (обёртка над чужим SDK, приводящая его к нашему порту), Factory (New..., возвращающий интерфейс, и реестр конструкторов), Observer (fan-out через каналы или список подписчиков), Facade (один фасадный клиент над несколькими внутренними), Singleton (sync.Once), State (явная машина состояний заказа), Builder в виде функциональных опций.

Глубже. Примеры, которые звучат убедительно. Strategy: type Pricer interface { Price(ctx, Order) (Money, error) } с реализациями «обычная», «промо», «B2B»; выбор по признаку заказа. Часто интерфейс с одним методом заменяют функциональным типом — это идиоматичнее. Decorator: http.Handler-middleware (WithAuth, WithTracing), gRPC-интерсепторы, и http.RoundTripper, который добавляет заголовки и метрики всем исходящим запросам. Adapter: чужой платёжный SDK возвращает свои типы и свои ошибки; адаптер конвертирует их в наши доменные, и остальная система про SDK не знает — благодаря этому смена провайдера локализуется в одном файле. Observer: внутренняя шина, где Publish рассылает событие в набор каналов подписчиков; тут обязательно продумать неблокирующую отправку и медленных подписчиков, иначе паблишер зависнет. State: вместо switch по строке-статусу — тип-состояние с методами разрешённых переходов, что убирает целый класс багов. Template Method в Go делается не наследованием, а передачей функции-шага: func Process(ctx context.Context, load func() ([]Item, error), handle func(Item) error) error.

Не забудьте про честную оговорку — где вы сознательно не применяли паттерн: Abstract Factory и Visitor в типичном Go-сервисе избыточны, Iterator до Go 1.23 писали руками, а с 1.23 это iter.Seq и range-over-func, Flyweight закрывается sync.Pool и unique (Go 1.23).

Сделайте решение расширяемым: если через месяц добавятся kind = "bicycle" и kind = "house", изменения в коде должны быть минимальными и локализованными.

Заголовок раздела «Сделайте решение расширяемым: если через месяц добавятся kind = "bicycle" и kind = "house", изменения в коде должны быть минимальными и локализованными.»

Коротко. Заменить switch kind { ... } на реестр фабрик: интерфейс продукта, тип-конструктор func(Spec) (Product, error), map[string]Factory и функция Register. Тогда добавление нового вида — это новый файл с реализацией и одной строкой регистрации; существующий код не меняется вообще. Это буквальное применение OCP плюс Factory Method.

Глубже. Технические детали, которые стоит проговорить. Где регистрировать: два стиля. Через init() в файле реализации — добавление вида требует только создать файл и импортировать пакет (иногда blank-импортом _ "app/vehicle/bicycle"), но регистрация становится неявной и зависит от порядка импортов; явная регистрация в main/wire.go многословнее, зато граф зависимостей виден и тестируется. Для библиотеки чаще берут init, для сервиса — явную сборку. Гонки: карта заполняется на старте и дальше только читается — это безопасно; если регистрация возможна в рантайме, нужен sync.RWMutex или sync.Map. Дубликаты: паниковать при повторной регистрации того же ключа — ошибка конфигурации должна падать на старте, а не молча перетирать. Неизвестный kind: возвращать типизированную ошибку (ErrUnknownKind), чтобы транспорт мог отдать 400, а не 500. Валидация специфичных полей должна жить в конструкторе конкретного вида, иначе общая структура снова обрастёт знанием обо всех видах.

Если различаются не только конструкторы, но и поведение, тот же приём поднимается на уровень выше: реестр отдаёт не «объект», а стратегию/хендлер (map[string]Handler), и добавление вида — это новый хендлер. Для случая, когда виды приходят из внешнего конфига и их поля разные, входной payload удобно держать как json.RawMessage и разбирать уже внутри конкретной фабрики — иначе общий тип запроса превратится в объединение всех полей всех видов.

package vehicle
import (
"encoding/json"
"fmt"
)
type Vehicle interface {
Kind() string
Describe() string
}
// Factory создаёт конкретный вид из «сырых» параметров.
type Factory func(raw json.RawMessage) (Vehicle, error)
var registry = make(map[string]Factory)
// Register вызывается из init() пакета реализации либо явно из main.
func Register(kind string, f Factory) {
if _, dup := registry[kind]; dup {
panic("vehicle: duplicate registration for kind " + kind)
}
registry[kind] = f
}
var ErrUnknownKind = fmt.Errorf("vehicle: unknown kind")
func New(kind string, raw json.RawMessage) (Vehicle, error) {
f, ok := registry[kind]
if !ok {
return nil, fmt.Errorf("%w: %q", ErrUnknownKind, kind)
}
return f(raw)
}
package bicycle
import (
"encoding/json"
"errors"
"app/vehicle"
)
type Bicycle struct {
Gears int `json:"gears"`
}
func (b *Bicycle) Kind() string { return "bicycle" }
func (b *Bicycle) Describe() string { return "bicycle" }
func init() {
vehicle.Register("bicycle", func(raw json.RawMessage) (vehicle.Vehicle, error) {
var b Bicycle
if err := json.Unmarshal(raw, &b); err != nil {
return nil, err
}
if b.Gears <= 0 {
return nil, errors.New("bicycle: gears must be positive")
}
return &b, nil
})
}
  • Называть паттерны по книге, не умея показать, как они выглядят именно в Go: рассказывать про абстрактные классы и наследование там, где есть только интерфейсы и композиция.
  • Путать CQRS и event sourcing, считать, что одно обязательно требует другого, или что CQRS невозможен без брокера и второй базы.
  • Говорить «мы сделали ретраи» без упоминания идемпотентности, джиттера, бюджета ретраев и оставшегося дедлайна — это прямой путь к retry storm, и интервьюер обязательно это поддавит.
  • Считать, что дедупликация в Redis или Kafka-транзакции дают exactly-once для побочных эффектов во внешних системах. Барьер уникальности должен быть в той же транзакционной БД, что и сам эффект.
  • Предлагать LIMIT/OFFSET для бесконечной ленты и не видеть двух проблем: линейной деградации на глубине и съезжающих страниц при вставках.
  • Объявлять интерфейс на каждую структуру «на всякий случай» и держать его рядом с реализацией, а не у потребителя. В Go интерфейс вводится по факту второй реализации или теста и делается узким.
  • Писать синглтон через if instance == nil без синхронизации и не понимать, почему это гонка данных даже на «безобидном» присваивании указателя.
  • Отвечать на вопросы про личный опыт списком модных слов без конкретной проблемы, альтернатив и цены решения. Интервьюер оценивает именно связку «проблема → выбор → последствия».