В большинстве фреймворков — 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 — как конструкторы через интерфейсы упрощают замену зависимостей в тестах.