Раздел 22 · Go

Архитектура pet-проекта: hex без фанатизма, Mat Ryer, DI

middle-senior~45 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Архитектура pet-проекта: hex без фанатизма, Mat Ryer, DI

Урок про то, как сложить pet-сервис на Go, чтобы через полгода с ним было приятно работать, и при этом не утонуть в спорах про hex/clean/DDD. Считаем, что у тебя уже есть HTTP-сервис на net/http (07-http-services), есть слой persistence (08-persistence-and-sql), и нужна общая структура.

Источники, на которые опираемся: пост Мата Райера 2024-го года про HTTP-сервисы, заметки Бена Джонсона про стандартный layout, обзоры Антона Жижанова, и серия Three Dots Labs про DDD-lite.

1. Главная идея, “domain-per-package”

В Java и Python привычная разбивка по техническим слоям:

api/ services/ repositories/ models/

В Go так делать обычно не стоит. Каждая фича размазывается по 4 пакетам, изменение одного бизнес-правила задевает 4 файла, и нет места, где живёт “всё про пользователя”.

Идиома Go, пакет, это feature/домен, не слой. Пакет user содержит:

  • тип User и связанные ошибки (ErrEmailTaken),
  • бизнес-функции (Register, Activate),
  • HTTP-handler (HandleRegister),
  • интерфейс-порт к store (type Repo interface { ... }),
  • адаптер к Postgres (type pgRepo struct { db *pgx.Pool }).

Файлы внутри пакета разделяет автор: service.go, http.go, pg.go, user.go. Снаружи виден один пакет, и пользоваться им просто:

import "github.com/USERNAME/app/internal/user"

svc := user.NewService(repo)

В виджете ниже сравни 4 варианта и пощёлкай вкладки. По умолчанию взять domain-per-package. Все остальные, частный случай.

Структура pet-проекта на Go
.
├── cmd/server/main.go
├── internal/
│   ├── api/        (handlers, knows services)
│   ├── services/   (business, knows repositories)
│   ├── repositories/ (db access, knows models)
│   └── models/     (anemic structs)
└── pkg/
    └── apperrors/
  • домены размазаны по 4 пакетам, изменение фичи задевает 4 файла
  • тяжело понять, "где живёт user", приходится прыгать по слоям
  • слой `models` обычно становится свалкой DTO
.
├── cmd/server/main.go
└── internal/
    ├── user/        (всё про пользователя)
    │   ├── user.go      (тип, ошибки)
    │   ├── service.go   (бизнес)
    │   ├── http.go      (handler)
    │   ├── repo.go      (interface для store)
    │   └── pg.go        (postgres-адаптер)
    ├── order/
    └── billing/
  • фича = пакет: новый файл редко тащит правки в чужой
  • interface для store объявлен в пакете-потребителе (по идиоме accept-interfaces, return-structs)
  • тесты пакета лежат рядом, видят приватные имена
  • домены без общих типов, иначе появляется shared/, легко превращается в свалку
.
├── cmd/server/
│   ├── main.go        (вызывает InitializeApp)
│   ├── wire.go        (//go:build wireinject, providers)
│   └── wire_gen.go    (сгенерирован, граф ручной DI)
└── internal/
    ├── user/  (NewService(repo))
    ├── order/
    └── billing/
  • DI разрешается в compile-time, лишних зависимостей нет
  • wire_gen.go читается как обычный Go-код, IDE прыгает
  • оправдан, когда конструкторов 20+ и руками монтировать не-вариант
  • плата, ещё один codegen-шаг и навык читать wire.go
.
├── cmd/server/main.go (fx.New(modules...).Run())
└── internal/
    ├── user/
    │   └── module.go    (fx.Module + fx.Lifecycle hooks)
    ├── order/
    │   └── module.go
    └── billing/
        └── module.go
  • graph строится в рантайме, удобно для plugin-подобных модулей
  • fx.Lifecycle закрывает start/stop хуки (БД, фоновые воркеры)
  • runtime-граф, ошибки видно только при старте, не при компиляции
  • stack-traces у panic становятся длиннее на 30+ кадров fx

По умолчанию для pet-проекта возьми domain-per-package. К wire переходи, когда конструкторов 15+ и руками становится больно. fx, только когда нужны жизненные циклы или динамические модули.

2. Hexagonal/Clean без фанатизма

Hex и Clean это, по сути, одна идея на разных диалектах: бизнес не зависит от инфры. Внутрь летят порты (interface), вокруг адаптеры (postgres, kafka, http).

Когда в pet-проекте оправдано:

  • В планах несколько источников данных. Например, репозиторий одновременно в Postgres и в in-memory для тестов. Тогда Repo-интерфейс даёт отделить тест от инфры.
  • Бизнес-логика реально сложная. Если в Service пять условий и три ветки, выносить порты честно.

Когда не оправдано:

  • Сервис из четырёх endpoint-ов и одного запроса в БД. Введение UserRepository интерфейса с одной реализацией это карго-культ. Зови *pgxpool.Pool напрямую, разберёшь по интерфейсам через год, если придётся.
  • Голый CRUD. Hex-граница превращает каждое INSERT в три файла без пользы.

Главная ловушка hex это интерфейсы наперёд. Не пиши UserService интерфейс одновременно с userService структурой, у которой одна реализация. Это анти-идиома Go. Правило: accept interfaces, return structs. Интерфейс заводи в пакете-потребителе, не в пакете-производителе.

// internal/user/service.go (потребитель)
type Repo interface {
    FindByEmail(ctx context.Context, email string) (*User, error)
    Save(ctx context.Context, u *User) error
}

func NewService(r Repo) *Service { return &Service{repo: r} }

// internal/user/pg.go (производитель)
type pgRepo struct{ db *pgxpool.Pool }
func NewPGRepo(db *pgxpool.Pool) *pgRepo { /* возвращает структуру */ }

pgRepo нигде не объявляет себя как user.Repo, но удовлетворяет интерфейсу за счёт структурной типизации. Тест в пакете user пишет свой fake, удовлетворяющий тому же Repo, и работает без testcontainers.

3. Mat Ryer 2024: NewServer и routes.go

Мат Райер с 2018-го выпускает заметку “How I write HTTP services in Go”. В версии 2024-го (после 13 лет) набор устаканился, и он кодифицирован в этой статье. Шаблон рабочий, бери за основу.

1. func main() вызывает func run(ctx, args, env, stdout). Это даёт два бонуса: тестируемость (можно вызвать run из теста), и graceful return (возвращает error, не делает os.Exit).

func main() {
    ctx := context.Background()
    if err := run(ctx, os.Args, os.Environ(), os.Stdout); err != nil {
        fmt.Fprintf(os.Stderr, "%s\n", err)
        os.Exit(1)
    }
}

func run(ctx context.Context, args []string, env []string, stdout io.Writer) error {
    cfg := mustLoadConfig(args, env)
    srv := NewServer(cfg, /* deps */)
    httpSrv := &http.Server{Addr: cfg.Addr, Handler: srv}

    go func() { _ = httpSrv.ListenAndServe() }()

    <-ctx.Done()
    shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    return httpSrv.Shutdown(shutCtx)
}

2. NewServer(deps...) http.Handler возвращает один Handler с уже подключёнными middleware и роутами. Не глобальный mux. Не глобальные переменные.

func NewServer(cfg Config, users *user.Service, billing *billing.Service) http.Handler {
    mux := http.NewServeMux()
    addRoutes(mux, cfg, users, billing)
    var handler http.Handler = mux
    handler = withRequestID(handler)
    handler = withSlog(handler)
    handler = withRecover(handler)
    return handler
}

3. routes.go содержит только регистрацию маршрутов. Один файл, который на ревью даёт обзор всего API:

func addRoutes(mux *http.ServeMux, cfg Config, users *user.Service, billing *billing.Service) {
    mux.Handle("POST /users",      users.HandleRegister(cfg))
    mux.Handle("GET /users/{id}",  users.HandleGet(cfg))
    mux.Handle("POST /charges",    billing.HandleCharge(cfg))
}

4. Handler как метод, возвращающий http.HandlerFunc. Это позволяет сделать “per-handler” зависимости и инициализацию при старте без глобалок:

func (s *Service) HandleRegister(cfg Config) http.HandlerFunc {
    // тут происходит "init на handler", один раз при старте сервера:
    type request struct { Email, Password string }
    type response struct { ID int64 }
    return func(w http.ResponseWriter, r *http.Request) {
        var req request
        if err := decodeJSON(r, &req); err != nil { /* 400 */ return }
        // ...
        encodeJSON(w, response{ID: 7}, http.StatusCreated)
    }
}

5. decode/encode-функции в одном месте. В большом сервисе эти две функции, главные точки совместимости со схемой и логом ошибок:

func decodeJSON[T any](r *http.Request, v *T) error {
    dec := json.NewDecoder(r.Body)
    dec.DisallowUnknownFields()
    return dec.Decode(v)
}
func encodeJSON[T any](w http.ResponseWriter, v T, status int) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    _ = json.NewEncoder(w).Encode(v)
}

6. Тестируешь через httptest.NewRecorder и NewServer. Никакого моно-фреймворка с in-process HTTP-сервером:

func TestRegister_201(t *testing.T) {
    srv := NewServer(testCfg, fakeUsers{}, fakeBilling{})
    req := httptest.NewRequest("POST", "/users", strings.NewReader(`{"email":"v@bat.school","password":"x"}`))
    rr := httptest.NewRecorder()
    srv.ServeHTTP(rr, req)
    require.Equal(t, http.StatusCreated, rr.Code)
}

Этот шаблон закрывает 95% pet-проектов и хорошую часть продакшна. Дальше начинаются специфичные удобства, например, генератор маршрутов из OpenAPI или собственный mux, но базу не трогаешь.

премиум-урок

Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.

Оплата пока не подключена: премиум и курсы открываются промокодом.