Архитектура 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. Все остальные, частный случай.
.
├── 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-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.