Структура проекта в Go Golang

05 января 2026
Автор

Олег Марков

Введение

Структура проекта в Go — одна из тем, которые почти всегда вызывают вопросы. Формально язык почти ничего не навязывает: вы можете складывать файлы как угодно. Но на практике без четкой структуры становится сложно поддерживать код, подключать новые модули, писать тесты и разворачивать сервисы.

Смотрите, я покажу вам, как можно подойти к организации проекта в Go так, чтобы:

  • код было легко читать и дополнять;
  • зависимости были понятны;
  • новые разработчики быстро вникали;
  • проект хорошо масштабировался.

Мы разберем несколько уровней:

  • базовая структура Go-модуля;
  • типовой «слойный» подход к приложению;
  • разделение кода на пакеты;
  • организация конфигураций, миграций, утилит;
  • практические примеры структуры каталогов.

По ходу статьи я буду давать примеры кода и структуры каталогов, чтобы вы могли сразу представить, как все выглядит в реальном репозитории.

Базовые принципы структуры проекта

Зачем вообще думать о структуре

Если не задумываться о структуре, проект часто растет так:

  • в корне модуля копятся десятки файлов;
  • в одном пакете смешиваются HTTP-обработчики, работа с БД и бизнес-логика;
  • тесты сложно запускать точечно;
  • повторное использование кода становится проблемой.

Структура проекта нужна не ради «красоты», а ради трех вещей:

  1. Понятность — где лежит конкретная логика.
  2. Изоляция — изменение одного модуля минимально затрагивает остальные.
  3. Масштабирование — проект можно расширять без полного переписывания.

В 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
│ └── user
repo.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.

Здесь особенно важно:

  • не допускать хаотичного импорта всего подряд;
  • четко разделять общие библиотеки и сервисные зависимости;
  • придерживаться единых подходов по структуре во всех сервисах.

Импорт, зависимости и структура

Структура проекта сильно влияет на то, как вы импортируете пакеты.

Как организовать импорты между пакетами

Вот несколько практических правил:

  1. Пакеты должны иметь четкие обязанности. Имя пакета должно говорить о том, что в нем лежит.
  2. Не создавайте «god package», который знает все обо всем.
  3. Направление импорта обычно идет сверху вниз по слоям: HTTP → Service → Domain → Storage (или наоборот, в зависимости от выбранной архитектуры, но без циклов).
  4. Если нужно обеспечить инверсию зависимостей — используйте интерфейсы.

Например, UserService зависит не от конкретного storage.UserRepo, а от интерфейса UserRepository. Так структура пакетов остается чистой, а зависимость на инфраструктуру «переворачивается».

Избежание циклических зависимостей

Циклические зависимости (A импортирует B, B импортирует A) в Go запрещены.

Структура проекта должна помогать избегать таких ситуаций:

  • выносите общие типы в отдельные пакеты (например, domain или model);
  • не помещайте в один и тот же пакет и HTTP-структуры, и бизнес-логику, и репозитории;
  • используйте интерфейсы в верхнем слое (сервисах), а реализации — в нижнем (storage).

Если у вас возник цикл, посмотрите на уровни:

  • что является доменом;
  • что является инфраструктурой;
  • что является транспортом (HTTP, gRPC).

Чаще всего цикл решается выделением промежуточного слоя или переносом интерфейсов в более абстрактный пакет.

Тесты и их место в структуре

Структура тестов тоже важна.

Unit-тесты

Обычно unit-тесты кладут рядом с тестируемым кодом, но в тех же пакетах.

Например:

internal/service/userservice.go
internal/service/user
service_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. В каждом пакете держите только адаптеры к бизнес-логике и транспортные детали. Сервисы и доменная логика должны быть общими и не зависеть от того, какой транспорт их вызывает.

Стрелочка влевоPublic API - что это такое и как с ним работать на практикеПринципы FSD - как проектировать архитектуру фронтенда по фичам и слоямСтрелочка вправо

Все гайды по Fsd

Открыть базу знаний

Отправить комментарий