Олег Марков
Структура проекта в Go Golang
Введение
Структура проекта в Go — одна из тем, которые почти всегда вызывают вопросы. Формально язык почти ничего не навязывает: вы можете складывать файлы как угодно. Но на практике без четкой структуры становится сложно поддерживать код, подключать новые модули, писать тесты и разворачивать сервисы.
Смотрите, я покажу вам, как можно подойти к организации проекта в Go так, чтобы:
- код было легко читать и дополнять;
- зависимости были понятны;
- новые разработчики быстро вникали;
- проект хорошо масштабировался.
Мы разберем несколько уровней:
- базовая структура Go-модуля;
- типовой «слойный» подход к приложению;
- разделение кода на пакеты;
- организация конфигураций, миграций, утилит;
- практические примеры структуры каталогов.
По ходу статьи я буду давать примеры кода и структуры каталогов, чтобы вы могли сразу представить, как все выглядит в реальном репозитории.
Базовые принципы структуры проекта
Зачем вообще думать о структуре
Если не задумываться о структуре, проект часто растет так:
- в корне модуля копятся десятки файлов;
- в одном пакете смешиваются HTTP-обработчики, работа с БД и бизнес-логика;
- тесты сложно запускать точечно;
- повторное использование кода становится проблемой.
Структура проекта нужна не ради «красоты», а ради трех вещей:
- Понятность — где лежит конкретная логика.
- Изоляция — изменение одного модуля минимально затрагивает остальные.
- Масштабирование — проект можно расширять без полного переписывания.
В Go это особенно важно, потому что:
- пакеты — основная единица переиспользования и изоляции;
- циклические зависимости между пакетами запрещены;
- стиль Go-комьюнити отдает приоритет простым решениям.
Минимальные требования Go
Есть несколько правил, которые задают «скелет»:
- В корне модуля лежит файл go.mod — он определяет модуль.
- Код организуется в пакеты (директории с файлами .go).
- В каждом исполняемом приложении должен быть пакет main с функцией main().
Остальное — договоренности и рекомендации, которые мы сейчас разберем.
Базовая структура Go-проекта
Давайте начнем с минимальной, но уже правильной структуры для небольшого сервиса API.
Пример:
project-structure-example
├── cmd
│ └── api
│ └── main.go
├── internal
│ ├── http
│ │ ├── handlers
│ │ │ └── user.go
│ │ └── router.go
│ ├── domain
│ │ └── user.go
│ ├── service
│ │ └── userservice.go
│ └── storage
│ └── userrepo.go
├── pkg
│ └── logger
│ └── logger.go
├── configs
│ └── config.yaml
├── migrations
│ └── 001_init.sql
├── go.mod
└── go.sum
Теперь давайте пройдемся по основным элементам.
Каталог cmd
Каталог cmd чаще всего содержит входные точки приложений. Если в репозитории один сервис, в cmd будет одна подпапка, если несколько — по папке на сервис.
Например:
cmd
└── api
└── main.goВнутри main.go вы обычно:
- парсите конфигурацию;
- настраиваете логгер;
- создаете зависимости (подключение к БД, сервисы);
- запускаете HTTP-сервер или другую «основную» службу.
Пример main.go:
package main
import (
"log"
"project-structure-example/internal/http"
"project-structure-example/internal/service"
"project-structure-example/internal/storage"
)
func main() {
// Здесь мы создаем подключение к хранилищу (например, к базе данных)
repo := storage.NewUserRepo()
// Здесь мы создаем сервис, который инкапсулирует бизнес-логику
userService := service.NewUserService(repo)
// Здесь мы создаем HTTP-роутер и регистрируем обработчики
router := http.NewRouter(userService)
// Здесь мы запускаем HTTP-сервер
if err := router.Run(":8080"); err != nil {
log.Fatalf("server stopped with error: %v", err)
}
}
Здесь main ничего «умного» не делает — он только собирает зависимости и запускает приложение. Это хороший ориентир: логика не должна жить в main.
Каталог internal
Каталог internal — особенность Go. Все, что лежит внутри internal, нельзя импортировать из других модулей. Это инструмент инкапсуляции.
В internal обычно кладут:
- бизнес-логику;
- HTTP-обработчики;
- репозитории и доступ к БД;
- внутренние утилиты, которые не предназначены для внешнего переиспользования.
Смотрите, я покажу вам пример разбиения на подкаталоги внутри internal:
internal
├── http // HTTP-уровень - обработчики, роутинг
├── domain // Доменные сущности и их правила
├── service // Бизнес-логика (сервисы)
└── storage // Хранилища - БД, кеш и т.д.
Такое разделение помогает не смешивать:
- внешний интерфейс (HTTP);
- чистую доменную модель;
- инфраструктуру (БД, внешние сервисы).
Каталог pkg
Каталог pkg обычно используют для кода, который вы не против переиспользовать в других проектах. Он не является обязательным, но очень распространен.
Пример содержимого pkg:
pkg
└── logger
└── logger.goВнутри может быть что-то вроде:
package logger
import "log"
// New создает простой логгер обертку
func New(prefix string) *log.Logger {
// Здесь мы создаем стандартный логгер с заданным префиксом
return log.New(log.Writer(), prefix+" ", log.LstdFlags|log.Lshortfile)
}
Если в будущем вы захотите вынести pkg/logger в отдельный модуль, это будет проще, чем если бы он лежал в internal.
Конфигурации и миграции
Для конфигураций часто используют каталог configs или config:
configs
└── config.yaml
Вы можете описать в файле config.yaml:
server:
port: 8080
db:
host: localhost
port: 5432
user: app
password: secret
name: app_db
А потом прочитать это в Go-коде.
Для миграций БД обычно используют каталог migrations:
migrations
└── 001_init.sql
Разделение артефактов приложения (конфигов, миграций, seed-данных) по своим каталогам делает проект понятнее.
Логическое разбиение на слои
Теперь давайте поговорим не только о каталогах, но и о логических слоях в коде. Здесь мы уже выходим на уровень архитектуры, но в контексте структуры проекта это очень важно.
Доменные модели (internal/domain)
Доменные модели описывают «язык предметной области». Например, сущность пользователя.
Файл internal/domain/user.go:
package domain
import "time"
// User описывает доменную сущность пользователя
type User struct {
ID int64 // Уникальный идентификатор пользователя
Email string // Электронная почта пользователя
Name string // Имя пользователя
CreatedAt time.Time // Время создания записи
}
// Validate проверяет базовые инварианты сущности
func (u *User) Validate() error {
// Здесь мы можем проверить что email не пустой
// и что в нем есть знак @
// В реальном проекте вы бы добавили более строгую проверку
if u.Email == "" {
return ErrInvalidEmail
}
return nil
}
Здесь доменная модель ничего не знает о БД, HTTP, JSON. Это сознательный подход — вы отделяете суть предметной области от деталей реализации.
Сервисный слой (internal/service)
Сервисный слой содержит бизнес-логику: правила, сценарии работы, комбинации операций.
Файл internal/service/user_service.go:
package service
import "project-structure-example/internal/domain"
// UserRepository описывает абстракцию над хранилищем пользователей
type UserRepository interface {
// Create создает пользователя в хранилище
Create(user *domain.User) error
// GetByID получает пользователя по идентификатору
GetByID(id int64) (*domain.User, error)
}
// UserService реализует бизнес-логику работы с пользователями
type UserService struct {
repo UserRepository
}
// NewUserService создает новый сервис пользователей
func NewUserService(repo UserRepository) *UserService {
// Здесь мы инжектим реализацию репозитория
return &UserService{repo: repo}
}
// RegisterUser регистрирует нового пользователя
func (s *UserService) RegisterUser(user *domain.User) error {
// Здесь мы вызываем доменную валидацию
if err := user.Validate(); err != nil {
return err
}
// Здесь мы вызываем репозиторий для сохранения пользователя
return s.repo.Create(user)
}
Как видите, сервис зависит только от интерфейса UserRepository и доменной модели. Он не привязан к конкретной БД или HTTP. Это позволяет легко тестировать и менять инфраструктуру.
Хранилища и репозитории (internal/storage)
Здесь располагаются конкретные реализации интерфейсов хранилищ.
Файл internal/storage/user_repo.go:
package storage
import (
"database/sql"
"project-structure-example/internal/domain"
)
// UserRepo реализует UserRepository для SQL-базы данных
type UserRepo struct {
db *sql.DB
}
// NewUserRepo создает новый репозиторий пользователей
func NewUserRepo() *UserRepo {
// В реальном проекте вы бы передавали сюда *sql.DB снаружи
// Здесь мы опустим детали инициализации подключения
return &UserRepo{}
}
// Create сохраняет пользователя в базе данных
func (r *UserRepo) Create(user *domain.User) error {
// Здесь вы бы подготовили SQL-запрос и выполнили его
// Например INSERT INTO users (email, name) VALUES (...)
return nil
}
// GetByID получает пользователя по ID
func (r *UserRepo) GetByID(id int64) (*domain.User, error) {
// Здесь вы бы сделали SELECT по базе данных
return &domain.User{ID: id, Email: "test@example.com", Name: "Test"}, nil
}
Обратите внимание, что этот пакет знает о sql.DB, SQL-запросах и т.д., но не знает ничего о HTTP или JSON.
HTTP-слой (internal/http)
Здесь вы описываете обработчики HTTP, которые используют сервисы.
Например:
internal/http/router.go
internal/http/handlers/user.go
Файл internal/http/router.go:
package http
import (
"net/http"
"project-structure-example/internal/service"
"github.com/go-chi/chi/v5"
)
// NewRouter создает и настраивает HTTP-роутер
func NewRouter(userService *service.UserService) *chi.Mux {
// Здесь мы создаем новый роутер chi
r := chi.NewRouter()
// Здесь мы регистрируем HTTP-обработчики
r.Post("/users", createUserHandler(userService))
r.Get("/users/{id}", getUserHandler(userService))
return r
}
Файл internal/http/handlers/user.go:
package http
import (
"encoding/json"
"net/http"
"strconv"
"project-structure-example/internal/domain"
"project-structure-example/internal/service"
"github.com/go-chi/chi/v5"
)
// createUserHandler обрабатывает запрос на создание пользователя
func createUserHandler(s *service.UserService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Здесь мы декодируем JSON тела запроса в доменную модель
var user domain.User
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
// Здесь мы возвращаем ошибку 400 если JSON некорректен
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
// Здесь мы вызываем бизнес-логику для регистрации пользователя
if err := s.RegisterUser(&user); err != nil {
// Здесь мы обрабатываем возможные ошибки домена или хранилища
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Здесь мы устанавливаем код ответа 201 Created
w.WriteHeader(http.StatusCreated)
}
}
// getUserHandler обрабатывает запрос на получение пользователя по ID
func getUserHandler(s *service.UserService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Здесь мы извлекаем параметр id из URL
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
// Здесь мы возвращаем ошибку 400 если id некорректен
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
// Здесь мы получаем пользователя из сервиса
user, err := s.GetUserByID(id)
if err != nil {
// Здесь вы могли бы различать 404 и другие ошибки
http.Error(w, "user not found", http.StatusNotFound)
return
}
// Здесь мы устанавливаем заголовок Content-Type
w.Header().Set("Content-Type", "application/json")
// Здесь мы кодируем пользователя в JSON и отправляем в ответ
if err := json.NewEncoder(w).Encode(user); err != nil {
http.Error(w, "failed to write response", http.StatusInternalServerError)
}
}
}
При такой структуре:
- HTTP-слой знает о сервисе и доменных моделях;
- сервис знает только о домене и абстракции репозитория;
- репозиторий знает о домене и инфраструктуре (БД);
- зависимости направлены «вниз», циклов нет.
Структура для разных размеров проектов
Теперь давайте посмотрим, как структура меняется в зависимости от масштаба.
Маленький проект / утилита
Для небольшой CLI-утилиты или одного простого сервиса структура может быть очень компактной.
Пример:
go-tool
├── cmd
│ └── tool
│ └── main.go
├── internal
│ └── app
│ ├── run.go
│ └── logic.go
├── pkg
│ └── utils
│ └── strings.go
└── go.mod
Внутри internal/app вы можете держать все слои, если их немного. Например:
internal/app/logic.go:
package app
// ProcessData выполняет основную логику утилиты
func ProcessData(input string) (string, error) {
// Здесь вы реализуете необходимые преобразования данных
// Например приводите строку к верхнему регистру
return strings.ToUpper(input), nil
}
Здесь строгие слои не так критичны, как в большом сервисе. Главное — чтобы код оставался читаемым.
Средний сервис (типичный REST API)
Для среднего по сложности сервиса уже полезно разделять по доменам и слоям, как мы делали выше.
Часто структуру делают доменно-ориентированной:
internal
├── user
│ ├── http
│ │ └── handler.go
│ ├── service
│ │ └── service.go
│ └── storage
│ └── repo.go
└── order
├── http
│ └── handler.go
├── service
│ └── service.go
└── storage
└── repo.goИли слойно-ориентированной, как ранее:
internal
├── http
├── service
├── storage
└── domain
Выбор подхода зависит от того, как вам удобнее мыслить:
- доменно (по функциональным областям);
- слойно (по техническим слоям).
Важно, чтобы внутри выбранного подхода вы были последовательны.
Крупные системы и монорепозитории
В больших системах часто используют монорепозитории с несколькими сервисами внутри.
Пример:
company-monorepo
├── services
│ ├── auth-service
│ │ ├── cmd
│ │ ├── internal
│ │ └── go.mod
│ └── billing-service
│ ├── cmd
│ ├── internal
│ └── go.mod
├── libs
│ ├── logger
│ └── config
└── go.work
В Go 1.18+ можно использовать go work для управления несколькими модулями. В этом случае каждый сервис — отдельный модуль со своей внутренней структурой, а общие библиотеки вынесены в libs.
Здесь особенно важно:
- не допускать хаотичного импорта всего подряд;
- четко разделять общие библиотеки и сервисные зависимости;
- придерживаться единых подходов по структуре во всех сервисах.
Импорт, зависимости и структура
Структура проекта сильно влияет на то, как вы импортируете пакеты.
Как организовать импорты между пакетами
Вот несколько практических правил:
- Пакеты должны иметь четкие обязанности. Имя пакета должно говорить о том, что в нем лежит.
- Не создавайте «god package», который знает все обо всем.
- Направление импорта обычно идет сверху вниз по слоям: HTTP → Service → Domain → Storage (или наоборот, в зависимости от выбранной архитектуры, но без циклов).
- Если нужно обеспечить инверсию зависимостей — используйте интерфейсы.
Например, UserService зависит не от конкретного storage.UserRepo, а от интерфейса UserRepository. Так структура пакетов остается чистой, а зависимость на инфраструктуру «переворачивается».
Избежание циклических зависимостей
Циклические зависимости (A импортирует B, B импортирует A) в Go запрещены.
Структура проекта должна помогать избегать таких ситуаций:
- выносите общие типы в отдельные пакеты (например, domain или model);
- не помещайте в один и тот же пакет и HTTP-структуры, и бизнес-логику, и репозитории;
- используйте интерфейсы в верхнем слое (сервисах), а реализации — в нижнем (storage).
Если у вас возник цикл, посмотрите на уровни:
- что является доменом;
- что является инфраструктурой;
- что является транспортом (HTTP, gRPC).
Чаще всего цикл решается выделением промежуточного слоя или переносом интерфейсов в более абстрактный пакет.
Тесты и их место в структуре
Структура тестов тоже важна.
Unit-тесты
Обычно unit-тесты кладут рядом с тестируемым кодом, но в тех же пакетах.
Например:
internal/service/userservice.go
internal/service/userservice_test.go
Внутри вы можете писать:
package service
import "testing"
// fakeUserRepo реализует UserRepository для тестов
type fakeUserRepo struct{}
// Create в фейковом репозитории ничего не делает и возвращает nil
func (r *fakeUserRepo) Create(user *domain.User) error {
return nil
}
// GetByID возвращает тестового пользователя
func (r *fakeUserRepo) GetByID(id int64) (*domain.User, error) {
return &domain.User{ID: id, Email: "test@example.com"}, nil
}
func TestUserService_RegisterUser(t *testing.T) {
// Здесь мы создаем фейковый репозиторий
repo := &fakeUserRepo{}
// Здесь мы создаем сервис с фейковым репозиторием
s := NewUserService(repo)
// Здесь мы создаем тестового пользователя
user := &domain.User{Email: "user@example.com"}
// Здесь мы вызываем тестируемый метод
if err := s.RegisterUser(user); err != nil {
t.Fatalf("expected no error, got %v", err)
}
}
Так тесты физически рядом с кодом, к которому они относятся, и структура остается понятной.
Интеграционные и e2e-тесты
Интеграционные тесты часто выносят в отдельные каталоги:
- tests;
- internal/tests;
- или в подкаталоги вроде internal/http/test.
Например:
tests
└── e2e
└── user_flow_test.goГлавная идея — отделить тесты, которые требуют внешних ресурсов (БД, сеть и т.д.), от простых unit-тестов.
Структура конфигураций и окружений
Проект — это не только Go-код, но и его окружение.
Разные конфигурации для сред
Чтобы структурировать конфиги для dev, staging и production, можно сделать так:
configs
├── config.dev.yaml
├── config.staging.yaml
└── config.prod.yaml
Или использовать один файл с разными секциями, плюс переменные окружения.
Смотрите, здесь пример структуры для работы с конфигами:
internal/config
├── loader.go
└── model.go
В model.go вы описываете структуру конфига:
package config
// Config описывает конфигурацию приложения
type Config struct {
Server struct {
Port string // Порт HTTP-сервера
}
DB struct {
DSN string // Строка подключения к базе данных
}
}
А в loader.go что-то вроде:
package config
import (
"os"
"gopkg.in/yaml.v3"
)
// Load загружает конфигурацию из файла
func Load(path string) (*Config, error) {
// Здесь мы читаем содержимое файла
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg Config
// Здесь мы декодируем YAML в структуру Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err
}
return &cfg, nil
}
Так конфигурации имеют свой понятный слой, и их структура не смешивается с остальным кодом.
Инфраструктурные файлы
Файлы вроде Dockerfile, docker-compose.yaml, Makefile, CI-конфигураций (например, .github/workflows) обычно лежат в корне репозитория.
Пример:
project-structure-example
├── Dockerfile
├── docker-compose.yaml
├── Makefile
└── .github
└── workflows
└── ci.yamlОни не должны «захламлять» код, но и не прятаться слишком глубоко — это часть того, как проект запускается и доставляется.
Практические советы по проектной структуре
Совместимость со стандартом Go
Go-сообщество обычно поддерживает простой стиль:
- короткие имена пакетов (http, config, auth, user);
- избегание лишней вложенности;
- минимизация абстракций там, где они не нужны.
Когда вы выбираете структуру, старайтесь не бороться с общим стилем экосистемы. Новым разработчикам будет проще разобраться.
Когда использовать internal, а когда pkg
Общая рекомендация:
- если код — часть логики конкретного приложения и не планируется его выносить наружу, кладите в internal;
- если это «утилитарная» библиотека, потенциально полезная в других сервисах, кладите в pkg или в отдельный модуль.
Пример:
- internal/service — специфично для приложения;
- pkg/logger — общая библиотека логирования.
Структура должна расти вместе с проектом
Не обязательно сразу делать сложную многоуровневую структуру. Можно начать проще:
internal
├── http
├── service
└── storage
А когда становится тесно:
- выделить поддомены (user, order, billing);
- разделить по доменным модулям;
- вынести часть кода в общие библиотеки.
Главное — не бояться рефакторинга структуры, когда проект эволюционирует.
Как оценить, что структура «не тянет»
Есть несколько признаков:
- вы часто не знаете, куда положить новый файл;
- файлы одного и того же типа (например, обработчики) разбросаны по разным местам;
- один пакет импортит «полмира» и становится точкой хрупкости;
- новые люди долго понимают, как все устроено.
Если вы видите это — значит, пора пересмотреть структуру и, возможно, четче выделить слои или доменные области.
Заключение
Структура проекта в Go — это не жесткий стандарт, а набор практик, которые помогают вам и вашей команде эффективно работать с кодом.
Ключевые идеи, которые стоит вынести:
- используйте cmd для входных точек приложений;
- кладите внутреннюю логику в internal, а общие библиотеки — в pkg;
- разделяйте домен, бизнес-логику, HTTP-слой и хранилища по пакетам;
- следите за направлением зависимостей и избегайте циклов;
- держите тесты рядом с кодом, а интеграционные — отдельно;
- структурируйте конфиги и инфраструктурные файлы так, чтобы их можно было быстро найти.
Структура проекта должна помогать вам отвечать на вопрос «где это лежит и как это устроено» максимально быстро. Если сейчас это не так — имеет смысл вернуться к структуре и пересобрать ее с учетом принципов, о которых мы поговорили.
Частозадаваемые технические вопросы по теме статьи и ответы на них
Как правильно называть пакеты в Go чтобы структура проекта была понятной
Используйте короткие и осмысленные имена в единственном числе. Например: user, order, config, storage, http. Избегайте суффиксов type, util, helper. Имя пакета должно отвечать на вопрос «что это за область ответственности», а не «какой это технический объект».
Можно ли использовать несколько пакетов main в одном модуле
Да, можно. Каждый пакет main должен находиться в своей директории, чаще всего внутри cmd. Например cmd/api и cmd/worker. go build позволяет указать какую директорию собирать, а go run — какой main запускать.
Как организовать общие модели для нескольких сервисов в монорепозитории
Вынесите общие модели в отдельный модуль или каталог внутри libs или pkg. Например libs/contracts или pkg/model. Следите за тем, чтобы эти модели не начинали тянуть за собой бизнес-логику конкретного сервиса, иначе появятся нежелательные зависимости между сервисами.
Где хранить интерфейсы - в пакете реализации или в пакете который их использует
Чаще интерфейс должен находиться там, где он используется, а не там, где реализуется. Тогда модуль с реализацией не навязывает свою абстракцию другим. Например интерфейс UserRepository логично держать в слое service, а реализацию - в storage.
Как структурировать код если в проекте используются разные транспорты - HTTP и gRPC
Разделите транспортные слои по пакетам. Например internal/http и internal/grpc. В каждом пакете держите только адаптеры к бизнес-логике и транспортные детали. Сервисы и доменная логика должны быть общими и не зависеть от того, какой транспорт их вызывает.