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

GO КАК ФУНДАМЕНТ Структура проекта

Структура проекта и проводка зависимостей в Go

Go

Пакеты, конструкторы, main и конфигурация: как собрать сервис без DI-контейнера и сохранить явные зависимости.

1. Пакеты отвечают за свою область

1 / 5

Код раскладывают по назначению: точка входа, обработчики, домен, хранилище. Пакет видит зависимости явно через import.

  • cmd/app собирает исполняемый файл.
  • internal закрывает код от внешних импортов.
  • Маленькие пакеты проще читать и тестировать.
cmd/apphandlersdomainstore

2. DI-контейнер не нужен

2 / 5

Зависимость передаётся параметром конструктора. Это видно в сигнатуре и не требует магии регистрации.

  • Конструктор принимает интерфейсы и конкретную конфигурацию.
  • Тест передаёт подмену тем же способом.
  • Скрытые глобальные синглтоны мешают изоляции.
NewHandler(store)handlerЗависимость видна в сигнатуре

3. Main собирает граф явно

3 / 5

Точка входа читает конфигурацию, создаёт соединения и передаёт их обработчикам. Порядок создания и закрытия виден.

  • Инициализация и graceful shutdown живут рядом.
  • Ресурсы закрываются через defer.
  • Слой main не содержит бизнес-правил.
configdbstoreservercreate → wire → run → close

4. Конфигурация приходит из окружения

4 / 5

Порт, адрес базы и внешние ключи передаются через env. Значения проверяются при старте, а не при первом запросе.

  • Обязательные настройки не имеют молчаливых дефолтов.
  • Секреты не попадают в исходники.
  • Невалидная конфигурация останавливает запуск с ясной ошибкой.
envvalidaterunОшибка видна сразу при старте

5. Явная проводка масштабируется спокойно

5 / 5

Когда проект растёт, конструкторы группируют по пакетам, но зависимости остаются видимыми. Проверка графа не требует контейнера.

  • Меняем реализацию интерфейса в одном месте.
  • Тестируем обработчик с подменой хранилища.
  • Проверяем main как сборку, а домен отдельно.
handlerPostgresMocktests

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

В большинстве фреймворков — 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 — Depends у FastAPI или отдельные библиотеки, в 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() — явное закрытие ресурса при завершении. Оговорка: log.Fatal завершает процесс через os.Exit, и отложенные вызовы при этом не выполняются, поэтому в реальном сервисе main обычно вызывает run() error, а defer живут внутри run — так сделано в статье про конкурентность и остановку.

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

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

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 с дефолтом для опциональных.

Что пощупать

Учебный каталог практикума remodov/marketplace-system-go собран как обычный Go-сервис: main.go читает настройки, накатывает миграции, собирает пул, репозиторий, сервис и роутер, а всё остальное лежит в internal и снаружи модуля недоступно.

Код: catalog-starter, main.go.

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