Справочник

Архитектура — справочник

Пакет как модуль, раскладка проекта, интерфейсы на стороне потребителя, зависимости и 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-серверmainShutdown(ctx)
фоновый воркервладелец воркераctx и Wait
обработчик запросасам обработчикдождаться или отменить через ctx
библиотечная функциявызывающийвернуть stop/cancel или быть синхронной

9. Тестовые швы

Шов — место, где зависимость можно подменить. В Go это маленький интерфейс на стороне потребителя, httptest для транспорта и настоящая база для адаптера. Моки на всё подряд делают тесты хрупкими и не проверяют SQL.

Что тестируемЧем
правила доменаобычные тесты, без зависимостей
сервисфейковый Repository в памяти
HTTPhttptest.NewRecorder или NewServer
SQL-адаптернастоящий Postgres, транзакция с откатом
внешний HTTPhttptest.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. Первоисточники