Главное отличие Go от Spring, NestJS и FastAPI — отсутствие DI-контейнера. Зависимости здесь не внедряются магией: граф собирается руками в main. Поначалу это кажется шагом назад, но на деле даёт то, чего магия не даёт, — весь граф зависимостей виден в одном месте и проверяется компилятором.
Раскладка проекта
В Go-сообществе устоялась раскладка вокруг cmd/ и internal/.
cmd/
server/
main.go — точка входа, проводка
internal/
product/
handler.go — UseCase/Handler сценария
repository.go — доступ к данным
routes.go — регистрация маршрутов
platform/
config.go — конфигурация
httpserver.go — настройка сервера
cmd/<имя>/main.go — исполняемый бинарь; internal/ — код, который нельзя импортировать извне модуля (компилятор это гарантирует). Пакеты — по доменам (product, order), а не по слоям: всё про продукт лежит вместе. Это та же граница домена, что модуль в Spring-биндинге, только средствами раскладки пакетов.
Конструкторы
Зависимость в Go — это обычная структура, которую создаёт функция-конструктор NewX, принимающая то, что ей нужно.
type ProductRepository struct {
pool *pgxpool.Pool
}
func NewProductRepository(pool *pgxpool.Pool) *ProductRepository {
return &ProductRepository{pool: pool}
}
type CreateProductHandler struct {
repo *ProductRepository
}
func NewCreateProductHandler(repo *ProductRepository) *CreateProductHandler {
return &CreateProductHandler{repo: repo}
}
Никакой регистрации в контейнере: конструктор честно говорит в сигнатуре, от чего зависит. Хочешь подменить зависимость в тесте — передаёшь другую реализацию (через интерфейс), об этом в статье про тестирование.
Проводка в main
Граф собирается явно — сверху вниз, в main. Это сердце «явного» стиля Go.
func main() {
cfg := loadConfig()
pool, err := pgxpool.New(context.Background(), cfg.DatabaseURL)
if err != nil {
log.Fatalf("connect db: %v", err)
}
defer pool.Close()
productRepo := NewProductRepository(pool)
createProduct := NewCreateProductHandler(productRepo)
router := newRouter(createProduct)
if err := http.ListenAndServe(cfg.Addr, router); err != nil {
log.Fatal(err)
}
}
Весь граф — перед глазами: что от чего зависит, в каком порядке создаётся, что закрывается (defer pool.Close()). Нет рефлексии, нет «магического» автосвязывания, нет сюрпризов в рантайме — если зависимость не передана, код не скомпилируется.
Конфигурация из окружения
Настройки читают из переменных окружения и валидируют на старте — отсутствие обязательной переменной должно ронять процесс сразу, а не всплывать в середине запроса.
type Config struct {
Addr string
DatabaseURL string
JWTSecret string
}
func loadConfig() Config {
cfg := Config{
Addr: getenv("ADDR", ":8080"),
DatabaseURL: mustEnv("DATABASE_URL"),
JWTSecret: mustEnv("JWT_SECRET"),
}
return cfg
}
func mustEnv(key string) string {
v, ok := os.LookupEnv(key)
if !ok {
log.Fatalf("missing required env %s", key)
}
return v
}
Профили окружения задаются не кодом, а тем, какие переменные поданы; для больших конфигов берут библиотеку вроде envconfig, но принцип тот же — типизированный конфиг, проверенный на старте.
Где это в UCP
Явная проводка — не примитивность, а осознанный выбор Go: граф зависимостей становится обычным кодом, который читается и проверяется компилятором, а не конфигурацией контейнера. Слои UCP — контроллер, Handler, репозиторий — связываются конструкторами в main, домен и сценарий остаются чистыми. Там, где Spring прячет связывание в контейнере, Go показывает его целиком — и продукт-инженеру не нужно держать в голове, что и как контейнер свяжет в рантайме. На этот каркас встают роутинг и весь конвейер запроса.