Архитектура и продакшн-код на Go
Go подталкивает к простоте, и это распространяется на архитектуру. Здесь не любят тяжёлые фреймворки, магические аннотации и контейнеры зависимостей с рефлексией. Идиоматичная архитектура на Go — это явные зависимости, маленькие интерфейсы и обычный код, который можно прочитать сверху вниз. В этой главе соберём каркас сервиса, который не стыдно вывести в прод.
Слои и гексагональная архитектура — по-гошному
Классическая идея: бизнес-логика не должна знать про детали — про то, PostgreSQL там или MySQL, HTTP или gRPC на входе. Это гексагональная архитектура (она же «порты и адаптеры»), она же в упрощённом виде — чистая/слоистая архитектура. В Go её реализуют без церемоний: через пакеты и интерфейсы.
(адаптер входа)"] --> SVC SVC["Сервис / бизнес-логика
(ядро, ничего не знает о деталях)"] --> PORT{{"Порт: интерфейс Repository"}} PORT --> PG["PostgreSQL адаптер
(адаптер выхода)"] PORT --> MEM["In-memory адаптер
(для тестов)"] SVC --> DOMAIN["Доменные типы
User, Order..."]
Три ключевых слоя и правило зависимостей — всё указывает внутрь, к домену:
- Домен (
internal/domain) — типы и правила предметной области. Не импортирует ничего инфраструктурного. - Сервис/юзкейсы (
internal/service) — оркестрация бизнес-логики. Зависит от домена и от интерфейсов (портов), а не от конкретных БД/транспорта. - Адаптеры (
internal/http,internal/storage) — реализуют порты и переводят внешний мир (HTTP-запрос, строка БД) в доменные термины.
Ключевой приём — интерфейс объявляет тот, кто его использует (сервис), а реализует адаптер. Так стрелка зависимости направлена от инфраструктуры к ядру, а не наоборот.
// internal/service/user.go — ПОРТ объявлен здесь, рядом с потребителем
package service
type UserRepository interface { // маленький интерфейс — только нужное
ByID(ctx context.Context, id int64) (domain.User, error)
Save(ctx context.Context, u domain.User) error
}
type UserService struct {
repo UserRepository // зависимость — интерфейс, не конкретный тип
log *slog.Logger
}
func NewUserService(repo UserRepository, log *slog.Logger) *UserService {
return &UserService{repo: repo, log: log}
}
func (s *UserService) Rename(ctx context.Context, id int64, name string) error {
u, err := s.repo.ByID(ctx, id)
if err != nil {
return fmt.Errorf("Rename: %w", err)
}
if err := u.SetName(name); err != nil { // бизнес-правило живёт в домене
return fmt.Errorf("Rename: %w", err)
}
return s.repo.Save(ctx, u)
}
Не переусердствуйте. Для маленького сервиса три пакета — уже архитектура. Плодить слои «на всякий случай» — тоже антипаттерн; в Go ценят соразмерность.
Внедрение зависимостей без фреймворка
В Go DI — это просто передача зависимостей в конструкторы. Никаких контейнеров, аннотаций и магии по умолчанию не нужно. Всё дерево зависимостей собирается вручную в одном месте — в main. Это называют «composition root».
// cmd/server/main.go — здесь и только здесь всё связывается воедино
func main() {
cfg := config.Load()
log := slog.New(slog.NewJSONHandler(os.Stdout, nil))
pool, err := pgxpool.New(context.Background(), cfg.DatabaseURL)
if err != nil {
log.Error("подключение к БД", "err", err)
os.Exit(1)
}
defer pool.Close()
// Снизу вверх: репозиторий -> сервис -> хендлеры -> роутер -> сервер
userRepo := storage.NewUserRepo(pool)
userSvc := service.NewUserService(userRepo, log)
handler := httpapi.NewHandler(userSvc, log)
srv := &http.Server{Addr: cfg.Addr, Handler: handler.Router()}
// ... graceful shutdown из главы про конкурентность
}
Плюсы явного DI: видно всё дерево зависимостей, легко подменить реализацию в тестах, нет рантайм-сюрпризов. Когда граф становится огромным, есть google/wire — он генерирует код связывания на этапе компиляции (не рефлексия!). Но большинству сервисов хватает ручной сборки — начинайте с неё.
Конфигурация через окружение
Стандарт индустрии — 12-Factor App: конфигурация приходит из переменных окружения, а не из файлов в репозитории. Секреты — тем более (их подкладывает секрет-менеджер). Идиома — распарсить env один раз при старте в типизированную структуру и провалиться на старте, если чего-то не хватает («fail fast»).
package config
type Config struct {
Addr string
DatabaseURL string
LogLevel string
Timeout time.Duration
}
func Load() Config {
return Config{
Addr: getEnv("APP_ADDR", ":8080"),
DatabaseURL: mustEnv("DATABASE_URL"), // упадём, если не задано
LogLevel: getEnv("LOG_LEVEL", "info"),
Timeout: getDuration("HTTP_TIMEOUT", 30*time.Second),
}
}
func mustEnv(key string) string {
v, ok := os.LookupEnv(key)
if !ok {
log.Fatalf("обязательная переменная %s не задана", key)
}
return v
}
Популярные библиотеки, если ручного парсинга мало: caarlos0/env (маппинг env в структуру по тегам), spf13/viper (мощно, но тяжело — для CLI с флагами и файлами). Для сервиса чаще достаточно env или пары строк своего кода. Никогда не коммитьте секреты; для локальной разработки — .env в .gitignore и godotenv.
HTTP: net/http и роутеры
net/http из стандартной библиотеки — полноценный продакшн-сервер, на нём одном можно писать сервисы. С Go 1.22 встроенный http.ServeMux научился методам и path-параметрам в паттернах — теперь для многих задач роутер вообще не нужен:
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") // path-параметр из паттерна (Go 1.22+)
// ...
})
mux.HandleFunc("POST /users", createUser)
Когда нужны богатые middleware, группы маршрутов и удобства — берут сторонний роутер. Trade-offs трёх популярных:
- chi — тонкий, идиоматичный, на 100% совместим с
net/http(хендлеры — обычныеhttp.Handler). Отличные middleware, роутинг-группы. Лучший выбор, когда хотите «стандартный Go, только удобнее». Рекомендуется по умолчанию. - echo — фреймворк с собственным
Context, встроенными байндингом/валидацией/рендерингом. Быстрее пишется CRUD, но вы уходите от стандартных интерфейсов. - gin — самый популярный, очень быстрый, большая экосистема, свой
gin.Context. Минус тот же — неnet/http-нативный, и API местами «магический».
Совет: начинайте со стандартного net/http + chi. К echo/gin переходите, только если их удобства реально экономят время команды. Своя абстракция поверх http.Handler почти всегда лучше вендор-лока.
Хендлер, отделённый от бизнес-логики, — это тонкий адаптер: распарсить запрос, вызвать сервис, отдать ответ.
func (h *Handler) getUser(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
u, err := h.svc.ByID(r.Context(), id) // прокидываем context запроса!
switch {
case errors.Is(err, service.ErrNotFound):
http.Error(w, "not found", http.StatusNotFound)
return
case err != nil:
h.log.Error("getUser", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(u)
}
Всегда пробрасывайте r.Context() вниз — так отмена запроса дойдёт до БД (см. конкурентность). И всегда ставьте таймауты на http.Server (ReadTimeout, WriteTimeout, IdleTimeout) — сервер без таймаутов уязвим к медленным клиентам.
База данных: database/sql, pgx и sqlc
Стандартный интерфейс — пакет database/sql: драйверо-независимый, с пулом соединений. Для PostgreSQL стандарт де-факто — драйвер pgx, причём его нативный API (pgxpool) быстрее и богаче, чем через database/sql.
pool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
// ...
var u domain.User
err = pool.QueryRow(ctx,
`SELECT id, name, email FROM users WHERE id = $1`, id,
).Scan(&u.ID, &u.Name, &u.Email)
if errors.Is(err, pgx.ErrNoRows) {
return domain.User{}, service.ErrNotFound // переводим в доменную ошибку
}
Про ORM: в Go к тяжёлым ORM (gorm) относятся с прохладцей — они прячут SQL и генерируют неожиданные запросы. Идиоматичнее — писать SQL явно. Здесь блистает sqlc: вы пишете обычный SQL, а sqlc генерирует типобезопасный Go-код (структуры и методы) на этапе сборки. Никакой рефлексии в рантайме, полный контроль над запросами, компилятор проверяет типы.
-- query.sql
-- name: GetUser :one
SELECT id, name, email FROM users WHERE id = $1;
sqlc generate # получаем db.GetUser(ctx, id) (User, error) — типизированно
Практические правила работы с БД:
- Всегда
QueryContext/QueryRowContextс контекстом — для таймаутов и отмены. - Настраивайте пул:
SetMaxOpenConns,SetMaxIdleConns,SetConnMaxLifetime(или параметрыpgxpool). Дефолты не годятся для прода. - Транзакции — через
BeginTx, сdefer tx.Rollback()(rollback после commit — no-op, безопасно). - Миграции — отдельным инструментом: golang-migrate, goose или atlas. Версионируйте схему в
migrations/, накатывайте отдельным шагом деплоя, а не «на старте приложения втихую». - Всегда параметризованные запросы (
$1,?) — никогда конкатенация строк (SQL-инъекции).
Устойчивость: таймауты, ретраи, circuit breaker
Прод — это сеть, которая падает. Паттерны устойчивости:
- Таймауты везде. Каждый внешний вызов — под
context.WithTimeout. Отсутствие таймаута — самая частая причина каскадных отказов. - Ретраи с экспоненциальной задержкой и джиттером — только для идемпотентных операций и временных ошибок. Ретраить неидемпотентный POST вслепую — путь к дублям.
var lastErr error for attempt := 0; attempt < 3; attempt++ { if err := call(ctx); err == nil { return nil } else { lastErr = err } select { case <-time.After(backoff(attempt)): // задержка растёт: 100ms, 200ms, 400ms + джиттер case <-ctx.Done(): return ctx.Err() } } return lastErr - Circuit breaker — перестать долбить упавший сервис, дав ему восстановиться (sony/gobreaker).
- Rate limiting —
golang.org/x/time/rateдля ограничения нагрузки на себя и на зависимости. - Graceful shutdown — из главы про конкурентность, обязательно.
Структурное логирование: log/slog
С Go 1.21 в стандартной библиотеке есть log/slog — структурное логирование. В проде логи должны быть машиночитаемыми (JSON) с полями, а не строками — чтобы их индексировать и искать.
log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(log)
log.Info("пользователь создан",
slog.Int64("user_id", u.ID),
slog.String("email", u.Email),
)
// {"time":"...","level":"INFO","msg":"пользователь создан","user_id":42,"email":"..."}
// Логгер с постоянными полями — прокидывайте по цепочке запроса
reqLog := log.With(slog.String("request_id", reqID))
slog заменяет старые logrus/zap для новых проектов (хотя zap всё ещё быстрее в горячих путях). Правила: не логируйте секреты и PII, используйте уровни осмысленно (Error — то, что будит дежурного), прокидывайте request_id/trace_id во все логи запроса. Подробнее про наблюдаемость — в следующей главе.
Итог: как выглядит здоровый сервис
- Тонкие адаптеры (HTTP/БД) вокруг ядра с бизнес-логикой.
- Зависимости — интерфейсы, объявленные потребителем; сборка дерева в
main. - Конфиг из env, fail-fast на старте.
net/http+chi, контекст пробрасывается до БД, таймауты на сервере и запросах.- Явный SQL через pgx/sqlc, миграции отдельным шагом.
- Устойчивость: таймауты, разумные ретраи, circuit breaker.
- Структурные JSON-логи через
slog.
Источники
- Standard Go Project Layout (с оговоркой — не официальный).
- 12 Factor App, slog docs, sqlc, pgx.
- Ardan Labs и Dave Cheney — про прод-практики и API-дизайн.
- Доклад Мэта Райера How I write HTTP services in Go — эталон организации HTTP-сервиса.
Что дальше
Сервис написан и структурирован. Осталось собрать его в артефакт, упаковать в контейнер и сделать наблюдаемым в проде.