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

Принципы проектирования: SOLID, DI, DDD, чистая архитектура

Все принципы этой подтемы решают одну задачу: сделать так, чтобы стоимость изменения кода не росла экспоненциально с его объёмом. Программа, которую никогда не будут менять, не нуждается ни в SOLID, ни в слоях — её можно написать одним файлом. Как только появляется вторая команда, второй источник данных или второй способ доставки запросов, начинает работать простое правило: код меняется по границам, которые вы заранее провели. Если границ нет — любое изменение расползается по всей кодовой базе. Поэтому в разговоре на собеседовании стоит держать в голове не список букв, а причину: SOLID — это набор эвристик о том, где проводить границы; DI и DIP — механика того, как эти границы не склеиваются обратно; чистая/гексагональная архитектура — конкретная раскладка границ по слоям; DDD — способ выбрать границы так, чтобы они совпадали с границами предметной области, а не с техническими деталями.

Ключевая идея, объединяющая DIP, чистую архитектуру и гексагон, — правило зависимостей: исходные зависимости (import’ы) всегда направлены внутрь, к бизнес-логике, и никогда наружу, к деталям. Домен не знает про PostgreSQL, Kafka и HTTP; он объявляет интерфейс («мне нужно уметь сохранить заказ»), а инфраструктура его реализует. Направление вызова при этом остаётся прежним (usecase вызывает репозиторий), а вот направление компиляционной зависимости инвертировано — отсюда и слово «инверсия». В Go это выражается особенно естественно: интерфейсы неявные (structural typing), поэтому интерфейс объявляется на стороне потребителя, а пакет-реализация про него вообще не знает и не импортирует. Это убирает главный источник циклических зависимостей и делает узкие интерфейсы (ISP) идиоматикой, а не дисциплиной.

Второй смысловой блок — DDD. Его чаще всего сводят к «папочкам entity/valueobject/repository», но это тактическая часть и она вторична. Главное в DDD — стратегическая часть: единый язык (ubiquitous language) с экспертами домена и деление системы на ограниченные контексты (bounded contexts), внутри каждого из которых термин «Заказ» означает ровно одно. Именно bounded context, а не «сущность», обычно становится границей микросервиса. Тактические шаблоны (сущность, объект-значение, агрегат, репозиторий, доменный сервис, доменное событие) — это способ выразить инварианты домена в коде так, чтобы их нельзя было нарушить снаружи.

Третий блок — метрики качества границ: связность (cohesion) внутри модуля должна быть высокой, связанность (coupling) между модулями — низкой. Практически все принципы SOLID — это частные случаи этого правила. И к тому же блоку относятся KISS, DRY, YAGNI — противовесы, которые не дают увлечься абстракциями: SOLID тянет в сторону большего числа сущностей, YAGNI и KISS тянут обратно, и хороший инженер удерживает баланс, а не доводит один полюс до абсурда.

Как выглядел ваш вариант чистой архитектуры на Go? Из каких слоев состоял?

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

Коротко. Это вопрос про личный опыт: интервьюер хочет услышать не канонические четыре круга Мартина, а конкретную раскладку по пакетам, объяснение, где вы отступили от канона и почему. Хороший каркас ответа — три слоя (domain → usecase → adapters) плюс cmd для сборки зависимостей, и обязательно пример, как проходит один запрос сверху вниз.

Глубже. Что стоит рассказать по частям: (1) структура каталогов — internal/domain (сущности, объекты-значения, доменные ошибки, интерфейсы репозиториев или они рядом с usecase), internal/usecase (сценарии, транзакционные границы), internal/adapter/{http,grpc,postgres,kafka}, cmd/<service>/main.go (composition root); (2) правило зависимостей — domain не импортирует ничего, кроме stdlib; (3) как связываются слои — интерфейсы объявляются потребителем, реализации внедряются в main; (4) DTO на границах: HTTP-структуры не протекают в usecase, доменные структуры не сериализуются напрямую в JSON — есть маппинг; (5) где вы сознательно упростили — например, не заводили отдельный слой entity для CRUD-ручек, а провели их напрямую handler → repo, чтобы не плодить пустые прослойки.

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

internal/usecase/order.go
package usecase
import (
"context"
"myapp/internal/domain"
)
// Интерфейс объявлен здесь — на стороне потребителя.
type OrderRepository interface {
Save(ctx context.Context, o *domain.Order) error
ByID(ctx context.Context, id domain.OrderID) (*domain.Order, error)
}
type OrderService struct {
repo OrderRepository
pub EventPublisher
}
func NewOrderService(repo OrderRepository, pub EventPublisher) *OrderService {
return &OrderService{repo: repo, pub: pub}
}
func (s *OrderService) Place(ctx context.Context, id domain.OrderID, items []domain.Item) error {
o, err := domain.NewOrder(id, items) // инварианты проверяются в домене
if err != nil {
return err
}
if err := s.repo.Save(ctx, o); err != nil {
return err
}
return s.pub.Publish(ctx, o.Events()...)
}

Провальные варианты ответа: перечислить круги с картинки и не сказать ни слова про свой код; заявить «у нас была идеальная чистая архитектура» без единого компромисса; путать usecase и handler.

Коротко. Domain-Driven Design — подход к проектированию сложных систем, в котором структура кода следует структуре предметной области, а не техническим слоям. Его центр — единый язык (ubiquitous language), общий у разработчиков и экспертов домена, и деление системы на ограниченные контексты (bounded contexts), внутри каждого из которых термины имеют одно фиксированное значение.

Глубже. DDD принято делить на стратегическую и тактическую части. Стратегическая: ubiquitous language, bounded context, карта контекстов (context map) с типами связей между ними — shared kernel, customer/supplier, conformist, anticorruption layer (ACL), open host service. Именно здесь принимаются решения, которые дорого менять: где проходят границы сервисов, кто чей клиент, где ставить слой антикоррупции, чтобы чужая модель не протекала в вашу. Тактическая: Entity (объект с идентичностью, живущий во времени), Value Object (неизменяемый объект без идентичности, сравнивается по значению), Aggregate + Aggregate Root (граница транзакционной консистентности), Repository (коллекциеподобный доступ к агрегатам), Domain Service (операция домена, не принадлежащая ни одной сущности), Domain Event, Factory.

Важная оговорка для собеседования: DDD оправдан там, где есть сложная бизнес-логика с нетривиальными правилами. Для CRUD-сервиса, который перекладывает JSON в таблицу, DDD — оверинжиниринг, и умение это сказать ценится выше, чем умение перечислить шаблоны. Каноническая книга — Eric Evans, «Domain-Driven Design» (2003), «синяя книга»; более практичная — Vaughn Vernon, «Implementing DDD» («красная книга»).

Коротко. Агрегат — кластер связанных объектов (сущностей и объектов-значений), который рассматривается как одно целое с точки зрения изменения данных. У агрегата есть корень (aggregate root) — единственная точка входа: снаружи можно ссылаться только на корень, а все инварианты внутри агрегата поддерживаются им и в момент коммита транзакции должны быть выполнены.

Глубже. Ключевые правила: (1) агрегат — граница транзакционной консистентности: одна транзакция изменяет один агрегат, между агрегатами консистентность конечная (eventual), через доменные события; (2) ссылки между агрегатами — только по идентификатору, а не по указателю на объект; (3) репозиторий существует один на агрегат, а не на каждую сущность внутри; (4) агрегат стоит делать как можно меньше — большой агрегат превращается в точку блокировок и конфликтов при конкурентных изменениях.

package domain
import "errors"
type OrderID string
// Order — корень агрегата. Позиции наружу не отдаются на изменение.
type Order struct {
id OrderID
items []Item // часть агрегата
status Status
}
var ErrTooManyItems = errors.New("order: too many items")
// Инвариант поддерживается корнем — снаружи в срез не залезть.
func (o *Order) AddItem(it Item) error {
if len(o.items) >= 100 {
return ErrTooManyItems
}
if o.status != StatusDraft {
return errors.New("order: cannot modify non-draft order")
}
o.items = append(o.items, it)
return nil
}

Типичная ошибка — делать агрегатом всю схему БД («Пользователь» с заказами, платежами и историей). Правильный критерий размера: какие данные обязаны меняться атомарно, чтобы бизнес-правило не нарушилось.

Коротко. SOLID — пять принципов объектно-ориентированного дизайна (SRP, OCP, LSP, ISP, DIP), задающих, где проводить границы между модулями. KISS (Keep It Simple, Stupid), DRY (Don’t Repeat Yourself) и YAGNI (You Aren’t Gonna Need It) — три эвристики-противовеса: не усложняй, не дублируй знание, не пиши то, что ещё не требуется.

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

  • DRY — про дублирование знания, а не текста. Два одинаковых куска кода, которые меняются по разным причинам и по разным поводам, дублированием не являются; преждевременное объединение таких кусков в общую функцию создаёт связанность между несвязанными вещами. В микросервисах DRY через общую библиотеку с бизнес-логикой — известный антипаттерн: он возвращает распределённый монолит. Формулировка Хантa и Томаса: «Every piece of knowledge must have a single, unambiguous, authoritative representation within a system».
  • KISS — предпочитать простое решение. В Go это буквально культурная норма: «clear is better than clever» из Go Proverbs.
  • YAGNI — не реализовывать функциональность «на будущее». Прямо противоречит наивному чтению OCP («сделаем всё расширяемым заранее»). Правило разрешения конфликта: абстракцию вводят по факту второго-третьего случая, а не по предчувствию (правило трёх).
  • SOLID тянет в сторону большего числа мелких сущностей; YAGNI и KISS ограничивают этот рост. Хороший ответ на собеседовании показывает, что вы умеете держать баланс: «интерфейс завожу, когда есть вторая реализация или когда нужен мок в тесте, а не заранее».

Коротко. Dependency Inversion Principle (буква D в SOLID): модули высокого уровня не должны зависеть от модулей низкого уровня — оба должны зависеть от абстракций; абстракции не должны зависеть от деталей — детали должны зависеть от абстракций. Практически это означает, что бизнес-логика объявляет интерфейс нужного ей сервиса, а инфраструктура его реализует.

Глубже. Слово «инверсия» относится к направлению зависимости при компиляции, а не к направлению вызова. Без DIP: usecase импортирует postgres — поменяли БД, перекомпилировали и, вероятно, поправили usecase. С DIP: usecase объявляет OrderRepository, postgres импортирует… ничего (в Go — благодаря неявным интерфейсам вообще ничего), а связывание происходит в main. Стрелка import’а развёрнута против стрелки вызова.

В Go DIP реализуется дешевле, чем в Java/C#: там интерфейс обычно живёт в отдельном пакете, который импортируют обе стороны, здесь — интерфейс объявляется потребителем и реализация о нём не знает («accept interfaces, return structs»). Побочный эффект: исчезает целый класс циклических импортов и не нужны пакеты вида interfaces или contracts.

Коротко. Dependency Injection — приём, при котором объект не создаёт свои зависимости сам, а получает их извне: через конструктор, сеттер или параметр метода. Цель — убрать из компонента знание о том, как конструируются его зависимости, и дать возможность подменить их в тестах и при смене окружения.

Глубже. Виды внедрения: конструкторное (в Go — доминирующее и рекомендуемое, NewService(repo, logger, clock)), через поля/сеттеры (используется редко, оставляет объект в частично сконструированном состоянии), через параметры метода (для того, что меняется от вызова к вызову). Место, где все зависимости собираются в граф, называется composition root — в Go это func main() или отдельный пакет internal/app.

func main() {
cfg := config.Load()
pool, err := pgxpool.New(context.Background(), cfg.DSN)
if err != nil {
log.Fatal(err)
}
defer pool.Close()
repo := postgres.NewOrderRepo(pool) // реализация
pub := kafka.NewPublisher(cfg.Brokers) // реализация
svc := usecase.NewOrderService(repo, pub) // внедрение через конструктор
h := httpapi.NewHandler(svc)
log.Fatal(http.ListenAndServe(cfg.Addr, h.Routes()))
}

Важно: DI — это не фреймворк. Ручная сборка в main — полноценный и в Go самый распространённый DI. Кодогенераторы (google/wire) и рантайм-контейнеры (uber-go/dig, uber-go/fx) нужны, когда граф зависимостей вырастает до сотни узлов.

Коротко. TDD (Test-Driven Development) — практика разработки через тесты: цикл red → green → refactor, тест пишется до кода. DDD (Domain-Driven Design) — подход к проектированию вокруг предметной области, см. ответ выше. Аббревиатуры созвучны, но лежат в разных плоскостях: TDD — про процесс написания кода, DDD — про то, как этот код структурирован.

Глубже. Связь между ними всё же есть: TDD давит в сторону слабой связанности (иначе юнит-тест не написать без поднятия половины системы), а значит, естественно приводит к внедрению зависимостей и узким интерфейсам — то есть к тем же границам, которые постулирует DDD и чистая архитектура. Обратно: чистое доменное ядро без инфраструктурных зависимостей — идеальный объект для TDD, потому что тестируется без БД и сети, за микросекунды.

Практические детали TDD, которые уместно упомянуть: три шага цикла (написать падающий тест → минимальный код, чтобы он прошёл → рефакторинг при зелёных тестах); отличие от «просто писать тесты» — в TDD тест является спецификацией и инструментом дизайна; в Go этому помогает go test, табличные тесты и t.Run для подтестов. Ответ «мы делаем TDD всегда и везде» звучит неубедительно — честнее сказать, где применяете (алгоритмическое ядро, парсеры, доменные правила) и где нет (интеграционные слои, прототипы).

Коротко. SOLID — акроним пяти принципов объектно-ориентированного проектирования, собранных Робертом Мартином: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion. Все они про одно: как разложить систему на модули, чтобы изменение требований затрагивало минимум кода.

Глубже. Формулировки, которые стоит знать точно:

  • SRP — «у модуля должна быть одна причина для изменения»; поздняя формулировка Мартина точнее: «модуль должен отвечать перед одним и только одним актором» (один источник требований — бизнес, бухгалтерия, DBA).
  • OCP (Бертран Мейер) — «программные сущности должны быть открыты для расширения и закрыты для модификации»: новое поведение добавляется новым кодом, а не правкой существующего.
  • LSP (Барбара Лисков, 1987) — объекты подтипа должны быть подставимы вместо объектов базового типа без нарушения корректности программы.
  • ISP — «клиенты не должны зависеть от методов, которые они не используют»: лучше несколько узких интерфейсов, чем один толстый.
  • DIP — зависимость на абстракции, а не на конкретику; детали зависят от абстракций.

Полезная рамка для ответа: SRP и ISP — про размер и назначение модулей (высокая связность), OCP и DIP — про направление зависимостей (низкая связанность), LSP — про корректность подстановки, то есть про то, чтобы полиморфизм не врал.

Коротко. The Twelve-Factor App — методология построения SaaS-приложений (Adam Wiggins, Heroku, 2011), описывающая двенадцать правил, которые делают сервис пригодным для облачного развёртывания: конфигурация в переменных окружения, stateless-процессы, логи в stdout, явные зависимости и так далее.

Глубже. Двенадцать факторов:

  1. Codebase — один репозиторий на приложение, много деплоев из него.
  2. Dependencies — зависимости объявлены явно и изолированы (в Go — go.mod/go.sum, никаких системных пакетов «по умолчанию»).
  3. Config — конфигурация в переменных окружения, а не в файлах в репозитории; всё, что различается между окружениями.
  4. Backing services — БД, очереди, кеши — подключаемые ресурсы, адресуемые URL; замена локального Postgres на managed не должна требовать изменений кода.
  5. Build, release, run — три строго разделённые стадии; релиз неизменяем и имеет уникальный id.
  6. Processes — процессы stateless и share-nothing; состояние — только в backing services.
  7. Port binding — приложение само экспортирует HTTP, слушая порт, а не встраивается в внешний веб-сервер (для Go это естественно: http.ListenAndServe).
  8. Concurrency — масштабирование горизонтально, добавлением процессов.
  9. Disposability — быстрый старт и graceful shutdown по SIGTERM (в Go — http.Server.Shutdown(ctx)).
  10. Dev/prod parity — окружения максимально похожи по времени, людям и инструментам.
  11. Logs — логи как поток событий в stdout, агрегация — задача платформы.
  12. Admin processes — разовые задачи (миграции) выполняются как одноразовые процессы в том же окружении и из того же релиза.

Критика, уместная для senior-уровня: часть факторов устарела или уточнилась в эпоху Kubernetes (например, секреты стоит подавать не только через env, потому что переменные окружения утекают в дампы и в /proc; факторы не покрывают телеметрию, health-чеки и распределённую трассировку — отсюда неформальные «15-factor» дополнения).

Коротко. Принцип подстановки Лисков: если S — подтип T, то объекты типа T в программе можно заменить объектами типа S, не изменяя корректность программы. Иначе говоря, реализация обязана соблюдать контракт абстракции, а не только её сигнатуру.

Глубже. Формальные следствия: в подтипе предусловия нельзя усиливать (нельзя требовать от вызывающего больше, чем базовый тип), постусловия нельзя ослаблять (нельзя гарантировать меньше), инварианты базового типа должны сохраняться, и должно выполняться history rule — подтип не должен позволять изменения состояния, недопустимые в базовом типе. Классический контрпример — Square наследует Rectangle: SetWidth у квадрата вынужден менять и высоту, что ломает постусловие прямоугольника.

В Go наследования нет, но LSP полностью применим к интерфейсам: тип реализует интерфейс формально (сигнатуры совпадают), но может нарушать его семантический контракт. Живой пример из stdlib — io.Writer: контракт требует «Write возвращает n < len(p) только вместе с ненулевой ошибкой» и «Write не изменяет p». Реализация, которая молча пишет половину и возвращает nil, компилируется, но ломает весь код, работающий через io.Copy. Другой типичный пример нарушения — реализация, которая на часть методов интерфейса отвечает panic("not implemented") или ErrUnsupported: клиент, написанный против абстракции, падает при подстановке.

Какой принцип SOLID вам нравится меньше всего? Какой чаще всего нарушается в вашем опыте?

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

Коротко. Это вопрос на рефлексию, а не на знание: интервьюер проверяет, есть ли у вас собственное мнение и опыт, или вы пересказываете статью. Хороший ответ — назвать OCP как самый спорный на практике (он легко вырождается в преждевременные абстракции и конфликтует с YAGNI) и SRP как чаще всего нарушаемый (расплывчатая формулировка «одна ответственность» даёт «god service» на 2000 строк).

Глубже. Каркас сильного ответа: (1) назвать принцип; (2) объяснить, почему именно он — с конкретным механизмом вреда, а не «он сложный»; (3) привести случай из практики; (4) сказать, что делаете вместо. Например: «OCP в исходной формулировке толкает готовить точки расширения заранее; в продукте с меняющимися требованиями это чаще создаёт мёртвые абстракции — фабрики с одной реализацией, интерфейсы на 15 методов “на будущее”. Я предпочитаю рефакторить по факту второго случая: пока реализация одна, конкретный тип, появилась вторая — выделяю интерфейс на стороне потребителя. Чаще всего в моей практике нарушался SRP: типичный UserService, который валидирует, ходит в БД, шлёт письма и рендерит ответ; лечится вынесением I/O в адаптеры и разделением по акторам».

Слабые ответы: «мне все нравятся» (нет опыта), «LSP, потому что в Go нет наследования» (неверно — LSP про контракты интерфейсов), общая ругань на SOLID без альтернативы.

Коротко. Clean Architecture (Роберт Мартин) — способ организации кода в концентрические слои, где действует правило зависимостей: исходный код зависит только внутрь, к бизнес-логике. Внутренние слои (сущности, сценарии использования) ничего не знают о внешних (БД, UI, фреймворки), поэтому детали инфраструктуры заменяемы, а бизнес-логика тестируется без них.

Глубже. Канонические слои изнутри наружу: Entities (корпоративные бизнес-правила), Use Cases / Interactors (правила приложения, оркестрация), Interface Adapters (контроллеры, презентеры, гейтвеи, реализации репозиториев), Frameworks & Drivers (веб-фреймворк, драйвер БД, брокер). Пересечение границы внутрь — прямой вызов; наружу — через интерфейс, объявленный внутри (тот самый DIP). Механизм, который в книге называется Dependency Inversion at the boundary, в Go выражается просто интерфейсом в пакете usecase.

Ключевые свойства, ради которых это делается: независимость от фреймворка, тестируемость домена без внешних систем, независимость от UI и БД, отложенность решений («какую БД брать» можно решить позже). Родственные подходы, о которых стоит знать: гексагональная архитектура (Alistair Cockburn, ports & adapters, 2005), Onion Architecture (Jeffrey Palermo, 2008), DCI. Все три — вариации одной идеи; чистая архитектура — самая поздняя и самая «брендированная» из них.

Честная оговорка: стоимость — заметный объём бойлерплейта и маппингов между слоями. Для маленького сервиса это не окупается.

Есть абстракции и детали. Кто от кого должен зависеть?

Заголовок раздела «Есть абстракции и детали. Кто от кого должен зависеть?»

Коротко. Детали должны зависеть от абстракций, а не наоборот. Абстракция (интерфейс, доменная модель) не должна знать о существовании конкретной реализации; конкретная реализация — знать и соблюдать контракт абстракции. Это вторая половина формулировки DIP.

Глубже. Практический критерий: смотрите на направление import. Если в пакете domain или usecase есть import "myapp/internal/postgres" — принцип нарушен, домен зависит от детали. Правильно наоборот: postgres импортирует domain (чтобы знать типы данных), а интерфейс OrderRepository объявлен в usecase и реализуется структурой из postgres неявно. Проверить можно механически: go list -deps ./internal/domain не должен содержать драйверов БД и веб-фреймворков.

Отдельная тонкость: абстракция должна принадлежать тому, кто её использует, а не тому, кто её реализует. Интерфейс OrderRepository, положенный в пакет postgres, формально существует, но зависимость не инвертирована — usecase по-прежнему импортирует инфраструктурный пакет.

Коротко. См. выше про DDD. Вторая половина вопроса — про опыт: здесь важно ответить честно и предметно, отделив «использовали стратегическую часть» (выделяли bounded contexts, вводили единый язык) от «использовали тактическую» (агрегаты, VO, доменные события) — на практике первое встречается реже и ценится выше.

Глубже. Каркас ответа: назвать домен и одно-два бизнес-правила, которые пришлось моделировать; сказать, что было агрегатом и почему выбрана такая граница; привести пример объекта-значения (Money, Email, OrderID — типизированный ID вместо голого string даёт бесплатную защиту от перепутанных аргументов); упомянуть, где использовали доменные события и как решали проблему их атомарной публикации (transactional outbox); сказать, где сознательно не применяли DDD и почему. Если реального опыта нет — так и сказать, но показать, что понимаете, какая задача его требует: «в текущем проекте бизнес-логика тонкая, DDD был бы оверхедом; я применял только Value Object’ы для типобезопасности идентификаторов».

Признак слабого ответа — перечисление шаблонов без единого примера из домена и утверждение, что DDD — это «структура папок».

Коротко. См. выше формулировки пяти принципов. Специфика Go: нет классов и наследования, есть композиция и неявные интерфейсы, поэтому SRP/ISP/DIP выражаются идиоматично и дёшево, OCP реализуется через интерфейсы и функции высшего порядка, а LSP превращается в вопрос соблюдения контракта интерфейса.

Глубже. По пунктам:

  • SRP — единица ответственности в Go чаще пакет, а не тип. go vet-уровня проверки нет, но признак нарушения виден по имени: пакет utils, common, helpers — почти всегда нарушение SRP.
  • OCP — расширение через новую реализацию интерфейса, через встраивание (embedding) или через функциональные опции (func(*Server)), а не через правку switch в существующей функции.
  • LSP — про контракты интерфейсов стандартной библиотеки: io.Reader, io.Writer, error, sort.Interface. Компилятор проверяет только сигнатуры; семантику вы обязаны соблюдать сами.
  • ISP — доведён в Go до предела: io.Reader и io.Writer состоят из одного метода, а составные (io.ReadWriteCloser) собираются встраиванием. Go Proverb: «The bigger the interface, the weaker the abstraction».
  • DIP — «accept interfaces, return structs»: функция принимает узкий интерфейс, возвращает конкретный тип; интерфейс определён на стороне вызывающего.

Для чего используются интерфейсы? Что можете сказать про dependency injection?

Заголовок раздела «Для чего используются интерфейсы? Что можете сказать про dependency injection?»

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

Глубже. Полезно назвать конкретные роли интерфейсов, а не общее «для абстракции»: (1) точка подмены для тестов (мок/стаб/фейк вместо БД и HTTP-клиента); (2) граница слоя — usecase не знает, что за ним PostgreSQL; (3) полиморфизм над разнородными типами с общим поведением (io.Writer для файла, буфера и сети); (4) сокрытие реализации внутри пакета (экспортируется интерфейс и конструктор, структура — нет); (5) декораторы/middleware — реализация оборачивает другую реализацию того же интерфейса, добавляя логирование, ретраи, метрики.

Что важно сказать про то, чего делать не надо: не заводить интерфейс на каждый тип «на всякий случай» (Go Proverb «Don’t design with interfaces, discover them»); не объявлять интерфейс рядом с реализацией; не возвращать интерфейс из конструктора без нужды — это ломает возможность добавить метод и мешает пользователю видеть реальный API. И отдельно: nil-указатель, положенный в интерфейс, даёт ненулевое значение интерфейса (i != nil при i из нулевого *T) — классическая ловушка на границе слоёв.

type Notifier interface {
Notify(ctx context.Context, userID string, msg string) error
}
// Декоратор: та же абстракция, добавленное поведение — OCP в действии.
type retryNotifier struct {
next Notifier
attempts int
}
func (r retryNotifier) Notify(ctx context.Context, userID, msg string) error {
var err error
for i := 0; i < r.attempts; i++ {
if err = r.next.Notify(ctx, userID, msg); err == nil {
return nil
}
}
return err
}

Коротко. Пример из stdlib: http.Handler — сервер зависит от интерфейса, ваш код его реализует, и net/http про ваш тип ничего не знает. Пример из приложения: usecase объявляет OrderRepository, а пакет postgres его реализует, не импортируя usecase; связывание в main.

Глубже. Ещё несколько показательных примеров:

  • database/sql + driver.Driver: пакет database/sql — высокоуровневый модуль, он определяет интерфейсы driver.Conn, driver.Stmt, а pgx, lib/pq, go-sqlite3 — детали, реализующие их и регистрирующиеся через sql.Register.
  • log/slog (Go 1.21+): slog.Handler — абстракция вывода; slog.Logger не знает, пишете вы JSON, текст или отправляете в сборщик.
  • sort.Interface до генериков: алгоритм сортировки зависел от абстракции Len/Less/Swap, а конкретный срез — деталь.
  • Плохой и хороший вариант в приложении:

Плохо — usecase знает про деталь:

package usecase
import "myapp/internal/postgres"
type OrderService struct {
repo *postgres.OrderRepo // жёсткая зависимость, не подменить в тесте
}

Хорошо — usecase знает только контракт:

package usecase
import (
"context"
"myapp/internal/domain"
)
type OrderRepository interface {
Save(ctx context.Context, o *domain.Order) error
}
type OrderService struct {
repo OrderRepository
}

Стоит отдельно проговорить, что инверсия не бесплатна: она осмысленна там, где деталь действительно может смениться или мешает тестировать. Инвертировать зависимость от time.Now ради «а вдруг сменим время» — карго-культ; инвертировать её ради детерминированных тестов (интерфейс Clock) — оправданно.

Коротко. См. выше «Что такое SOLID?»: SRP — одна причина для изменения; OCP — открыт для расширения, закрыт для модификации; LSP — подтип подставим вместо базового типа; ISP — много узких интерфейсов вместо одного толстого; DIP — зависимость на абстракции, детали зависят от абстракций.

Глубже. Отличие этого вопроса от предыдущего только в форме: здесь ждут развёрнутый рассказ по каждой букве с примером. Хорошая стратегия — на каждый принцип давать пару «нарушение → как исправить»: SRP — сервис, который и считает скидку, и шлёт письмо → разделить; OCP — switch по типу платежа, растущий с каждой интеграцией → интерфейс PaymentProvider и реестр реализаций; LSP — ReadOnlyRepo, у которого Save возвращает ErrNotSupported → не наследовать, а разделить интерфейсы; ISP — Storage на 20 методов, из которых потребителю нужен один → узкий интерфейс на стороне потребителя; DIP — usecase импортирует postgres → интерфейс в usecase.

Коротко. См. выше «SOLID вообще и в Go». Кратко: те же пять принципов, но в Go нет наследования, поэтому OCP и LSP реализуются через интерфейсы и композицию, а ISP и DIP встроены в идиоматику языка благодаря неявным интерфейсам и правилу «интерфейс объявляет потребитель».

Глубже. Дополнение к прежнему ответу — то, чего нет в классических ООП-языках и что стоит упомянуть именно здесь: встраивание (embedding) даёт переиспользование без наследования, но не даёт полиморфизма — метод встроенного типа не «переопределяется» в смысле виртуального вызова, поэтому шаблон Template Method в Go делают через поле-интерфейс, а не через встраивание. Ещё одно отличие: в Go нет protected, единица инкапсуляции — пакет, поэтому SRP и границы модулей естественно ложатся на структуру пакетов, а не на иерархии классов.

Можешь рассказать про чистую архитектуру и слои?

Заголовок раздела «Можешь рассказать про чистую архитектуру и слои?»

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

Глубже. Что чаще всего просят уточнить: как данные проходят слои. Ответ — через отдельные структуры на каждой границе. HTTP-запрос десериализуется в DTO слоя транспорта, маппится в входную модель usecase, usecase работает с доменными типами, репозиторий маппит домен в свою модель БД (теги db: не должны висеть на доменной структуре). Это выглядит избыточным на маленьком проекте, и это осознанный компромисс: без маппинга схема БД и формат API становятся частью домена и намертво связывают его с деталями. Второй частый вопрос — где транзакция: типично на уровне usecase, через абстракцию Unit of Work / WithinTx(ctx, func(ctx) error), чтобы usecase не знал про *sql.Tx.

Знаешь ли аббревиатуру методики для определения единственной ответственности?

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

Коротко. Речь про SRP — Single Responsibility Principle, буква S в SOLID: у модуля должна быть одна причина для изменения. Если имеется в виду методика поиска ответственностей, то это GRASP (General Responsibility Assignment Software Patterns) Крэга Лармана — набор шаблонов распределения ответственностей между объектами (Information Expert, Creator, Controller, Low Coupling, High Cohesion и др.).

Глубже. Полезно назвать оба и уточнить, что имеется в виду. SRP отвечает на вопрос «сколько ответственностей у модуля» (одна), GRASP — на вопрос «кому отдать конкретную ответственность»: например, Information Expert говорит отдавать поведение тому, у кого есть данные для его выполнения; High Cohesion и Low Coupling — прямые формулировки метрик связности и связанности. В контексте DDD близкая идея — «поведение живёт в агрегате, а не в сервисе», иначе получается анемичная модель.

Почему один большой интерфейс - это антипаттерн?

Заголовок раздела «Почему один большой интерфейс - это антипаттерн?»

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

Глубже. Механизм вреда по шагам: (1) интерфейс Storage на 25 методов заставляет каждый тест писать заглушку на 25 методов — на практике это заканчивается моками с panic("not implemented"), то есть нарушением LSP; (2) добавление 26-го метода — ломающее изменение для всех реализаций (в Go — ошибка компиляции у каждого, кто реализует); (3) невозможно понять по сигнатуре функции, что именно она делает: func Process(s Storage) может сделать что угодно, а func Process(r OrderReader) сразу говорит, что только читает; (4) большой интерфейс склеивает несвязанные группы клиентов — изменение ради одного тянет перекомпиляцию и правки у всех.

Go Proverb: «The bigger the interface, the weaker the abstraction». Правильный рецепт — узкий интерфейс на стороне потребителя, ровно под нужду:

// В пакете, который только читает.
type orderReader interface {
ByID(ctx context.Context, id domain.OrderID) (*domain.Order, error)
}
func RenderInvoice(ctx context.Context, r orderReader, id domain.OrderID) ([]byte, error) {
// ...
}

Толстый тип-реализация при этом остаётся один — он просто удовлетворяет нескольким узким интерфейсам одновременно.

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

Глубже. Полезно сгруппировать и добавить go-специфику.

  • Размер и ответственность: функция, не помещающаяся на экран; пакет utils/common/helpers; структура на 30 полей; Service, который делает всё.
  • Связанность: циклические зависимости между пакетами (в Go просто не скомпилируется — и это плюс языка); доменные структуры с тегами json: и db: одновременно; глобальные переменные и синглтоны, из-за которых тесты нельзя запускать параллельно.
  • Обработка ошибок в Go: игнорирование ошибок (_ = doSomething()), panic в библиотечном коде, потеря контекста ошибки (возврат err без fmt.Errorf("...: %w", err)), сравнение ошибок по строке вместо errors.Is/errors.As.
  • Конкурентность: горутина без явного владельца и без способа её остановить, отсутствие ctx в сигнатурах долгих операций, time.Sleep в тестах вместо синхронизации, гонки, которые ловит go test -race.
  • Тестируемость как индикатор: если для юнит-теста нужно поднять Postgres и Kafka — это признак нарушения DIP, а не признак «сложного домена».
  • Инструментальная проверка: go vet, staticcheck, golangci-lint, цикломатическая сложность (gocyclo), go test -race, детект дублирования — стоит упомянуть, что часть признаков ловится автоматически в CI.

За какое время можно выявить нарушение принципов SOLID в коде?

Заголовок раздела «За какое время можно выявить нарушение принципов SOLID в коде?»

Коротко. Часть нарушений видна за минуты — по структуре импортов и размерам сущностей (DIP: домен импортирует драйвер БД; ISP: интерфейс на 20 методов; SRP: файл на 2000 строк). Нарушения LSP и тонкие нарушения SRP так не ловятся — они выявляются только при чтении логики и обычно всплывают при попытке внести изменение или написать тест.

Глубже. Разумный ответ — про метод, а не про число. Быстрый скрининг Go-репозитория (15–30 минут): посмотреть граф зависимостей пакетов (go list -deps, go mod graph, визуализация импортов) — сразу видно инверсию и циклы; посчитать методы в интерфейсах — ISP; посмотреть распределение размеров файлов и функций, прогнать golangci-lint с gocyclo, funlen, dupl — кандидаты на SRP; поискать switch v := x.(type) в бизнес-логике — кандидаты на OCP; поискать not implemented, ErrUnsupported, panic( в реализациях интерфейсов — кандидаты на LSP.

Что стоит добавить, чтобы ответ выглядел взрослым: сама по себе «нарушенность SOLID» не является дефектом — это индикатор. Тратить время на аудит имеет смысл, когда есть симптом: высокая цена изменений, регулярные регрессии в одном модуле, невозможность покрыть код тестами. Формально «нарушенный» код в стабильном модуле, который не меняется два года, трогать не нужно.

Что будет если в коде одновременно нарушены несколько принципов SOLID?

Заголовок раздела «Что будет если в коде одновременно нарушены несколько принципов SOLID?»

Коротко. Нарушения усиливают друг друга: жёсткая зависимость на детали (DIP) плюс раздутая ответственность (SRP) дают код, который нельзя ни протестировать изолированно, ни изменить локально. Практический итог — рост стоимости каждого изменения, регрессии в неожиданных местах и постепенный переход к «переписать проще, чем починить».

Глубже. Полезно показать цепочку причинности, а не просто сказать «будет плохо». Типичный сценарий: OrderService вырос и делает всё (SRP) → чтобы им пользоваться, нужен интерфейс, и он получается на 20 методов (ISP) → замокать его в тесте дорого, поэтому в моках появляются заглушки-паники (LSP) → проще позвать postgres.Query напрямую, чем протаскивать ещё одну зависимость (DIP) → добавление нового типа оплаты правится switch’ем внутри существующего метода (OCP) → каждое изменение затрагивает файл, который правят пять человек, растут конфликты слияния и регрессии.

Терминология Мартина для итогового состояния полезна на собеседовании: rigidity (изменение тянет за собой каскад правок), fragility (правка ломает то, что с ней не связано), immobility (кусок нельзя переиспользовать, потому что он тащит полсистемы), viscosity (сделать «правильно» дороже, чем захачить). Это и есть определение технического долга в измеримых терминах.

Расскажи про разницу между инверсией зависимостей и внедрением зависимостей.

Заголовок раздела «Расскажи про разницу между инверсией зависимостей и внедрением зависимостей.»

Коротко. DIP (инверсия) — принцип проектирования: зависеть надо от абстракций, а детали зависят от абстракций. DI (внедрение) — приём реализации: объект получает готовые зависимости извне вместо того, чтобы создавать их сам. DI — один из способов достичь DIP, но сам по себе DIP не гарантирует.

Глубже. Различие видно на контрпримерах. Можно делать DI без инверсии: NewService(repo *postgres.OrderRepo) — зависимость внедряется, но принимается конкретный тип, значит usecase по-прежнему импортирует деталь, DIP нарушен. Можно формально соблюсти DIP без DI: объект сам достаёт реализацию через глобальный реестр (registry.Get[Repo]()) — зависимость на абстракцию есть, но зависимости не внедрены, тестировать и рассуждать о графе тяжело (это Service Locator, широко считается антипаттерном).

Рядом стоит третий термин — IoC (Inversion of Control), самый широкий: управление потоком передаётся фреймворку/рантайму («не вы вызываете библиотеку, а она вас»). DI — частный случай IoC применительно к зависимостям. Иерархия для ответа: IoC (общая идея) ⊃ DI (способ доставки зависимостей) и рядом DIP (правило, на что именно должна указывать стрелка зависимости).

Коротко. S — Single Responsibility Principle: у модуля должна быть одна причина для изменения. В более поздней формулировке Роберта Мартина — модуль должен быть ответственен перед одним и только одним актором (одной группой заинтересованных лиц).

Глубже. Частая ошибка — читать SRP как «класс должен делать одну вещь». Это ведёт к дроблению на анемичные однометодные классы. Правильный критерий — источник изменений: если бухгалтерия просит поменять формулу расчёта, а отдел маркетинга — формат отчёта, и обе правки лежат в одном типе, SRP нарушен, потому что у типа два актора. В Go этот критерий чаще применяют к пакету: пакет billing меняется по требованиям биллинга, пакет httpapi — по требованиям к API.

Практический признак нарушения в Go: в одном файле лежат и вычисление бизнес-правила, и SQL, и сериализация ответа; или структура имеет и теги db:, и теги json:, и методы валидации формы — она обслуживает три разных актора сразу.

Коротко. Связанность (coupling) — степень взаимозависимости между модулями; её нужно минимизировать. Связность (cohesion) — степень, в которой элементы внутри одного модуля относятся к одной задаче; её нужно максимизировать. Идеал: «low coupling, high cohesion» — модуль занимается одной темой и мало знает о соседях.

Глубже. Термины путают из-за близких русских слов, поэтому на собеседовании полезно сразу назвать английские. Классификация связанности от худшей к лучшей: content (модуль лезет во внутренности другого), common (общие глобальные данные), external, control (передача флага, управляющего поведением другого модуля), stamp (передача большой структуры, когда нужно одно поле), data (передаются только нужные данные) — последняя лучшая. Связность от худшей к лучшей: coincidental (пакет utils), logical, temporal (init() со всем подряд), procedural, communicational, sequential, functional (модуль делает ровно одну задачу) — последняя лучшая.

Связь с SOLID прямая: SRP и ISP повышают cohesion, DIP и OCP понижают coupling. В Go признаки высокой связанности: пакет common, импортируемый отовсюду; передача огромных конфиг-структур туда, где нужен один параметр (stamp coupling); булев флаг-переключатель поведения в сигнатуре (control coupling) — вместо него лучше две функции.

Как вы применяете принципы SOLID в Go? Приведите примеры, где это уместно. Какие особенности реализации этих принципов в Go по сравнению с классическими ООП-языками?

Заголовок раздела «Как вы применяете принципы SOLID в Go? Приведите примеры, где это уместно. Какие особенности реализации этих принципов в Go по сравнению с классическими ООП-языками?»

Коротко. SOLID в Go применяется через пакеты и интерфейсы, а не через иерархии классов: SRP — на уровне пакета, ISP и DIP — через узкие неявные интерфейсы, объявленные потребителем, OCP — через новые реализации интерфейса, встраивание и функциональные опции, LSP — через соблюдение семантического контракта интерфейса. Ключевое отличие от Java/C#: нет наследования и нет явной декларации implements, поэтому абстракции «обнаруживаются» по факту, а не проектируются заранее.

Глубже. Конкретные отличия, которые стоит проговорить:

  1. Неявные интерфейсы (structural typing). Реализация не знает про интерфейс. Следствие: интерфейс живёт в пакете-потребителе, зависимость инвертирована «бесплатно», нет пакетов-контрактов и меньше циклов импорта. В Java интерфейс обычно лежит рядом с реализацией или в общем модуле, и обе стороны его импортируют.
  2. Нет наследования — есть встраивание. Встраивание переиспользует реализацию, но не даёт виртуальной диспетчеризации: метод внешнего типа не «переопределит» вызов из метода встроенного. Поэтому Template Method делается через поле-интерфейс, а не через «наследника».
  3. Единица инкапсуляции — пакет, а не тип. Внутри пакета доступны неэкспортируемые поля любых типов, поэтому SRP естественно формулируется в терминах пакетов.
  4. Дженерики (Go 1.18+) дали ещё один способ соблюдать OCP без интерфейсов — параметризация по типу вместо interface{} и рефлексии. Но дженерик-констрейнт — не замена интерфейсу-абстракции: он про типы, а не про поведение подстановки в рантайме.
  5. Ошибки как значения. LSP и контракт интерфейса в Go включают контракт ошибок: если абстракция обещает ErrNotFound (через errors.Is), реализация обязана его возвращать, а не свою pgx.ErrNoRows, иначе клиенты сломаются при смене реализации.
// OCP через функциональные опции: расширяем поведение, не меняя конструктор.
type Server struct {
addr string
timeout time.Duration
log *slog.Logger
}
type Option func(*Server)
func WithTimeout(d time.Duration) Option { return func(s *Server) { s.timeout = d } }
func WithLogger(l *slog.Logger) Option { return func(s *Server) { s.log = l } }
func NewServer(addr string, opts ...Option) *Server {
s := &Server{addr: addr, timeout: 5 * time.Second, log: slog.Default()}
for _, o := range opts {
o(s)
}
return s
}

Уместность: интерфейсы вводить там, где есть вторая реализация, граница слоя или необходимость подмены в тесте; в остальных местах — конкретные типы. Это прямо следует из «Don’t design with interfaces, discover them».

Что такое чистая архитектура? Как вы адаптируете ее в Go-проектах? С какими сложностями сталкивались при внедрении?

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

Коротко. Определение — см. выше. Адаптация в Go обычно сводится к трём слоям вместо четырёх (domain, usecase, adapter) плюс cmd как composition root, интерфейсам на стороне потребителя и отказу от презентеров как отдельного слоя. Главные сложности на практике: транзакции, пересекающие границу usecase↔репозиторий; объём маппинга между моделями слоёв; и соблазн сделать «слой ради слоя» там, где логики нет.

Глубже. Конкретика по сложностям, которую ждут от senior:

  • Транзакции. Usecase не должен знать про *sql.Tx, но должен уметь атомарно изменить несколько агрегатов. Практичное решение — абстракция type TxManager interface { Within(ctx context.Context, fn func(ctx context.Context) error) error }, где реализация кладёт транзакцию в контекст, а репозитории её оттуда достают. Альтернатива — Unit of Work.
  • Маппинг. Три модели одной сущности (transport DTO, domain, db-модель) — это буквально втрое больше кода на простых сущностях. Компромисс, который часто применяют: маппинг заводить там, где модели уже разошлись, а для чистого CRUD оставлять одну структуру и осознанно это документировать.
  • Пустые слои. Usecase, который только делегирует в репозиторий, — чистый шум. Здесь помогает разделение на «rich»-сценарии (проходят все слои) и «thin»-чтения (CQRS-подход: запросы читают проекции напрямую, минуя домен).
  • Доменные ошибки на границе. Нужен единый маппинг доменных ошибок в HTTP/gRPC-коды, иначе слои протекают: либо errors.Is в транспорте по списку доменных ошибок, либо типизированные ошибки с методом-классификатором.
  • Циклы импорта при попытке разложить всё по пакетам; лечится тем, что интерфейс объявляется у потребителя, а общие типы уходят в domain.
  • Организационная сложность: не вся команда одинаково понимает границы, поэтому нужен либо линтер зависимостей (depguard в golangci-lint, go-arch-lint), либо архитектурный тест, проверяющий, что internal/domain не импортирует инфраструктуру.

Коротко. D — Dependency Inversion Principle: модули верхнего уровня не зависят от модулей нижнего уровня, оба зависят от абстракций; абстракции не зависят от деталей, детали зависят от абстракций. См. подробнее выше в ответе «Dependency inversion».

Глубже. На собеседовании после этого вопроса почти всегда идёт уточнение «а чем это отличается от DI» — ответ есть выше: DIP это принцип (куда смотрит стрелка зависимости), DI это техника (как передать реализацию). Второе частое уточнение — «покажи на Go»: минимальный пример — интерфейс в пакете usecase, структура в пакете postgres, связывание в main.

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

Глубже. Специфика Go, ради которой вопрос обычно и задают: в Go есть инкапсуляция (через экспортируемость по регистру имени, единица — пакет), полиморфизм (через интерфейсы, динамическая диспетчеризация по itab) и абстракция, но нет наследования — вместо него композиция и встраивание. Встраивание похоже на наследование синтаксически (методы встроенного типа промотируются наружу), но семантически это делегирование: нет виртуальных вызовов «вверх», нет super, и внешний тип не является подтипом встроенного.

Полезное дополнение: язык не даёт ни классов, ни конструкторов, ни final/abstract; идиома — «composition over inheritance», которая в Go не рекомендация, а единственный доступный вариант. Стоит также упомянуть, что интерфейс в Go — это пара (тип, значение) во время выполнения, поэтому проверка iface == nil и «typed nil» — распространённая ловушка при работе с полиморфизмом.

Коротко. См. выше «SOLID вообще и в Go» и развёрнутый ответ про применение SOLID в Go. Сжато: SRP — по пакетам; OCP — через интерфейсы, встраивание и опции; LSP — соблюдение контракта интерфейса, включая контракт ошибок; ISP — узкие интерфейсы (io.Reader как эталон); DIP — «accept interfaces, return structs», интерфейс у потребителя.

Глубже. Если вопрос повторяется в интервью в такой краткой форме, обычно ждут именно go-специфику, а не определения. Три вещи, которые точно стоит назвать: неявная реализация интерфейсов (нет implements), отсутствие наследования (композиция), и то, что stdlib — лучший пример SOLID (io, net/http, database/sql, sort, log/slog). Ссылка на живой код всегда сильнее пересказа теории.

Коротко. DDD в Go выглядит как пакет domain без внешних зависимостей, в котором лежат сущности с приватными полями и методами-инвариантами, объекты-значения на типизированных примитивах, доменные ошибки и доменные события; репозитории объявлены интерфейсами на стороне приложения, а их реализации живут в инфраструктурных пакетах.

Глубже. Что специфично именно для Go:

  • Value Object делается через отдельный тип (type Email string, type Money struct{ amount int64; currency Currency }) и конструктор с валидацией; неизменяемость обеспечивается приватными полями и отсутствием сеттеров, языкового const-объекта нет.
  • Инкапсуляция инвариантов упирается в то, что единица приватности — пакет: внутри domain любые поля доступны, поэтому крупный «пакет-домен» на всё приложение постепенно теряет защиту инвариантов. Практика — пакет на bounded context или на агрегат.
  • Анемичная модель — самая частая беда Go-проектов: структура с публичными полями и тегами, вся логика в service. Это работает, но перестаёт масштабироваться, когда правил становится много.
  • Доменные события — обычные структуры, накапливаемые в агрегате и публикуемые после успешного коммита; для атомарности с БД используется transactional outbox.
  • Отсутствие ORM-магии — плюс: маппинг пишется руками (sqlc, pgx, sqlx), домен не обязан наследовать базовый класс и не тащит теги ORM.
  • Bounded context обычно проецируется на модуль/сервис или на каталог internal/<context>/ с собственными domain, usecase, adapter.

Коротко. См. выше «Что такое SOLID?» — SRP, OCP, LSP, ISP, DIP. Пять правил о том, где проводить границы модулей и куда направлять зависимости, чтобы изменение требований затрагивало минимум кода.

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

Коротко. Обобщённый пункт: см. ответы выше про DDD, агрегаты, правила проектирования и DDD в Go. Ядро — ubiquitous language, bounded context, агрегат как граница транзакции, репозиторий на агрегат, ссылки между агрегатами по id.

Глубже. Типовой набор вопросов, к которому стоит быть готовым: чем Entity отличается от Value Object (идентичность против значения); что такое агрегат и как выбрать его размер; зачем репозиторий, если есть DAO (репозиторий работает с агрегатами и говорит на языке домена, DAO — с таблицами); что такое доменный сервис и когда он нужен (операция домена без естественного владельца, например перевод денег между счетами); что такое анемичная модель и чем плоха; что такое ACL и зачем он на границе с чужим контекстом; как согласовывать данные между агрегатами (доменные события + eventual consistency + outbox); совпадает ли bounded context с микросервисом (не обязательно, но это разумная стартовая гипотеза).

Используете ли вы в своей практике гексагональную архитектуру?

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

Коротко. Вопрос про опыт. Хороший ответ описывает, что гексагональная архитектура (ports & adapters, Alistair Cockburn) — это то же правило зависимостей, что и в чистой архитектуре, но выраженное через порты (интерфейсы приложения) и адаптеры (их реализации), с делением на driving-порты (входящие, ими пользуется внешний мир) и driven-порты (исходящие, ими пользуется приложение).

Глубже. Каркас содержательного ответа: (1) объяснить терминологию — driving/primary adapters это HTTP-, gRPC-хендлеры и консьюмеры, они вызывают входящие порты; driven/secondary adapters это репозитории, клиенты чужих API, продюсеры, они реализуют исходящие порты; (2) сказать, чем это отличается от чистой архитектуры по существу — почти ничем, гексагон не предписывает число слоёв и не выделяет отдельно Entities/Use Cases, он проще; (3) привести практическую выгоду, которая проверяется на деле: одна и та же бизнес-логика вызывается из HTTP-хендлера, из gRPC и из Kafka-консьюмера без дублирования, а в тестах приложение поднимается с in-memory адаптерами; (4) назвать цену: больше интерфейсов и маппинга.

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

Чем чистая архитектура помогает вам в разработке?

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

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

Глубже. Стоит отвечать через конкретные ситуации, а не через лозунги: «переехали с REST на gRPC — тронули только адаптеры»; «добавили Kafka-консьюмер, переиспользовав те же usecase»; «прогон тестов домена занимает секунды, поэтому TDD на бизнес-правилах реально работает»; «подменяем платёжного провайдера, реализуя тот же порт». И столь же полезно назвать, где она не помогает: не ускоряет разработку CRUD, не спасает от плохо выбранных границ (если bounded context нарезан неверно, слои этого не исправят), не заменяет интеграционные тесты — контракт адаптера всё равно нужно проверять против реальной БД (например, через testcontainers).

Коротко. SRP — одна причина для изменения; OCP — открыт для расширения, закрыт для модификации; LSP — подтип подставим вместо базового типа без нарушения корректности; ISP — узкие интерфейсы вместо толстых; DIP — зависимость на абстракции, детали зависят от абстракций. Подробности см. в ответах выше.

Глубже. См. развёрнутые формулировки в «Что такое SOLID?» и go-специфику в «Как вы применяете принципы SOLID в Go?».

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

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

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

Глубже. Развёрнутый список правил, полезных для ответа:

  1. Ubiquitous language — имена в коде совпадают с языком экспертов; если бизнес говорит «резервирование», в коде не должно быть OrderLock.
  2. Bounded context — один термин имеет одно значение внутри контекста; на границе ставится ACL, чужие модели внутрь не пускаются.
  3. Малый агрегат — включать только то, что обязано меняться атомарно; всё остальное — отдельный агрегат.
  4. Ссылки по id, а не по указателю: order.CustomerID, а не order.Customer *Customer.
  5. Одна транзакция — один агрегат; согласование между агрегатами — через доменные события и eventual consistency (плюс outbox для надёжной публикации).
  6. Репозиторий возвращает агрегат целиком и говорит на языке домена; выборки для чтения (списки, отчёты) идут мимо агрегатов — это уже CQRS-запросы к проекциям.
  7. Инварианты внутри агрегата, а не в usecase: сервис оркеструет, домен решает.
  8. Value Object по умолчанию: если у объекта нет идентичности во времени, он VO и он неизменяем.
  9. Domain Service только для операций, у которых нет естественного владельца.
  10. Фабрика для сложного создания, чтобы агрегат нельзя было получить в невалидном состоянии.
  11. И метаправило: применять DDD только там, где сложность домена это оправдывает.

Коротко. См. подробный ответ выше «Что такое агрегат в DDD?»: кластер сущностей и объектов-значений с единым корнем, который является границей транзакционной консистентности и единственной точкой доступа снаружи.

Глубже. Дополнение, которое уместно, когда вопрос задают повторно во множественном числе — про взаимодействие агрегатов между собой. Между агрегатами нет прямых ссылок на объекты, только идентификаторы; изменение нескольких агрегатов в одном бизнес-процессе делается либо серией транзакций с доменными событиями (eventual consistency), либо распределённой сагой, если агрегаты живут в разных сервисах. Практическое следствие для конкурентного доступа: агрегат — естественная единица блокировки, и оптимистическая блокировка по версии (version в таблице, UPDATE ... WHERE version = $1) ставится именно на корень, а не на отдельные строки внутри.

Как соблюдать SOLID при программировании на Go?

Заголовок раздела «Как соблюдать SOLID при программировании на Go?»

Коротко. Практические правила: пакет с одной темой и без имён utils/common (SRP); расширение через новые реализации интерфейса, встраивание и функциональные опции вместо правки switch (OCP); соблюдение семантического контракта интерфейсов, включая контракт ошибок (LSP); интерфейсы в один-три метода, объявленные потребителем (ISP); «accept interfaces, return structs» и сборка графа в main (DIP).

Глубже. Что помогает соблюдать это механически, а не силой воли: архитектурные линтеры (depguard, go-arch-lint, import-boss) с правилом «internal/domain не импортирует ничего инфраструктурного»; golangci-lint с funlen, gocyclo, interfacebloat (ловит толстые интерфейсы), revive; ревью-чеклист «где объявлен интерфейс — у потребителя или у реализации?»; тест домена без единого внешнего сервиса как критерий соблюдения DIP; и правило трёх — не выделять абстракцию раньше второго-третьего реального случая, чтобы SOLID не выродился в оверинжиниринг.

Коротко. В Go подавляющее большинство инженеров ответит «ручная сборка в main через конструкторы» — это явно, отлаживается, не требует рефлексии и падает на этапе компиляции. Для больших графов разумная альтернатива — кодогенерация google/wire, которая делает ровно то же самое, но пишет код за вас; рантайм-контейнеры (uber-go/dig, uber-go/fx) применяются, когда нужны жизненный цикл компонентов и модульная сборка.

Глубже. Аргументация, которая обычно ожидается: (1) ручной DI — «explicit is better than implicit», компилятор проверяет граф, ничего не ищется рефлексией в рантайме; минус — main разрастается, поэтому его выносят в internal/app и режут на функции по подсистемам; (2) wire — генерирует тот же явный код на этапе сборки по провайдер-функциям, ошибки графа видны при go generate, рантайм-оверхеда нет; (3) fx/dig — рефлексивные контейнеры, дают хуки старта/остановки и модули, но ошибки вылезают в рантайме и стек становится непрозрачным. Хорошо звучит и упоминание тестовой стороны: при ручном DI тест собирает объект с фейковыми зависимостями за три строки, без специального инструментария.

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

Заголовок раздела «В чистой архитектуре как ты связываешь сущности между собой?»

Коротко. Внутри домена сущности связываются либо через агрегат (корень владеет частями), либо по идентификатору между агрегатами — прямых указателей между агрегатами не держат. Между слоями связывание идёт через интерфейсы, объявленные во внутреннем слое, а конкретные реализации подставляются в composition root (main).

Глубже. Вопрос допускает два прочтения, и на собеседовании стоит закрыть оба.

  1. Связи между доменными объектами. Внутри одного агрегата — обычные поля и срезы, инварианты держит корень. Между агрегатами — только CustomerID, ProductID и т. п.; загрузка соседнего агрегата — это отдельный вызов репозитория из usecase, а не ленивая подгрузка из домена (в Go её и нет — ORM-магии никто не встраивает). Оркестрация нескольких агрегатов — задача usecase или доменного сервиса, а не самой сущности.
  2. Связывание компонентов слоёв. Интерфейс объявлен там, где используется; реализация — во внешнем слое; экземпляры создаются и «сшиваются» в main/internal/app. Если нужна связь наружу-внутрь по потоку данных (например, презентер), её тоже выражают интерфейсом внутреннего слоя. Для сквозных вещей — контекст (context.Context) для request-scoped данных, а не глобальные переменные.
// Домен: ссылка на соседний агрегат — по id.
type Order struct {
id OrderID
customerID CustomerID // не *Customer
lines []OrderLine
}
// Usecase оркеструет два агрегата, каждый — через свой репозиторий.
func (s *OrderService) Place(ctx context.Context, cid domain.CustomerID, items []domain.Item) error {
c, err := s.customers.ByID(ctx, cid)
if err != nil {
return err
}
if !c.CanOrder() {
return domain.ErrCustomerBlocked
}
o, err := domain.NewOrder(domain.NewOrderID(), cid, items)
if err != nil {
return err
}
return s.orders.Save(ctx, o)
}
  • Перечисляют буквы SOLID без единого примера и не могут показать нарушение и его исправление. Пять определений наизусть — это уровень джуна; ценится «вот код, вот что не так, вот как чинить».
  • Путают DIP и DI. DIP — принцип о направлении зависимости, DI — техника передачи зависимости в объект. Можно делать DI и при этом нарушать DIP (внедрять конкретный тип).
  • Говорят, что LSP в Go неприменим, «потому что нет наследования». LSP полностью применим к интерфейсам: реализация обязана соблюдать семантический контракт, а не только сигнатуры (io.Writer, error, sort.Interface).
  • Объявляют интерфейсы рядом с реализацией и заводят пакет interfaces. В Go интерфейс объявляет потребитель — иначе зависимость не инвертирована и появляются лишние импорты и циклы.
  • Заводят интерфейс на каждый тип «для тестируемости». Это карго-культ: интерфейс нужен при второй реализации, на границе слоя или для подмены внешней системы. Go Proverb: «Don’t design with interfaces, discover them».
  • Сводят DDD к структуре папок entity/repository/service и не могут назвать ubiquitous language, bounded context и правило «одна транзакция — один агрегат».
  • Называют агрегатом всю схему БД или всю сущность «Пользователь» со всей историей. Агрегат выбирается по инвариантам, которые обязаны соблюдаться атомарно, и должен быть как можно меньше.
  • Читают SRP как «класс делает одну вещь» и дробят код на однометодные типы. Критерий — одна причина для изменения, один актор.
  • Считают чистую архитектуру бесплатной. Она стоит бойлерплейта и маппингов; на CRUD-сервисе это чистые потери, и умение это признать — сильный сигнал.
  • Тащат DRY в микросервисы через общую библиотеку с бизнес-логикой и получают распределённый монолит. DRY — про дублирование знания внутри одной границы, а не про одинаковые строки во всей компании.
  • Кладут теги json: и db: на доменную структуру и потом удивляются, что домен зависит от формата API и схемы БД одновременно.