11.1Организация кода и принципы
Организуй код так, чтобы доменная логика зависела от нужных ей контрактов, а конкретные адаптеры собирались у границы приложения. Проверь интерфейсы, ошибки, конфиг и размер пакетов на реальные причины изменения, а не на названия архитектурных стилей.
Кто про кого знает: инверсия зависимостей и DI
Практический вопрос тут один: если я поменяю одну часть программы, сколько других частей придётся чинить следом? Ответ зависит от того, кто про кого знает, то есть от зависимостей. На схемах зависимость рисуют стрелкой от того, кто знает, к тому, кого знают, и остальное в главе сводится к тому, куда эти стрелки должны смотреть.
Инверсия зависимостей - развернуть стрелку, не меняя вызовов
Обычно тот, кто вызывает, зависит от того, кого вызывает: сервису нужен репозиторий, значит, сервис импортирует пакет репозитория. Инверсия зависимостей оставляет вызов прежним, но разворачивает стрелку импорта: сервис объявляет у себя интерфейс «мне нужен вот такой репозиторий», а пакет с реальной реализацией импортирует сервис и подписывается под этим интерфейсом.
Похоже на наём сотрудника. В обычной схеме ты идёшь к конкретному Петрову и просишь его о помощи: ты зависишь от Петрова, ушёл Петров - переписывать всё. В инвертированной ты публикуешь вакансию с требованиями, и уже кандидаты приходят под неё подстраиваться. Требования принадлежат тебе, а кто им соответствует, тебя не касается. Инверсию инверсией делает вот что: интерфейс принадлежит тому, кто им пользуется, а не тому, кто его реализует.
Внедрение зависимостей (DI) - передать готовое, а не создавать внутри
DI звучит громко, а означает
до скуки простую вещь: объект не создаёт то, что ему нужно, сам, а получает это
снаружи - обычно аргументом конструктора. Не repo := postgres.New(...)
внутри сервиса, а NewService(repo Repository). Всё. Никакого фреймворка
для этого не нужно, и в Go его обычно и не берут.
Зачем: пока сервис создаёт зависимость сам, он намертво привязан к конкретной реализации. В тесте вместо базы не подставишь заглушку, вторую реализацию не добавишь. DI и есть та механика, которой пользуются инверсия зависимостей и всё, что из неё следует.
Слои: это не про папки, это про направление стрелок
Слой - это не папка и не пакет, а роль: код, который решает задачи одного рода.
Транспортный слой разбирает HTTP-запросы, слой сценариев выполняет бизнес-операции, слой
хранения ходит в базу. Классическая тройка handler → service → repository существует ради одного
правила: зависимости идут в одну сторону и никогда обратно.
Смысл появляется, когда ты можешь удалить весь HTTP-слой,
заменить его на gRPC-сервер или на консольную команду, и не тронуть ни строчки в service.
Каждый слой отвечает за свой уровень абстракции, и у каждого есть список того, чего он не должен знать. Этот список важнее списка обязанностей: нарушают именно его.
| Слой | За что отвечает | Чего знать не должен | Как ломают на практике |
|---|---|---|---|
| transport handler |
Разбор запроса, валидация формата, коды ответа, сериализация, маппинг ошибок домена в HTTP-статусы | Про SQL, транзакции, структуру таблиц | Бизнес-правило «скидка не больше 30 %» прописано прямо в хендлере |
| service usecase |
Сценарий целиком, инварианты, оркестрация репозиториев и внешних клиентов, границы транзакции | Про http.Request, JSON-теги, номера статусов |
В сервис прилетает *gin.Context, и он же пишет ответ |
| repository storage |
Доступ к данным, SQL, маппинг строк в доменные типы, ретраи драйвера | Про то, кто и зачем его вызвал | Репозиторий сам решает, слать ли письмо; наружу торчит *sql.Rows |
| domain core |
Типы предметной области и правила, которые верны всегда | Вообще ни о чём внешнем | domain импортирует gorm.io/gorm ради тегов |
go list -deps у сервиса покажет драйвер БД, справа - ничего лишнего.// internal/order/service.go: потребитель объявляет контракт, узкий и в своих терминах
package order
type Repository interface {
ByID(ctx context.Context, id ID) (*Order, error)
Save(ctx context.Context, o *Order) error
}
type Notifier interface {
OrderPaid(ctx context.Context, o *Order) error
}
type Service struct {
repo Repository
notif Notifier
now func() time.Time // время тоже зависимость: иначе тесты будут флакать
}
// принимаем интерфейсы, возвращаем конкретный тип
func NewService(r Repository, n Notifier) *Service {
return &Service{repo: r, notif: n, now: time.Now}
}
// internal/storage/postgres/order.go: реализация ничего не знает про order.Service
package postgres
type OrderRepo struct {
db *sql.DB
}
func NewOrderRepo(db *sql.DB) *OrderRepo { return &OrderRepo{db: db} }
func (r *OrderRepo) ByID(ctx context.Context, id order.ID) (*order.Order, error) {
var o order.Order
err := r.db.QueryRowContext(ctx, `SELECT id, status, total FROM orders WHERE id = $1`, id).
Scan(&o.ID, &o.Status, &o.Total)
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, fmt.Errorf("order %s: %w", id, order.ErrNotFound) // деталь БД наружу не течёт
case err != nil:
return nil, fmt.Errorf("select order: %w", err)
}
return &o, nil
}
Запусти go list -deps ./internal/order. Если в выводе есть net/http,
database/sql или имя драйвера - слоёв нет, есть папки. Эту же проверку вешают
в CI линтерами depguard (в составе golangci-lint) или
go-arch-lint. Архитектура, которую не проверяет машина, разрушается за квартал.
Чистая и гексагональная: чем они отличаются от «просто слоёв»
Гексагональная архитектура (она же ports & adapters, Алистер Кокбёрн, 2005) и «чистая» архитектура (Роберт Мартин, 2012) описывают одну идею в разной упаковке. Разница со слоёнкой ровно одна, но принципиальная:
- В слоёнке стрелки идут сверху вниз. Верхний слой зависит от нижнего.
Репозиторий считается «низом», и сервис зависит от него; ничто не мешает протащить
*sql.DBпрямо в сервис, и код будет компилироваться. - В гексагоне стрелки идут снаружи внутрь, всегда. Внешнего мира нет вообще: есть порты и адаптеры. HTTP-сервер и Postgres оказываются на одной и той же «наружной» окружности - и то и другое всего лишь способ поговорить с ядром.
Откуда слова. Порт описывает форму разъёма, которую задаёт ядро: «мне нужен кто-то, умеющий сохранить заказ и найти его по id». Как розетка в стене - она описывает форму отверстий, но ничего не знает о том, что в неё воткнут. Адаптер работает вилкой конкретного прибора: реализация подогнана под эту форму, будь то Postgres, файл или заглушка в тесте. Аналогия держится ровно до одного места: розетка отдаёт ток одинаково всем, а порт бывает двух видов, и они противоположны по направлению.
Порты делятся на два вида, и это единственная терминология, которую стоит запомнить. Через driving (primary, ведущие) внешний мир дёргает приложение: интерфейс use-case реализует сам сервис, а вызывают его HTTP-хендлер, gRPC, консьюмер Kafka или cron. Driven (secondary, ведомые) смотрят в другую сторону, через них уже приложение дёргает мир: репозиторий, кэш, шину, почту; их объявляет ядро, а реализуют адаптеры.
«Чистая архитектура» не сводится к папкам entity/ usecase/ adapter/. Если внутри
entity лежит gorm.Model, а usecase принимает
*gin.Context - у тебя ровно та же слоёнка, только с модными именами и лишними
маппингами. Критерий один: направление импортов, и проверяется он механически.
Когда это оверинжиниринг
Гексагон стоит денег: каждый переход через границу требует типа DTO и функции маппинга.
Три уровня моделей (OrderRequest → domain.Order → orderRow)
и две функции перекладывания полей на каждый сценарий. Если у сервиса 12 CRUD-ручек и ноль
бизнес-правил, ты получишь +300 % кода и ноль пользы: менять всё равно придётся все три модели
сразу, они меняются по одной и той же причине.
Число слоёв должно быть пропорционально количеству бизнес-правил, а не размеру команды и не моде. Тонкому CRUD хватит handler + storage. Появились инварианты, которые нужно проверять в нескольких местах, появились сценарии длиннее одного запроса к БД: вот тогда выделяется service. Появилось два транспорта или два хранилища, пора заводить порты. Обратный ход (схлопнуть слои) делать тяжело психологически, но технически он проще, чем добавление.
DI: конструкторы, composition root и надо ли wire
Composition root - единственное место в программе, где все конкретные реализации создаются и соединяются друг с другом. Знание «какая именно база, какой именно почтовик» должно сидеть в программе ровно в одной точке, остальные файлы про это не знают.
Go-идиома проста до скуки: зависимости приходят аргументами конструктора, а граф
собирается ровно в одном месте - в main. Никаких глобальных синглтонов,
никакого service locator, никакой «магии» через теги и рефлексию. Плюс: граф зависимостей
виден глазами, компилятор проверяет его целиком, а go build падает, если ты
что-то забыл подключить.
// cmd/api/main.go: composition root, единственное место со всеми конкретными типами
func main() {
cfg := config.MustLoad()
db, err := sql.Open("pgx", cfg.DSN)
must(err)
db.SetMaxOpenConns(cfg.DBMaxConns)
// снизу вверх: инфраструктура → репозитории → сервисы → транспорт
orderRepo := postgres.NewOrderRepo(db)
mailer := smtp.NewMailer(cfg.SMTP)
bus := kafka.NewPublisher(cfg.Brokers)
orderSvc := order.NewService(orderRepo, notify.New(mailer, bus))
h := httpapi.NewHandler(orderSvc, logger)
srv := &http.Server{Addr: cfg.Addr, Handler: h, ReadHeaderTimeout: 5 * time.Second}
run(srv)
}
Когда граф вырастает до 40–60 узлов, main на 200 строк начинает раздражать, и тут
появляются генераторы. Их два, и они принципиально разные.
| google/wire | uber-go/fx (и dig) | |
|---|---|---|
| Когда собирается граф | На этапе компиляции - генерирует wire_gen.go | В рантайме, через рефлексию |
| Где падает ошибка | go generate / компиляция | При старте процесса |
| Что читаешь при отладке | Обычный сгенерированный Go-код | Стек рефлексии и лог fx |
| Что даёт сверх сборки | Ничего - только сборку | Lifecycle (OnStart/OnStop), модули, graceful shutdown из коробки |
| Стоимость входа | Низкая, но нужен build-тег и генерация в CI | Средняя: команда должна знать fx, иначе «где это создалось?» |
«По умолчанию собираю руками в main: это самый читаемый вариант и он ничего не стоит.
wire беру, когда сборка перестала помещаться на экран. Он генерирует ровно тот код, который я написал бы сам, и ошибка ловится компилятором. Что с августа 2025 года репозиторий wire в архиве и новых версий не будет, я знаю, но это не страшно: сгенерированный файл остаётся обычным Go-кодом, и если генератор однажды отстанет от языка, этот файл можно дальше вести руками. fx оправдан там,
где важен не столько граф, сколько lifecycle: много фоновых воркеров, которые надо
согласованно поднимать и гасить. А вот DI-контейнер в сервис на десять файлов
ради красоты не тащу.»
Интерфейсы как контракты: объявлять у потребителя
Go отличается от Java/C# одним: интерфейсы реализуются неявно. Типу не нужно
объявлять implements, достаточно иметь нужные методы. Следствие: интерфейс можно
объявить после реализации и в другом пакете - том, который его использует.
Это и есть DIP в практическом виде.
// плохо: интерфейс живёт у реализации
package storage
type UserRepository interface {
Create(ctx context.Context, u *User) error
Update(ctx context.Context, u *User) error
Delete(ctx context.Context, id ID) error
ByID(ctx context.Context, id ID) (*User, error)
ByEmail(ctx context.Context, e string) (*User, error)
List(ctx context.Context, f Filter) ([]*User, error)
}
// сервису нужен один метод -
// а мокать придётся шесть
// хорошо: контракт у потребителя, ровно по нужде
package auth
type UserByEmail interface {
ByEmail(ctx context.Context, e string) (*domain.User, error)
}
type Service struct {
users UserByEmail
}
// storage.UserRepo подходит автоматически,
// он ничего не знает про пакет auth
Есть законные исключения, и их полезно назвать. Первое: интерфейс и есть
публичный контракт библиотеки, у которой несколько реализаций (io.Reader,
driver.Conn, hash.Hash). Второе: множество потребителей ждут
один и тот же набор методов - дублировать одинаковое объявление в шести пакетах хуже, чем
завести один. Третье: интерфейс нужен, чтобы разорвать цикл импортов, и по смыслу он
принадлежит нижнему общему пакету. Формулировка: «объявляю у потребителя по умолчанию,
у поставщика - когда это осознанный публичный контракт».
Структура проекта: cmd, internal, pkg и нарезка по домену
| Путь | Что там | Правило |
|---|---|---|
cmd/<bin>/main.go | По одной папке на каждый бинарь: api, worker, migrator | Только флаги, конфиг и сборка графа. Логики - ноль |
internal/ | Всё приложение | Команда go запрещает импорт извне дерева, в котором лежит эта папка. Единственный настоящий модификатор доступа в Go на уровне пакетов |
pkg/ | То, что ты обещаешь поддерживать для чужих | Не обещаешь совместимость - не заводи pkg/. Пустая папка-ритуал |
api/, proto/ | OpenAPI/proto-схемы и сгенерированный код | Схема - источник правды, код генерируется в CI |
migrations/ | SQL-миграции | Только вперёд, только через инструмент (goose/migrate), никаких ручных ALTER в проде |
Внутри internal/ есть развилка, на которой валятся чаще всего.
Нарезка по типам (models/, services/, handlers/,
repositories/) выглядит аккуратно, но одна правка фичи задевает четыре пакета,
и любой пакет знает про все домены сразу. Package-oriented design режет по
предметным областям (order/, billing/, user/): внутри
пакета лежат и модель, и логика, и интерфейсы хранилища. Правка фичи остаётся в одной
папке, а циклы почти не возникают, потому что домены слабо связаны по смыслу.
# по типам: «горизонтальная» нарезка # по домену: «вертикальная»
internal/ internal/
models/ user.go order.go payment.go user/ user.go service.go store.go
services/ user.go order.go payment.go order/ order.go service.go store.go
handlers/ user.go order.go payment.go billing/ payment.go service.go store.go
repository/ user.go order.go payment.go platform/ postgres/ kafka/ httpx/
Про монорепу спрашивают реже, но ответ короткий: она оправдана, когда общие контракты меняются часто и менять их нужно атомарно - один PR правит proto, сервер и всех клиентов. Платишь за это тулингом (нужен селективный CI, иначе каждый PR гоняет всё), правами доступа и дисциплиной: без границ монорепа за год превращается в клубок, где всё импортирует всё.
SOLID на Go: по одному честному примеру
SOLID складывается из первых букв пяти принципов проектирования (Роберт Мартин, начало 2000-х). Все пять отвечают на один вопрос: как написать код, который потом можно менять, не разбирая половину системы. SOLID придуман для языков с наследованием, и часть букв в Go звучит иначе. Каждую лучше показывать кодом, а не определением, но сначала сказать своими словами, о чём она.
S - Single Responsibility: одна причина для изменения
Принцип единственной ответственности. Формулируют его обычно как «класс должен делать одно дело», и это неточно: дел может быть много, важно другое. У куска кода должна быть одна причина меняться. За причиной стоит человек или отдел, который приходит с просьбой. Если один и тот же файл правят и когда маркетинг меняет текст письма, и когда бухгалтерия меняет правила скидок, и когда DBA переименовывает колонку - причин три, а значит, три разных повода сломать чужую работу в одном файле. Ровно этот случай в примере ниже.
// было: три причины меняться, правила заказа, формат письма, схема БД
func (s *OrderService) Place(ctx context.Context, req PlaceReq) error {
if req.Total < 0 { return errors.New("bad total") }
_, err := s.db.ExecContext(ctx, `INSERT INTO orders ...`)
if err != nil { return err }
body := "<h1>Заказ " + req.ID + "</h1>" // вёрстка письма в сервисе
return smtp.SendMail(s.addr, nil, "no-reply@x", []string{req.Email}, []byte(body))
}
// стало: каждая причина изменения живёт отдельно
func (s *OrderService) Place(ctx context.Context, o *domain.Order) error {
if err := o.Validate(); err != nil { return err } // правила проверяет домен
if err := s.repo.Save(ctx, o); err != nil { return err } // схему знает репозиторий
return s.notifier.OrderPlaced(ctx, o) // письмо собирает notifier
}
O - Open/Closed: расширяем добавлением, а не правкой switch
Принцип открытости/закрытости: код должен быть открыт для расширения и закрыт
для изменения. По-человечески - новую возможность добавляют, дописав новый код,
а не отредактировав старый, работающий и уже протестированный. Признак нарушения виден
глазом: каждый новый случай требует дописать ветку в тот же самый switch,
который трогали уже двадцать раз. Каждая такая правка даёт шанс сломать девятнадцать
предыдущих веток.
// было: каждый новый провайдер = правка функции, которую все уже протестировали
switch method {
case "card": return chargeCard(amount)
case "sbp": return chargeSBP(amount)
case "wallet": return chargeWallet(amount)
}
// стало: стратегия + реестр. Новый провайдер = новый файл, старый код цел
type Provider interface {
Name() string
Charge(ctx context.Context, a Money) (TxID, error)
}
type Registry struct {
m map[string]Provider
}
func (r *Registry) Register(p Provider) { r.m[p.Name()] = p }
func (r *Registry) Charge(ctx context.Context, name string, a Money) (TxID, error) {
p, ok := r.m[name]
if !ok { return "", fmt.Errorf("provider %q: %w", name, ErrUnsupported) }
return p.Charge(ctx, a)
}
L - Liskov: реализация не имеет права ужесточать контракт
Принцип подстановки Барбары Лисков: если код работает с интерфейсом, то любая реализация этого интерфейса должна в нём работать - без «а вот с этой надо осторожнее». Бытовой пример: розетка обещает 220 вольт. Прибор, который в неё воткнули, вправе на это рассчитывать. Реализация, которая иногда выдаёт 380, формально «тоже розетка», но пользоваться ей нельзя - и не потому, что она хуже, а потому что она нарушила обещание, на которое все уже полагаются. В коде такие нарушения выглядят так.
// Нарушение LSP, которое реально встречается:
// реализация io.Writer паникует вместо ошибки
func (w *badWriter) Write(p []byte) (int, error) {
if len(p) == 0 { panic("empty write") } // контракт io.Writer этого не разрешает
...
}
// Второе нарушение, частое в репозиториях:
func (r *cachedRepo) ByID(ctx context.Context, id ID) (*User, error) {
if u, ok := r.cache[id]; ok { return u, nil }
return nil, nil // вместо ErrNotFound: вызывающий получит nil-указатель и панику
}
Формулировка для собеса: подстановка реализации не должна ломать вызывающего. На практике это значит не добавлять предусловий, которых нет в контракте, не менять набор возвращаемых ошибок и не менять смысл «нулевого» результата.
I - Interface Segregation: маленькие интерфейсы
Принцип разделения интерфейсов: никого нельзя заставлять зависеть от методов, которыми он не пользуется. Чем больше методов требует интерфейс, тем меньше типов ему соответствует и тем больше лишнего приходится писать в заглушках для тестов. Просить надо ровно то, что реально вызываешь: функции, которой нужно только писать байты, незачем требовать объект, умеющий ещё и закрываться, перематываться и отдавать имя файла.
Формулировка Роба Пайка: the bigger the interface, the weaker the abstraction.
Стандартная библиотека держит планку: io.Reader и io.Writer по одному
методу, а всё остальное собирается композицией (io.ReadWriteCloser).
Функция должна просить минимум:
func Backup(dst io.Writer, src io.Reader) error // подойдёт файл, сокет, буфер, gzip-писатель
func Backup(dst *os.File, src *os.File) error // подойдёт только файл, тест потребует диска
D - Dependency Inversion: оба зависят от абстракции
Принцип инверсии зависимостей, тот самый, с которого начиналась глава: модуль высокого уровня не должен зависеть от модуля низкого уровня; оба зависят от абстракции. «Высокий уровень» описывает то, ради чего программа написана (сценарии, правила), «низкий» - как именно она это делает (Postgres, SMTP, файлы). Правило спасает от ситуации, когда бизнес-логику переписывают из-за смены библиотеки драйвера.
Выше это уже было: order.Service зависит от order.Repository,
postgres.OrderRepo - тоже от него. Ни один из них не зависит от другого напрямую.
Уточнение, которое любят слышать: абстракция принадлежит верхнему уровню,
а не нижнему, иначе инверсии не произошло.
Паттерны GoF в Go-идиомах
| Паттерн GoF | Как выглядит в Go | Где в stdlib / экосистеме |
|---|---|---|
| Strategy | Интерфейс с одним методом + поле в структуре. Часто - просто func-тип | sort.Interface, http.RoundTripper |
| Decorator | Middleware: func(http.Handler) http.Handler, обёртка вокруг интерфейса | http.StripPrefix, bufio.NewReader, интерсепторы gRPC |
| Adapter | Тип-переходник, приводящий один интерфейс к другому | http.HandlerFunc, sort.Reverse, io.NopCloser |
| Builder | Чаще заменён на functional options; классический builder - там, где нужен пошаговый неизменяемый объект | strings.Builder, net/url.URL + Query() |
| Factory | Функция NewX(...); фабричный метод - реестр map[string]func() T | sql.Register + sql.Open, image.RegisterFormat |
| Singleton | sync.Once / sync.OnceValue (Go 1.21). Обычно вообще не нужен - есть DI | rand глобальный источник, пулы соединений |
| Observer | Каналы + горутина-диспетчер, либо колбэки, либо внешний pub/sub | signal.Notify, context.Done(), fsnotify |
| Template Method | Функция высшего порядка вместо абстрактного класса: передаём шаг как аргумент | sort.Slice(s, less), filepath.WalkDir(fn) |
| Command | Структура-задача + очередь; для отложенных задач - сообщение в брокере | context.CancelFunc, задачи в worker pool |
// Functional options: конструктор с обязательными аргументами и расширяемым хвостом
type Client struct {
addr string
timeout time.Duration
retries int
log Logger
}
type Option func(*Client)
func WithTimeout(d time.Duration) Option { return func(c *Client) { c.timeout = d } }
func WithRetries(n int) Option { return func(c *Client) { c.retries = n } }
func WithLogger(l Logger) Option { return func(c *Client) { c.log = l } }
func New(addr string, opts ...Option) *Client {
c := &Client{addr: addr, timeout: 3 * time.Second, retries: 2, log: nopLogger{}}
for _, o := range opts { o(c) } // умолчания разумные, опции их переопределяют
return c
}
c := New("10.0.0.1:9000", WithTimeout(time.Second), WithRetries(5))
// Decorator == middleware. Цепочка разворачивается справа налево от вызова
type Middleware func(http.Handler) http.Handler
func Chain(h http.Handler, mw ...Middleware) http.Handler {
for i := len(mw) - 1; i >= 0; i-- { h = mw[i](h) }
return h
}
func WithLogging(l Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rw := &statusRecorder{ResponseWriter: w, code: 200}
next.ServeHTTP(rw, r)
l.Info("req", "path", r.URL.Path, "code", rw.code, "dur", time.Since(start))
})
}
}
// Тот же приём годится для любого интерфейса, не одного лишь http.Handler.
type cachedRepo struct {
next order.Repository
c *lru.Cache
}
func (r *cachedRepo) ByID(ctx context.Context, id order.ID) (*order.Order, error) { ... }
// Singleton по-современному: sync.OnceValue (Go 1.21) вместо ручного sync.Once и глобальной переменной
var loadRates = sync.OnceValue(func() *RateTable {
return mustParse(embeddedRates) // выполнится ровно один раз, потокобезопасно
})
// и вариант с ошибкой:
var conn = sync.OnceValues(func() (*grpc.ClientConn, error) {
return grpc.NewClient(addr)
})
Репозиторий: что возвращать при «не найдено»
Репозиторий прячет способ хранения за интерфейсом в терминах домена. Он даёт три вещи: тестируемость сервиса без БД, единое место маппинга «строка → доменный тип» и запрет на протекание SQL по всему коду. А вот чего он, вопреки легенде, не даёт, так это «легко сменить Postgres на Mongo»: реальные запросы, транзакции и модель консистентности так не переносятся.
package user
// Sentinel-ошибки домена: не привязаны ни к SQL, ни к HTTP
var (
ErrNotFound = errors.New("user not found")
ErrAlreadyExists = errors.New("user already exists")
)
type Repository interface {
ByID(ctx context.Context, id ID) (*User, error)
ByEmail(ctx context.Context, email string) (*User, error)
Create(ctx context.Context, u *User) error
Update(ctx context.Context, u *User) error
}
- Не
return nil, nil: вызывающий обязан помнить о проверке на nil, забудет - поймает панику. - Не
(*User, bool, error). Три возвращаемых значения превращают каждый вызов в лапшу. - Не
sql.ErrNoRowsнаружу: деталь хранилища утекает в бизнес-слой, а стоит части данных переехать в Redis или в соседний сервис, сломается всё. - Да: доменный sentinel
ErrNotFound, обёрнутый через%w, иerrors.Isна верхних уровнях. Транспорт мапит его в 404 в одном месте.
// Транспорт: единственное место, где домен встречается с HTTP-кодами
func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
u, err := h.svc.Get(r.Context(), user.ID(r.PathValue("id"))) // Go 1.22 routing
switch {
case errors.Is(err, user.ErrNotFound):
http.Error(w, "not found", http.StatusNotFound)
case errors.Is(err, context.DeadlineExceeded):
http.Error(w, "timeout", http.StatusGatewayTimeout)
case err != nil:
h.log.Error("get user", "err", err)
http.Error(w, "internal", http.StatusInternalServerError) // деталь наружу не отдаём
default:
writeJSON(w, u)
}
}
Как только сценарий трогает два репозитория, всплывает «где начинается транзакция».
Плохо, если *sql.Tx протащили через интерфейс: домен узнал про SQL.
Рабочие варианты: (1) метод-обёртка WithTx(ctx, func(ctx) error) у
отдельного типа Transactor, который кладёт транзакцию в context,
а репозитории достают её оттуда (querier(ctx) возвращает либо *sql.Tx,
либо *sql.DB); (2) Unit of Work - объект, отдающий согласованный набор
репозиториев внутри одной транзакции; (3) просто вынести весь сценарий в один метод
репозитория, если он всё равно про одну агрегатную сущность. Чаще всего в Go берут первый.
Рефакторинг god-object на 1000+ строк
God-object знает и умеет всё: тысячи строк, три десятка полей, методы про базу, про HTTP, про почту и про бизнес-правила одновременно. Признак не в размере, а в том, что его правят по любому поводу: какое требование ни принеси, менять придётся этот файл. Это прямое нарушение S из SOLID в самой запущенной форме, а вредит он конфликтами в git и страхом что-либо трогать.
Правило простое: сначала сеть безопасности, потом движение. Переписать «начисто» почти всегда проигрывает, потому что в старом коде зашиты годы багфиксов, о которых никто не помнит. Порядок, который стоит проговорить на собесе:
- Характеристические тесты (термин Майкла Физерса). Не «правильные» тесты, а фиксирующие текущее поведение, включая странности. Проще всего через golden-файлы: прогнать реальные входы, записать выходы, закоммитить, сравнивать. Цель в том, чтобы любое изменение поведения было видно как diff.
- Найти швы - места, где можно подменить зависимость. В Go это обычно
значит: вынести глобалы и прямые вызовы
time.Now,http.Get,os.Getenv,db.Queryв поля структуры. - Выделить чистые функции. Куски без ввода-вывода и без состояния вытаскиваются первыми: их легко покрыть тестами и невозможно сломать незаметно.
- Разрезать по данным. Смотришь, какие поля используются какими методами вместе - это и есть будущие типы. Метод, который трогает только три поля из двадцати, просится наружу.
- Переносить по одному методу, оставляя в старом объекте делегирующий вызов. Компилятор Go тут помогает: он найдёт все места использования.
- Удалить делегаты и старый тип, когда все вызовы переехали.
Big bang rewrite: ветка на три месяца, которая не мержится, потому что главная ветка ушла вперёд. Смешивание переименования и переноса в одном коммите делает diff нечитаемым, и ревью превращается в формальность. Рефакторинг без метрики не кончается никогда: заранее договорись, что считается успехом (покрытие, цикломатика, число зависимостей пакета).
Циклические зависимости пакетов
Go запрещает циклы импортов на уровне компилятора: import cycle not allowed.
Это не каприз - так держится корректный порядок инициализации пакетов и возможность
компилировать пакеты независимо. Причина цикла почти всегда одна: пакеты нарезаны
по типам данных, а не по зонам ответственности, и два «домена» ссылаются друг на друга.
Конфигурация и 12-factor
Практический канон: флаги перекрывают переменные окружения, те перекрывают файл, файл
перекрывает умолчания. Порядок можно выбрать и другой, лишь бы он был один,
задокументирован и не менялся от сервиса к сервису. Конфиг разбираем один раз на старте,
валидируем целиком, дальше раздаём кусками в конструкторы. os.Getenv
посреди бизнес-логики работает как скрытый глобал: его не протестируешь и не увидишь в графе.
type Config struct {
Addr string `env:"ADDR" envDefault:":8080"`
DSN string `env:"DATABASE_URL,required"`
DBMaxConns int `env:"DB_MAX_CONNS" envDefault:"20"`
ReadTimeout time.Duration `env:"READ_TIMEOUT" envDefault:"5s"`
LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
}
func (c Config) Validate() error {
if c.DSN == "" { return errors.New("DATABASE_URL is required") }
if c.DBMaxConns <= 0 { return fmt.Errorf("DB_MAX_CONNS=%d must be > 0", c.DBMaxConns) }
if !slices.Contains([]string{"debug", "info", "warn", "error"}, c.LogLevel) {
return fmt.Errorf("LOG_LEVEL=%q unknown", c.LogLevel)
}
return nil
}
// fail fast: неправильный конфиг убивает процесс на старте,
// а не через два часа на первом обращении к необязательной ручке.
- III. Config в окружении, потому что он меняется между стендами, а код нет. В репозитории не должно быть конфигов стендов; внутренний конфиг приложения, вроде таблицы маршрутов, манифест конфигом не считает.
- VI. Stateless-процессы. Никакого состояния в памяти между запросами: сессии в Redis, файлы в S3. Иначе не масштабируется горизонтально и ломается при рестарте.
- VII. Port binding. Приложение само слушает порт, а не живёт внутри сервера приложений. Для Go это данность.
- IX. Disposability: быстрый старт и корректный
SIGTERM. Перестать принимать новое, дожать текущее, закрыть коннекты. Прямая связка с graceful shutdown и k8s. - XI. Логи в stdout: ротацией и файлами занимается окружение, а не приложение.
- Секреты устроены иначе. Env-переменная видна в
/proc/<pid>/environи попадает в дампы; если же значение вписано прямо в манифест, а не взято из Secret, его покажет и описание пода. Под секреты берут Vault/KMS/k8s Secret, монтируют файлом и ротируют.
Вопросы
11
Формулировка, которую ждут: каждый слой знает только про слой ниже, и никогда наоборот.
Транспорт знает про сервис, сервис - про интерфейсы репозиториев, репозиторий - про SQL.
Обратных импортов нет вообще: service не должен уметь собрать HTTP-ответ,
а repository не должен знать, кто его вызвал и зачем.
Что это покупает
- Транспорт меняется, логика остаётся. Появился gRPC или консольная команда, дописываешь ещё один адаптер поверх того же сервиса. Если бизнес-правила размазаны по хендлерам, второй транспорт означает копипасту правил.
- Тестируемость. Сервис тестируется без БД и без HTTP: подставил фейковый репозиторий, проверил сценарий. Это самые дешёвые и самые полезные тесты в проекте.
- Изменения не расползаются. Поменялась схема таблицы, правишь репозиторий. Поменялся формат ответа API, правишь транспорт. Поменялось правило скидки, правишь сервис. Если правка одного требования трогает все три слоя, границы проведены неправильно.
Как это ломают
Три классических нарушения. Первое, «анемичный сервис»: хендлер сам ходит в репозиторий,
сам считает, сам решает; сервис остаётся тонкой прослойкой-делегатом и никакой ценности не несёт.
Второе: транспорт протекает вниз, в сервис прилетает *gin.Context или
http.ResponseWriter, и тестировать его теперь нельзя без поднятия сервера.
Третье: хранилище протекает вверх, наружу возвращается *sql.Rows или
структура с gorm-тегами, и домен оказывается формой таблицы.
Тест на собесе: «могу ли я собрать бинарь, где вместо HTTP - CLI, и не тронуть
ни строки в service?» и «сколько файлов надо открыть, чтобы понять сценарий целиком?».
В Go есть и механическая проверка - правило импортов легко закрепить линтером
(depguard, go-arch-lint) или просто структурой пакетов.
Если internal/storage физически не импортируется из internal/domain,
компилятор сам не даст нарушить.
«А DTO между слоями отдельные или одна структура на всё?» Честный ответ: по умолчанию одна доменная структура, отдельные DTO появляются, когда транспортное и доменное представления начинают расходиться (скрытые поля, версии API, вычисляемые поля). Три зеркальные структуры на CRUD из пяти полей выглядят как ритуал, а не как архитектура, и грамотный интервьюер это ценит.
В наивной трёхслойке цепочка импортов такая: handler → service → postgres.
Домен в самом низу зависит от драйвера БД. Гексагональная (она же «порты и адаптеры»,
она же ядро Clean Architecture) разворачивает это: service объявляет интерфейс
UserRepo у себя, а пакет postgres импортирует service,
чтобы этот интерфейс реализовать. Домен теперь не импортирует ничего, кроме стандартной
библиотеки. Это и есть Dependency Inversion из SOLID, применённый к границе процесса.
Ports and adapters, конкретно
- Driving-порты (входящие, «слева») описывают, что домен предлагает миру: интерфейс юзкейса. Адаптеры: HTTP-хендлер, gRPC-сервер, consumer Kafka, cron-команда.
- Driven-порты (исходящие, «справа») говорят, что домену нужно от мира:
UserRepo,Mailer,PaymentGateway,Clock. Адаптеры: Postgres, SMTP, HTTP-клиент,time.Now. - Composition root остаётся единственным местом (
main), где конкретные адаптеры встречаются с портами.
Практический выигрыш измеряется не красотой схемы, а тем, сколько тестов работают без Docker.
Когда все внешние эффекты спрятаны за портами, весь домен покрывается быстрыми юнит-тестами,
а тестконтейнеры нужны только адаптерам. Второй выигрыш: время больше не просачивается
в код случайно, Clock как порт снимает флаки в тестах на таймауты и TTL.
Когда это оверинжиниринг
Когда домена нет. Если сервис просто проксирует БД (CRUD, фильтры, пагинация), то «домен»
состоит из структуры и валидации, и три слоя абстракций вокруг него означают, что новое поле
придётся дописывать в шести файлах. Признаки лишнего: интерфейс ровно с одной реализацией,
которая никогда не менялась; маппинг структуры в структуру без единого изменения полей;
папка usecase, где в каждом файле один метод, дёргающий один метод репозитория.
Честная позиция на собесе: «начинаю с плоской структуры, инвертирую зависимость там, где
реально появилась вторая реализация или где мне мешает тестировать».
Термины лучше развести. Hexagonal (Кокбёрн, 2005) говорит только про симметрию портов и адаптеров, никаких обязательных четырёх колец. Onion (Палермо) добавляет кольца и правило зависимостей внутрь. Clean (Мартин) сводит это вместе и дописывает сущности, юзкейсы и явные Boundary-интерфейсы. В Go 90 % пользы даёт одна идея из всех трёх: интерфейс объявляет тот, кто его вызывает. Остальное зависит от вкуса и размера команды, и на собесе полезно так и сказать.
wire и fx решают не проблему DI,
а проблему размера composition root.
Канон: у каждого компонента есть New…, принимающий интерфейсы своих зависимостей
и возвращающий готовый к работе объект. Всё связывание происходит в одном месте, в
main (composition root). Граф зависимостей виден глазами, порядок инициализации
и закрытия ресурсов явный, компилятор проверяет типы. Это и есть «DI без магии».
func main() {
cfg := config.MustLoad()
db, err := pgxpool.New(ctx, cfg.DSN) // ресурсы создаются сверху вниз,
if err != nil { log.Fatal(err) }
defer db.Close() // закрываются снизу вверх
repo := postgres.NewUserRepo(db) // адаптер реализует порт
mailer := smtp.NewMailer(cfg.SMTP)
svc := user.NewService(repo, mailer, clock.System{})
h := httpapi.NewHandler(svc, logger)
srv := &http.Server{Addr: cfg.Addr, Handler: h.Routes()}
// ... graceful shutdown
}
Когда становится больно
Когда в main набирается 300 строк проводки и новую зависимость приходится
протаскивать через пять конструкторов. Тогда есть два ответа.
wire (Google) генерирует код: ты описываешь провайдеры, go generate
выдаёт обычный Go-файл с той же ручной проводкой. Ошибки вылезают на компиляции, рантайм
не платит ничего, в отладке видно нормальный код. fx (Uber) собирает граф в рантайме
через рефлексию: удобные жизненные циклы (OnStart/OnStop), группы,
декораторы, но ошибки графа всплывают при старте, а стектрейсы становятся неприятными.
| Ручные конструкторы | wire | fx | |
|---|---|---|---|
| Когда падает при ошибке графа | компиляция | кодогенерация/компиляция | старт процесса |
| Рефлексия в рантайме | нет | нет | да |
| Читаемость графа | полная | полная (сгенерённый код) | надо доверять контейнеру |
| Жизненный цикл ресурсов | руками, defer | руками + cleanup-функции | встроен |
| Порог входа для нового человека | нулевой | низкий | заметный |
«По умолчанию - руками. Это идиоматично, читается без документации и не стоит ничего
в рантайме. Если проводка разрастается и по ней начинают ошибаться, беру wire: он даёт тот же самый код, только сгенерированный. Что wire с 2025 года в архиве, не страшно: сгенерированный код от него не зависит и при нужде дальше ведётся руками. fx оправдан там, где много одинаковых
сервисов и нужна единая платформенная обвязка с жизненным циклом; в одном сервисе он
добавляет магии больше, чем убирает бойлерплейта.» Отдельно назови, чего в Go делать
не надо: сервис-локатор, глобальный контейнер, зависимости через init()
и пакетные переменные. Всё это скрытый граф, который нельзя ни увидеть, ни подменить в тесте.
В Java или C# интерфейс объявляется вместе с реализацией, потому что класс обязан явно сказать
implements. В Go этого нет: любой тип с подходящим набором методов автоматически
удовлетворяет интерфейсу. Значит, интерфейс можно объявить где угодно, и правильное место
там, где он используется. Пакет-потребитель описывает минимальный контракт, который ему нужен,
а пакет-реализация о нём даже не знает.
// Плохо: пакет-производитель объявляет широкий интерфейс "на всякий случай"
package postgres
type Storage interface { // 20 методов; потребителю нужен один
GetUser(...) ; SaveUser(...) ; ListUsers(...)
GetOrder(...) ; SaveOrder(...) ; /* ... */
}
// Хорошо: потребитель просит ровно то, что использует
package notify
type userGetter interface { // с маленькой буквы: деталь пакета
ByID(ctx context.Context, id user.ID) (*user.User, error)
}
type Notifier struct {
users userGetter
}
func New(u userGetter) *Notifier { return &Notifier{users: u} }
Что это даёт по шагам
- Мок пишется в две строки и лежит рядом с тестом: ни gomock, ни правка чужого пакета не нужны.
- Циклы импорта рвутся. Стрелка разворачивается: раньше
notifyимпортировалpostgres, теперь никто никого. - Честный ISP. Интерфейс с одним методом не получится «раздуть на будущее» - он ровно такой, каким его требует код.
- Устойчивость к изменениям. Добавили метод в конкретный тип - ни один потребитель не сломался, потому что их контракты уже, чем реализация.
Обратная сторона правила: возвращать надо конкретный тип. Если конструктор отдаёт
интерфейс, потребитель теряет доступ к новым методам, доку становится читать сложнее,
и появляется классическая ловушка Go - «типизированный nil в интерфейсе»: функция вернула
(*MyErr)(nil), а сравнение err != nil дало true.
Есть законные исключения, и их стоит назвать: (1) интерфейсы стандартной библиотеки
(io.Reader, error, fmt.Stringer) задают общий словарь
и объявлены «у производителя» специально; (2) плагинная точка расширения, когда
реализаций заведомо много и они внешние (драйверы database/sql);
(3) контракт, который и есть публичный API библиотеки. Во всех этих случаях
интерфейс входит в продукт, а не остаётся деталью потребителя.
internal/ (импорт разрешён только из поддерева родителя этой папки). Всё остальное - соглашение,
и pkg/ чаще вреден, чем полезен.Что означает каждая папка
cmd/<binary>/main.goдаёт по папке на бинарь. Внутри только разбор конфига, сборка графа зависимостей и запуск. Логики здесь нет: всё, что вmain, невозможно импортировать и тяжело тестировать.internal/остаётся единственной папкой со смыслом на уровне языка. Пакет внутриinternalимпортируется только из поддерева его родителя. Это защита от того, что чужой сервис начнёт зависеть от твоих внутренностей и ты никогда не сможешь их поменять. По умолчанию весь код сервиса живёт здесь.pkg/означает «код, который мы разрешаем импортировать снаружи». В проекте с одним сервисом это лишний уровень вложенности. Заводить его стоит, только когда у тебя есть внешние потребители и ты готов держать обратную совместимость.- В
api/лежат proto/OpenAPI-схемы, вmigrations/SQL, вdeploy/манифесты. Это уже вкусовщина, но она предсказуемая.
Package-oriented design
Идея Билла Кеннеди: пакет задаёт зону ответственности, а не категорию типов.
Нарезка models/, services/, handlers/, utils/
группирует по «что это за штука», из-за чего одна фича размазывается по четырём папкам,
а импорты неизбежно закольцовываются. Нарезка user/, order/,
billing/ группирует по «про что это», и тогда фича живёт в одном месте,
а имя пакета работает на читаемость: user.Service, а не services.UserService
(заикание user.UserService выдаёт ту же неправильную нарезку).
Отдельно под запретом пакет utils/common/helpers:
склад без границы, который со временем импортирует всё и становится источником циклов.
Монорепа
Монорепа выигрывает, когда сервисы делает одна команда и они меняются вместе: атомарный
коммит через границу сервиса, единые версии зависимостей, общий тулинг и линтеры, простой
shared-код без публикации библиотек, честный «сломал контракт - увидел сразу». Проигрывает,
когда репозиторий вырастает настолько, что CI начинает собирать всё на любое изменение
(лечится go work, тегами и селективной сборкой по изменённым путям), и когда
разные команды хотят разный релизный цикл и разные права доступа. Практичный ответ:
«пока команда одна и сервисов меньше десятка, монорепа почти всегда дешевле;
боль начинается на масштабе CI, и лечится она тулингом, а не разделением репозиториев».
Этот репозиторий часто выдают за официальный стандарт. Стандартом он не стал, и авторы Go
от него открыто дистанцируются. Рабочая позиция на собесе: «начинаю плоско, с main.go
и нескольких пакетов по доменам; cmd/ появляется, когда бинарей становится больше
одного; internal/ завожу сразу, потому что это бесплатная защита границы;
pkg/ только под реальных внешних потребителей».
S - Single Responsibility
«У модуля должна быть одна причина для изменения» - причина, а не действие. Классический
антипример: OrderService, который валидирует, считает налог, пишет в БД, шлёт письмо
и отдаёт PDF. Его меняют бухгалтерия, маркетинг и DBA - три причины, три источника конфликтов.
Разбиваешь на Validator, TaxCalculator, Repository,
Notifier, а сам сервис становится сценарием, который их вызывает.
O - Open/Closed
Расширять поведение без правки существующего кода. В Go это делается не наследованием, а функциями и интерфейсами: реестр стратегий, куда регистрируются новые обработчики, или functional options, куда добавляется новая опция.
// Было: switch, который правят при каждом новом типе оплаты
switch method {
case "card": ...
case "sbp": ...
}
// Стало: новая оплата = новый файл, старый код не трогаем
type PaymentMethod interface {
Charge(ctx context.Context, amount Money) (TxID, error)
}
type Registry struct {
m map[string]PaymentMethod
}
func (r *Registry) Register(name string, p PaymentMethod) { r.m[name] = p }
L - Liskov Substitution
Наследования в Go нет, но LSP никуда не делся: любая реализация интерфейса обязана вести себя
так, как обещает контракт. Контракт нарушает реализация, которая на половине методов возвращает
errors.New("not supported"), или Read, который не возвращает
io.EOF, или репозиторий, который вместо ErrNotFound отдаёт
nil, nil. Код, написанный против интерфейса, ломается при подстановке, и это
нарушение LSP. В контракт входят сигнатуры, а вместе с ними ошибки, блокирующее поведение
и потокобезопасность.
I - Interface Segregation
Много маленьких интерфейсов лучше одного большого. Эталон держит стандартная библиотека:
io.Reader и io.Writer по одному методу, а io.ReadWriteCloser
собирается композицией. Практическое правило: если реализациям приходится писать заглушки -
интерфейс слишком широкий.
D - Dependency Inversion
Оба уровня зависят от абстракции, и объявляет её верхний уровень. Именно поэтому в Go
интерфейс живёт у потребителя: service объявляет UserRepo,
postgres его реализует, и стрелка импорта идёт снизу вверх, к домену.
Пересказать принципы «как в книжке» и не сказать про цену. Сильный ответ добавляет:
«SRP, доведённый до предела, даёт сотню типов по одному методу, где сценарий невозможно
прочитать целиком; OCP через реестр стратегий делает поток управления неявным -
по коду больше не видно, кто сработает. Я применяю их как направление, а не как проверку:
если правка одного требования трогает пять пакетов - нарушен SRP; если новая фича заставляет
править switch в трёх местах - просится OCP.»
| Паттерн | Идиома в Go | Где живёт в стдлибе / экосистеме |
|---|---|---|
| Builder | Functional options: New(addr, WithTimeout(5*time.Second)) | grpc.NewClient, zap.New |
| Strategy | Поле-интерфейс или просто поле-функция | sort.Slice(less func) |
| Decorator | Обёртка, реализующая тот же интерфейс; middleware-цепочка | http.Handler, io.LimitReader |
| Adapter | Тип-функция с методом: http.HandlerFunc | http.HandlerFunc, sort.Reverse |
| Factory | Обычная функция New…; реестр для выбора по строке | sql.Open по имени драйвера |
| Singleton | sync.Once / sync.OnceValue (Go 1.21) | ленивые клиенты, компиляция регэкспов |
| Observer | Каналы, список подписчиков, context.Done() | шины событий, signal.NotifyContext |
| Iterator | Функции-итераторы iter.Seq (Go 1.23), range over func | maps.Keys, slices.Values |
| Template Method | Не наследование, а функция-параметр или встраивание интерфейса | - |
Functional options - почему именно они
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 New(addr string, opts ...Option) *Server {
s := &Server{addr: addr, timeout: 30 * time.Second, log: slog.Default()} // умолчания
for _, o := range opts { o(s) }
return s
}
Плюсы: обратная совместимость (новая опция не ломает старые вызовы), разумные умолчания, самодокументируемость на месте вызова, невозможность перепутать порядок аргументов. Минусы: многословно, и валидация уезжает в рантайм; для двух-трёх параметров честнее просто структура конфига.
Decorator = middleware
type Middleware func(http.Handler) http.Handler
func WithLogging(log *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Info("req", "path", r.URL.Path, "dur", time.Since(start))
})
}
}
// Порядок важен: первый в списке становится самым внешним
func Chain(h http.Handler, ms ...Middleware) http.Handler {
for i := len(ms) - 1; i >= 0; i-- { h = ms[i](h) }
return h
}
Abstract Factory с иерархией фабрик в языке без наследования вырождается в чистый бойлерплейт.
Singleton через пакетную переменную и init() прячет зависимость:
в тесте её не подменить, порядок инициализации между пакетами непредсказуем;
если состояние правда одно на процесс, оно всё равно должно создаваться в main
и передаваться явно. Visitor почти всегда проще выражается через
switch v := x.(type). Хороший ответ на собесе заканчивается фразой:
«паттерн это имя для решения, а не цель; в Go половина GoF растворяется в языке».
ErrNotFound, обёрнутая через
%w; ни nil, nil, ни sql.ErrNoRows наружу отдавать нельзя.Три реальные причины заводить репозиторий: сервис тестируется без базы; маппинг «строка → доменный тип» лежит в одном месте; SQL не растекается по хендлерам. Причина, которую называют чаще всего и которая почти всегда ложная, звучит так: «сможем сменить Postgres на Mongo». Не сможем. Транзакции, блокировки, уникальные индексы и модель консистентности интерфейсом не абстрагируются.
// domain/user: интерфейс объявлен там, где используется
type Repository interface {
ByID(ctx context.Context, id ID) (*User, error)
ByEmail(ctx context.Context, email string) (*User, error)
Create(ctx context.Context, u *User) error
Update(ctx context.Context, u *User) error
}
var (
ErrNotFound = errors.New("user not found")
ErrAlreadyExists = errors.New("user already exists")
)
// storage/postgres: адаптер переводит ошибки драйвера в доменные
func (r *UserRepo) ByID(ctx context.Context, id user.ID) (*user.User, error) {
const q = `SELECT id, email, name, created_at FROM users WHERE id = $1`
var u user.User
err := r.db.QueryRow(ctx, q, id).Scan(&u.ID, &u.Email, &u.Name, &u.CreatedAt)
switch {
case errors.Is(err, pgx.ErrNoRows):
return nil, fmt.Errorf("user %s: %w", id, user.ErrNotFound) // %w сохраняет sentinel
case err != nil:
return nil, fmt.Errorf("select user %s: %w", id, err)
}
return &u, nil
}
func (r *UserRepo) Create(ctx context.Context, u *user.User) error {
_, err := r.db.Exec(ctx, `INSERT INTO users (...) VALUES (...)`, ...)
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" { // unique_violation
return fmt.Errorf("email %s: %w", u.Email, user.ErrAlreadyExists)
}
return err
}
Почему именно так
nil, nilперекладывает на вызывающего обязанность помнить про проверку; рано или поздно кто-то забудет и получит панику вместо 404.(*User, bool, error)возвращает три значения на каждый вызов, конструкцияif !okплодится по всему коду и путается сerr != nil.sql.ErrNoRowsнаружу тащит деталь драйвера в бизнес-логику: стоит части данных переехать, скажем, в Redis (там свойredis.Nil), и проверки сломаются по всему сервису. Переход наpgxкак раз пройдёт гладко: с версии 5.7 егоpgx.ErrNoRowsоборачиваетsql.ErrNoRows, но это любезность авторов драйвера, а не гарантия.- Sentinel +
errors.Isпозволяют транспорту в одном месте отобразить домен в HTTP:ErrNotFound→ 404,ErrAlreadyExists→ 409.
Транзакции: не тащить *sql.Tx в интерфейс. Либо Transactor
с методом WithTx(ctx, fn), кладущий транзакцию в context, либо
Unit of Work. Списки: метод List с произвольными фильтрами быстро
превращается в конструктор SQL внутри домена, и это признак, что нужен либо явный
типизированный Filter-объект, либо отдельный read-модуль с честными запросами
(шаг в сторону CQRS). Для пустого списка ошибка не нужна: пустой срез - валидный результат.
Порядок действий
- Характеристические тесты (Майкл Физерс). Не «правильные», а фиксирующие текущее поведение, включая странности. Golden-файлы: прогнал реальные входы, записал выходы, закоммитил. Теперь любое изменение поведения видно как diff.
- Найти швы (seams). В Go это значит вытащить наружу
time.Now,http.Get,os.Getenv, глобальные переменные, прямыеdb.Query, и сделать их полями структуры или параметрами. - Вынести чистые функции. Куски без ввода-вывода покрываются тестами первыми и ломаются заметнее всего.
- Разрезать по данным. Смотришь, какие поля используются какими методами вместе. Метод, трогающий три поля из двадцати, просится в отдельный тип. Это самый надёжный критерий границы - куда надёжнее, чем «по смыслу».
- Переносить по одному методу, оставляя в старом объекте делегирующий вызов. Каждый шаг компилируется, каждый шаг деплоится.
- Удалить делегаты, когда все вызовы переехали. Компилятор Go найдёт остатки.
Что говорить про процесс
Рефакторинг идёт в основной ветке маленькими коммитами, а не в трёхмесячной ветке. Переименование и перенос разводи по разным коммитам, иначе diff нечитаем и ревью формально. Если поведение обязано измениться, ставь feature flag, чтобы старый и новый путь жили рядом и можно было сравнить результаты на проде (shadow-запуск). И заранее договорись о метрике успеха: размер файла, число зависимостей пакета, время сборки, покрытие. Иначе процесс не заканчивается.
«Я не рефакторю ради красоты. Триггером работает конкретная боль: в этот файл упираются все задачи спринта, любая правка ломает соседнее, тесты идут десять минут. Тогда я делаю ровно столько, сколько нужно, чтобы текущая задача стала простой. Правило Кента Бека: make the change easy, then make the easy change. Большой рефакторинг „потому что некрасиво“ никогда не окупается и не проходит приоритизацию.»
import cycle not allowed). Цикл почти всегда означает, что пакеты нарезаны
по типам данных, а не по зонам ответственности.
Запрет не каприз: без циклов у компилятора есть однозначный топологический порядок
инициализации пакетов (переменные уровня пакета, затем init()), и каждый пакет
можно компилировать независимо и кэшировать. В языках, где циклы разрешены, порядок
инициализации становится источником трудноуловимых багов.
Четыре способа разорвать
- Интерфейс у потребителя. Работает почти всегда:
orderобъявляет у себяtype userGetter interface{ ByID(...) }, и импортorder → userисчезает, аuserо нём и не знал. - Общий нижний пакет. Типы, нужные обоим, уезжают в
internal/domain(илиentity), от которого зависят оба, а он - ни от кого. - Слить пакеты. Если два пакета взаимно нужны друг другу постоянно, это чаще всего одна зона ответственности, разрезанная искусственно.
- Инвертировать через события. Вместо прямого вызова
user → notifyпакетuserпубликует событие в шину, аnotifyподписывается; оба зависят от пакета с типами событий.
Разрывать цикл через any и reflect, через глобальную мапу
обработчиков или через регистрацию в init(). Компилятор замолчит, но цикл
никуда не делся - он просто переехал в рантайм, где вместо ошибки сборки ты получишь
панику при старте или в проде. Особняком идёт тестовый цикл: пакет a
тестируется с помощью b, а b импортирует a.
Лечится внешним тестовым пакетом package a_test, которому разрешено
импортировать и a, и b.
Практический канон
- Приоритет один на все сервисы и задокументирован. Какой именно, дело вкуса; важно, чтобы он не менялся от проекта к проекту.
- Fail fast. Невалидный конфиг убивает процесс на старте, а не через два часа на первом обращении к редкой ручке. В k8s это правильно: под не пройдёт readiness и старая версия останется жить.
- Конфиг не глобал. Разобрали структуру в
main, дальше передаём куски в конструкторы.os.Getenvпосреди бизнес-логики прячет зависимость: её не видно в графе и не подменить в тесте. - Типы, а не строки.
time.Duration,int,url.URLразбираются и проверяются на старте, чтобы потом не гадать: «таймаут тут в миллисекундах или в секундах?».
12-factor: что реально спрашивают
- III. Config в окружении. В репозитории конфигов стендов быть не должно, а новый стенд не должен требовать релиза.
- VI. Stateless-процессы: никакого состояния в памяти между запросами. Сессии в Redis, файлы в объектном хранилище. Иначе не масштабируется горизонтально и теряется при рестарте.
- IX. Disposability требует быстрого старта и корректной реакции на
SIGTERM: перестать принимать новое, дожать текущее, закрыть коннекты. Прямая связка с graceful shutdown и с тем, как k8s выкатывает поды. - XI. Логи в stdout. Приложение не занимается ротацией и файлами, это работа окружения.
- X. Dev/prod parity: одинаковые версии зависимостей и одинаковый способ запуска, отсюда популярность docker-compose для локальной разработки.
Этот вопрос любят задавать следом. Переменная окружения видна в
/proc/<pid>/environ, попадает в core-дампы и в логи упавшего процесса, а ротировать её можно только рестартом. Если значение вписано прямо в манифест, а не взято из Secret, его покажет ещё и kubectl describe pod. Поэтому секреты
хранят в Vault/KMS/k8s Secret, монтируют файлом, ротируют и держат короткий TTL
(в идеале - динамические креды к БД). И главное: секрет не коммитят никогда, даже
в приватный репозиторий, потому что вычистить его из истории получится только переписыванием
всех коммитов с force-push, а утёкший ключ всё равно придётся считать
скомпрометированным.
Что стоит запомнить
- Интерфейс обычно объявляет потребитель и включает только поведение, которое ему нужно.
- Composition root знает конкретные реализации и соединяет зависимости; бизнес-пакет от этого не импортирует инфраструктуру.
- Архитектура полезна, когда меняет направление зависимости или изолирует причину изменения; дополнительные слои имеют цену.
Частые ошибки
- Не создавай широкий универсальный интерфейс в storage только ради тестов, если потребитель использует один метод.
- Не возвращай nil,nil при NotFound: вызывающий код может принять отсутствие за успех и разыменовать nil.
- Не добавляй абстракцию без второй реализации, нужного test seam или независимой причины изменения.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Прочитай направление зависимости
Какой пакет владеет контрактом, какой его реализует и какой constructor из composition root создаёт адаптер?
// package order
type Repository interface { ByID(context.Context, ID) (*Order, error) }
// package postgres
type OrderRepo struct { db *sql.DB }
func NewOrderRepo(db *sql.DB) *OrderRepo { return &OrderRepo{db: db} }
func (r *OrderRepo) ByID(ctx context.Context, id order.ID) (*order.Order, error) { ... }
Ответ и разбор
Контракт принадлежит пакету order, потому что он описывает потребность use case. postgres реализует его неявно методом с совпадающей сигнатурой, при этом домен не импортирует PostgreSQL. Composition root может передать *postgres.OrderRepo в конструктор order.Service.
Код-ревью
2. Сузь потребительский интерфейс
Сервису нужен только ByEmail, но интерфейс объявлен в storage и требует шесть методов. Предложи интерфейс на стороне потребителя и объясни практический эффект.
type UserRepository interface {
Create(context.Context, *User) error
Update(context.Context, *User) error
Delete(context.Context, ID) error
ByID(context.Context, ID) (*User, error)
ByEmail(context.Context, string) (*User, error)
List(context.Context, Filter) ([]*User, error)
}
Ответ и разбор
Объяви в auth маленький unexported интерфейс с одним методом ByEmail(context.Context, string) (*domain.User, error) и принимай его в NewService. Реализация storage всё равно подойдёт структурно. Это уменьшает сцепление и позволяет тесту подставить только нужное поведение, не превращая интерфейс в полный CRUD-контракт.
Применение
3. Выбери, нужна ли абстракция
Пакет invoice имеет один SQL repository, не мокается, а unit-тесты проверяют чистые функции расчёта. Команда предлагает добавить Repository интерфейс из 14 методов "на будущее". Оставить или добавить?
Ответ и разбор
Не добавляй такой интерфейс сейчас: нет второй реализации и нет нужного test seam, а потребитель получит лишний контракт. Если use case начнёт зависеть от конкретной БД и появится реальная граница для теста или замены, объяви у потребителя узкий интерфейс только с используемыми методами.
Применение
4. Найди цикл импорта
Пакет domain импортирует postgres для чтения, а postgres импортирует domain ради типов. Какое направление нарушено и как разорвать цикл, не перенося всё в package common?
Ответ и разбор
Домен не должен импортировать адаптер БД. Оставь модели/контракт в доменном пакете, реализацию postgres направь на импорт домена, а main/composition root импортирует оба и соединяет их. Если общая структура принадлежит transport/storage, перемести DTO к её владельцу, не создавая свалочный common пакет.