← назад к разделу

В большинстве фреймворков — Spring, NestJS, FastAPI — зависимости между компонентами расставляет контейнер: вы объявляете, что нужно, а фреймворк сам находит и подставляет. В Go всего этого нет. Зависимости создаются явно и передаются вручную. Поначалу кажется, что чего-то не хватает — на деле же весь граф зависимостей оказывается в одном файле, читается как обычный код и проверяется компилятором.

Как принято раскладывать файлы

В Go нет единого обязательного стандарта структуры проекта, но сообщество выработало устойчивую конвенцию вокруг двух папок: cmd/ и internal/.

cmd/
  server/
    main.go        — точка входа, здесь собирается граф зависимостей
internal/
  product/
    handler.go     — логика сценария
    repository.go  — доступ к данным
    routes.go      — регистрация маршрутов
  platform/
    config.go      — конфигурация
    httpserver.go  — настройка HTTP-сервера

cmd/<имя>/main.go — исполняемый файл приложения. Если в проекте несколько программ (сервер, воркер, CLI-утилита), каждая получает свой подкаталог в cmd/.

internal/ — закрытый код модуля. Go-компилятор гарантирует: пакет из internal/ нельзя импортировать из другого модуля. Это защита от случайного переиспользования кода, который не рассчитан на внешних потребителей.

Пакеты внутри internal/ организуют по предметным областям, а не по техническим слоям. Всё про продукт — в одном пакете product, всё про заказ — в order. Слой «репозиторий» и слой «обработчик» живут рядом, в одной папке домена.

Почему нет DI-контейнера

В Java есть Spring-контейнер, в Python — pytest-fixtures или специальные либо, в Node.js — NestJS с декораторами. Все они делают одно: находят зависимости и «магически» их подставляют, опираясь на метаданные (аннотации, рефлексию).

Go пошёл в другую сторону намеренно. Язык не имеет аннотаций, рефлексия работает в рантайме и от неё стараются держаться подальше. Зависимость в Go — это просто поле структуры. Передать её — значит вызвать функцию-конструктор с нужным аргументом. Никакой магии, только обычный код.

Конструкторы вместо @Autowired

Каждый компонент объявляет свои зависимости через функцию 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. Это единственное место, где все компоненты знают друг о друге.

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() — явное закрытие ресурса при завершении.

Весь граф виден в одном месте. Нет рантайм-ошибок про «не нашёл бин» — если передать не то, код не скомпилируется.

Конфигурация из переменных окружения

Go-сервисы читают настройки из переменных окружения — это стандарт для контейнеризованных приложений. Важное правило: отсутствие обязательной переменной должно немедленно останавливать процесс, а не всплывать позже в виде непонятной ошибки.

type Config struct {
    Addr        string
    DatabaseURL string
    JWTSecret   string
}

func loadConfig() Config {
    return Config{
        Addr:        getenv("ADDR", ":8080"),
        DatabaseURL: mustEnv("DATABASE_URL"),
        JWTSecret:   mustEnv("JWT_SECRET"),
    }
}

func mustEnv(key string) string {
    v, ok := os.LookupEnv(key)
    if !ok {
        log.Fatalf("missing required env %s", key)
    }
    return v
}

func getenv(key, fallback string) string {
    if v := os.Getenv(key); v != "" {
        return v
    }
    return fallback
}

mustEnv — для обязательных переменных: нет переменной — процесс падает с понятным сообщением. getenv — для опциональных с разумным значением по умолчанию.

Для конфигураций из множества полей вместо ручных os.LookupEnv берут библиотеку вроде envconfig — она читает переменные окружения сразу в структуру через тэги. Принцип тот же: типизированный конфиг, проверенный при старте.

Что насчёт сложных проектов

Когда зависимостей много, main становится длинным — это нормально. Длинный main — это не проблема, а документация: вы видите, что от чего зависит, без погружения в исходники компонентов.

Если очень хочется автоматизировать сборку графа, есть инструмент wire от Google — генератор кода, который по описанию провайдеров создаёт main-подобный файл. Но для большинства сервисов ручная проводка проще и понятнее.

Коротко

  • Структура проекта: cmd/<имя>/main.go — исполняемый файл, internal/ — закрытый код модуля, пакеты по доменам, не по слоям.
  • DI-контейнера нет: зависимости передаются через конструкторы NewX, всё явно.
  • Граф зависимостей собирается в main сверху вниз; весь граф виден в одном месте.
  • Ошибка в зависимостях — ошибка компиляции, а не рантайм-сюрприз.
  • Конфигурация из переменных окружения; отсутствие обязательной переменной роняет процесс сразу.
  • mustEnv для обязательных, getenv с дефолтом для опциональных.

Что почитать дальше

  • Роутинг: net/http и chi — как на этот каркас встаёт HTTP-роутер.
  • Тестирование в Go — как конструкторы через интерфейсы упрощают замену зависимостей в тестах.