Архитектура — справочник
Пакет как модуль, раскладка проекта, интерфейсы на стороне потребителя, зависимости и DI без фреймворка, слои, ошибки как контракт, конфигурация, жизненный цикл и тестовые швы.
Собрано из Organizing a Go module, Package names, Go Code Review Comments и Effective Go. Примеры проверены go build и go vet.
1. Пакет как модуль
Единица проектирования в Go — пакет, а не файл и не класс. Пакет скрывает решение за экспортированным API: снаружи видно только имена с заглавной буквы, остальное можно менять. Хороший пакет — глубокая вещь: маленький интерфейс, за которым много работы.
Имя пакета — часть клиентского кода: http.Server, time.Duration. Оно должно быть коротким, в нижнем регистре, без подчёркиваний и mixedCaps, обычно существительным. Имя не повторяют в именах содержимого: в пакете http тип называется Server, а не HTTPServer.
| Правило | Почему |
|---|---|
| короткое имя в нижнем регистре | bufio, http, time |
без util, common, misc | такое имя не говорит, что внутри, и тянет всё подряд |
| не красть имена клиента | bufio, а не buf: buf — хорошее имя переменной |
| не повторять имя пакета | http.Server, а не http.HTTPServer |
| аббревиатуры в одном регистре | ServeHTTP, appID, не ServeHttp |
Экспортированное — контракт. Чем меньше экспортированных имён, тем свободнее рефакторинг. Если тип нужен только для реализации интерфейса, его не экспортируют.
2. Раскладка проекта
Официальная рекомендация для серверного проекта: логика — в internal, команды — в cmd. Пакеты в internal нельзя импортировать из других модулей, поэтому их API можно менять без оглядки на внешних пользователей.
project-root/
go.mod
internal/
user/
user.go // домен: типы и ошибки
service.go // правила и интерфейс хранилища
postgres/
repo.go // адаптер Postgres
httpapi/
handler.go // транспорт
cmd/
api/
main.go // сборка зависимостей
migrations/
go.sum
| Каталог | Что там | Кто импортирует |
|---|---|---|
internal/ | вся логика сервиса | только этот модуль |
cmd/api/ | package main, wiring | никто; это точка входа |
pkg/ | код, который отдают наружу | другие модули; нужен редко |
migrations/ | SQL-файлы схемы | не Go-код |
Дословно из документации: «Server projects typically won't have packages for export… it's recommended to keep the Go packages implementing the server's logic in the internal directory… keep all Go commands together in a cmd directory». pkg/ — соглашение, а не требование; для сервиса он обычно не нужен.
Пакет не должен быть свалкой по типу файла (models, handlers, services). Деление идёт по скрываемому решению: user знает про пользователей, httpapi — про HTTP, user/postgres — про SQL.
3. Интерфейсы на стороне потребителя
Интерфейс объявляют там, где он используется, а не там, где реализуется. Пакет-реализация возвращает конкретный тип; потребитель описывает, что ему нужно, своим маленьким интерфейсом. Тогда новый метод реализации не ломает контракт.
Дословно из Go Code Review Comments: «Go interfaces generally belong in the package that uses values of the interface type, not the package that implements those values. The implementing package should return concrete (usually pointer or struct) types». И далее: «Do not define interfaces before they are used».
// internal/user/service.go
package user
import "context"
// Repository описывает то, что нужно сервису, а не то, что умеет Postgres.
type Repository interface {
Get(ctx context.Context, id int64) (User, error)
Save(ctx context.Context, u User) error
}
type Service struct {
repo Repository
}
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}
Чем меньше методов в интерфейсе, тем легче подделка и тем меньше поводов её менять. io.Reader с одним методом реализуют файл, сокет, строка, буфер и HTTP-тело; интерфейс на десять методов не реализует почти никто.
| Где объявлен | Что получается |
|---|---|
| в пакете-потребителе | контракт по потребности; реализация подстраивается |
| в пакете-реализации | реализация диктует контракт; потребитель зависит от лишнего |
| заранее, «на будущее» | интерфейс без второго потребителя и без причины |
4. Зависимости и DI без фреймворка
Зависимости передают явно: параметром конструктора или полем структуры. Собирают их в main, где видно весь граф. Глобальные переменные и init с побочными эффектами прячут зависимости и мешают тестам.
func main() {
db, err := sql.Open("pgx", os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatal(err)
}
defer db.Close()
repo := postgres.New(db)
svc := user.NewService(repo)
api := httpapi.NewHandler(svc)
mux := http.NewServeMux()
mux.HandleFunc("PATCH /users/{id}", api.Rename)
}
| Приём | Когда |
|---|---|
конструктор NewXxx(dep) | зависимость обязательна |
| поле структуры | зависимость нужна методам |
| параметр метода | зависимость нужна одному вызову |
| функциональные опции | много необязательных настроек |
| глобальная переменная | почти никогда: скрытая связь и проблема в тестах |
DI-фреймворк не обязателен: для сервиса из десяти компонентов ручная сборка в main читается лучше и не требует рефлексии. Фреймворк оправдан, когда граф действительно большой и генерируется.
5. Слои и направление зависимостей
Зависимости идут внутрь: транспорт знает про домен, домен не знает про транспорт и базу. Домен описывает интерфейс хранилища, адаптер его реализует, main соединяет.
| Слой | Знает про | Не знает про |
|---|---|---|
transport (httpapi) | HTTP, JSON, домен | SQL, драйвер базы |
domain (user) | правила и типы предметной области | HTTP, SQL, конкретную базу |
adapter (user/postgres) | домен, SQL, драйвер | HTTP |
composition (cmd/api) | все слои | — |
// internal/user/postgres/repo.go
package postgres
import (
"context"
"database/sql"
"errors"
"example.com/app/internal/user"
)
type Repo struct {
db *sql.DB
}
func New(db *sql.DB) *Repo {
return &Repo{db: db}
}
func (r *Repo) Get(ctx context.Context, id int64) (user.User, error) {
var u user.User
err := r.db.QueryRowContext(ctx,
`select id, name from users where id = $1`, id).
Scan(&u.ID, &u.Name)
if errors.Is(err, sql.ErrNoRows) {
return user.User{}, user.ErrNotFound
}
return u, err
}
Цикл импортов компилятор запрещает. Если два пакета начинают ссылаться друг на друга, это сигнал: либо они один модуль, либо интерфейс объявлен не на той стороне, либо в пакете смешаны две ответственности.
6. Ошибки как контракт
Ошибка домена — часть API. Транспорт переводит её в статус, адаптер переводит ошибку базы в ошибку домена. Наружу не уходят тексты драйвера, имена таблиц и стек.
// internal/user/user.go
package user
import "errors"
var (
ErrNotFound = errors.New("user not found")
ErrInvalidName = errors.New("invalid name")
)
// internal/httpapi/handler.go
func (h *Handler) Rename(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id")
return
}
var req struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json")
return
}
if err := h.users.Rename(r.Context(), id, req.Name); err != nil {
switch {
case errors.Is(err, user.ErrNotFound):
writeError(w, http.StatusNotFound, "not_found")
case errors.Is(err, user.ErrInvalidName):
writeError(w, http.StatusBadRequest, "invalid_name")
default:
slog.Error("rename", "err", err)
writeError(w, http.StatusInternalServerError, "internal")
}
return
}
w.WriteHeader(http.StatusNoContent)
}
| Правило | Почему |
|---|---|
оборачивать с контекстом %w | цепочка сохраняет исходную ошибку для Is/As |
| текст ошибки — строчными, без точки | её печатают после другого контекста |
| переводить на границе слоя | домен не зависит от драйвера |
| не логировать и не возвращать одну ошибку дважды | дубли в логе и потеря статуса |
panic — только для программных дефектов | ожидаемые ошибки возвращают через error |
7. Конфигурация и жизненный цикл
Конфигурация — структура с значениями по умолчанию, которую заполняют из окружения и флагов и проверяют один раз на старте. Дальше по коду передаётся готовая структура, а не os.Getenv в каждом месте.
type Config struct {
Addr string
DatabaseURL string
ShutdownTimeout time.Duration
}
func Load() (Config, error) {
cfg := Config{
Addr: envOr("ADDR", ":8080"),
DatabaseURL: os.Getenv("DATABASE_URL"),
ShutdownTimeout: 10 * time.Second,
}
if cfg.DatabaseURL == "" {
return Config{}, errors.New("DATABASE_URL is required")
}
return cfg, nil
}
Жизненный цикл: main собирает зависимости, запускает run(ctx), ждёт сигнал, останавливает сервер. os.Exit и log.Fatal допустимы только в main: они пропускают defer.
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := run(ctx); err != nil {
log.Fatal(err)
}
}
func run(ctx context.Context) error {
cfg, err := Load()
if err != nil {
return err
}
db, err := sql.Open("pgx", cfg.DatabaseURL)
if err != nil {
return err
}
defer db.Close()
// сборка и запуск сервера
return serve(ctx, cfg, db)
}
8. Владение горутинами
Кто запустил горутину, тот отвечает за её остановку. Функция не должна оставлять горутину, которая переживёт вызов, если это не описано в контракте. Синхронная функция с возвратом результата проще для вызывающего и для тестов.
Дословно из Go Code Review Comments: «Prefer synchronous functions… Synchronous functions keep goroutines localized within a call, making it easier to reason about their lifetimes and avoid leaks and data races».
| Кто запускает | Кто останавливает | Как |
|---|---|---|
| HTTP-сервер | main | Shutdown(ctx) |
| фоновый воркер | владелец воркера | ctx и Wait |
| обработчик запроса | сам обработчик | дождаться или отменить через ctx |
| библиотечная функция | вызывающий | вернуть stop/cancel или быть синхронной |
9. Тестовые швы
Шов — место, где зависимость можно подменить. В Go это маленький интерфейс на стороне потребителя, httptest для транспорта и настоящая база для адаптера. Моки на всё подряд делают тесты хрупкими и не проверяют SQL.
| Что тестируем | Чем |
|---|---|
| правила домена | обычные тесты, без зависимостей |
| сервис | фейковый Repository в памяти |
| HTTP | httptest.NewRecorder или NewServer |
| SQL-адаптер | настоящий Postgres, транзакция с откатом |
| внешний HTTP | httptest.NewServer вместо реального хоста |
type fakeRepo struct {
users map[int64]user.User
}
func (f *fakeRepo) Get(ctx context.Context, id int64) (user.User, error) {
u, ok := f.users[id]
if !ok {
return user.User{}, user.ErrNotFound
}
return u, nil
}
func (f *fakeRepo) Save(ctx context.Context, u user.User) error {
f.users[u.ID] = u
return nil
}
Фейк проверяет поведение сервиса, но не SQL. Ошибки в запросах, типах и ограничениях ловит только тест на реальной базе — это разные уровни, и один не заменяет другой.
10. Антипаттерны
| Симптом | Что происходит | Что делать |
|---|---|---|
util, common, helpers | пакет без темы, в него стекается всё | разложить по решаемой задаче |
models со всеми структурами | домен, DTO и строки БД в одном месте | держать тип рядом с поведением |
| интерфейс на десять методов | реализуют один-два типа, тесты громоздкие | разбить на маленькие по потребителю |
| интерфейс в пакете-реализации | потребитель зависит от лишнего | объявить интерфейс у потребителя |
глобальный конфиг и init с побочными эффектами | скрытые зависимости, тесты зависят от порядка | передавать явно, собирать в main |
panic на ожидаемой ошибке | сервис падает от плохого ввода | вернуть error |
| ошибка базы наружу | утечка схемы и текста драйвера | перевести в ошибку домена |
| горутина без владельца | утечка и гонки при завершении | ctx, Wait, явный stop |
| цикл импортов | компилятор не собирает пакет | вынести общий тип или развернуть интерфейс |
11. Первоисточники
- Organizing a Go module —
internal,cmd, серверный проект - Package names — имена пакетов и содержимого
- Go Code Review Comments — интерфейсы, ошибки, синхронные функции, инициализмы
- Effective Go — интерфейсы, встраивание, ошибки
- Error handling and Go — ошибки как значения
- Working with Errors in Go 1.13 —
%w,Is,As - pkg.go.dev/net/http/httptest — тестовый транспорт