Главное отличие 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 показывает его целиком — и продукт-инженеру не нужно держать в голове, что и как контейнер свяжет в рантайме. На этот каркас встают роутинг и весь конвейер запроса.