Как я сейчас структурирую сервис на Go
Содержание
Когда я только начал писать backend-сервисы на Go, больше всего я путался не в синтаксисе, а в структуре проекта.
Что такое «бизнес-логика»? Зачем нужна директория infrastructure? Куда класть ошибки, логгер, модели данных?
Ситуацию усложняло то, что каждый автор статьи или видео показывал свой вариант. Подходов к структуре Go-сервиса существует множество. Одной идеальной структуры не существует — всё зависит от проекта и задач.
Со временем у меня выработался свой базовый шаблон, к которому я возвращаюсь снова и снова.
Обычно я использую эту структуру для сервисов среднего размера: когда есть HTTP API, база данных, несколько доменных модулей и отдельные сервисы с бизнес-логикой. Для маленьких pet-проектов она может быть избыточной.
В этой статье расскажу, как я сейчас организую сервис на Go (на примере своих проектов).
Верхний уровень проекта
Примерная структура корня проекта:
myapp/
├── cmd/
├── configs/
├── internal/
├── migrations/
├── pkg/
├── tests/
├── web/
├── go.mod
├── go.sum
└── Makefile
Это не «единственно правильная» структура, а рабочий шаблон, который помогает не тонуть в хаосе.
Что лежит в корне
cmd
Точка входа в приложение.
Для каждого бинарника — своя подпапка с main.go.
Например:
cmd/
├── server/
│ └── main.go
└── bot/
└── main.go
Отдельно для HTTP-сервера и Telegram-бота.
Такой подход удобен, когда у приложения несколько точек входа: например API-сервер, воркер или CLI-утилита.
configs
Конфиги приложения: порты, адреса сервисов, настройки HTTP-сервера и т.п.
Типичный пример:
http:
addr: ":8080"
db:
host: "localhost"
port: 5432
user: "app"
name: "app"
Иногда добавляю конфиги для разных окружений:
configs/
├── config.yml
├── config.dev.yml
└── config.prod.yml
Это позволяет менять параметры (например адреса сервисов или уровни логирования) без изменения кода.
Секреты (пароли, токены) сюда не кладу — они живут в .env.
Если всё-таки складывать секреты в YAML, файл нужно добавлять в .gitignore и держать рядом config.example.yml для примера.
migrations
SQL-миграции для баз данных: создание таблиц, изменения схемы, добавление новых полей.
migrations/
├── 20260302090925_create_users.sql
├── 20260302120917_create_posts.sql
└── 20260306120308_create_sessions.sql
С этого обычно начинается инфраструктура любого более-менее серьёзного сервиса.
pkg
Код, который я потенциально могу переиспользовать в других проектах.
Например:
- телеграм-клиент для отправки сообщений;
- retry / backoff утилиты;
- небольшой HTTP-клиент с таймаутами и ретраями;
- helpers работы со временем.
Пример:
pkg/
└── telegram/
├── client.go
└── client_test.go
Если понимаю, что пакет живёт только внутри конкретного приложения, оставляю его в internal, а не в pkg.
Директория pkg не является официальным стандартом Go, но часто используется в open-source проектах для кода, который можно переиспользовать в других сервисах.
tests
Интеграционные тесты и тестовые данные.
tests/
└── integration/
└── repository/
├── user_test.go
├── post_test.go
└── session_test.go
Здесь обычно лежат тесты, которые поднимают реальную базу данных, HTTP-сервер или другие внешние зависимости.
web
Фронтенд или статика: HTML-лендинг, SPA (React/Vue) и прочее.
Например:
web/
├── index.html
├── assets/
└── dist/
Самое интересное — internal
Вся внутренняя логика приложения, которая не должна импортироваться снаружи.
Папка internal — это особенность Go.
Пакеты внутри неё нельзя импортировать из других модулей, поэтому она помогает явно отделить внутренний код приложения от публичных библиотек.
Базовый скелет:
internal/
├── app/
├── config/
├── delivery/
├── domain/
├── infra/
├── logger/
├── repository/
└── service/
Разберём папки по очереди.
internal/config
Здесь я собираю конфигурацию: читаю config.yml + .env, валидирую и отдаю уже готовую структуру.
internal/
└── config/
└── config.go
Идея в том, чтобы остальные части кода работали с нормальной Go-структурой, а не парсили YAML или ENV каждый раз сами.
internal/logger
Единая настройка логгера: уровни, формат, вывод, поля по умолчанию.
internal/
└── logger/
└── logger.go
Логгер инициализируется один раз и дальше прокидывается по слоям, а не создаётся «по месту» в каждом пакете.
Иногда логгер выносят в pkg, так как он может быть переиспользуемым компонентом.
Однако если логгер используется только внутри одного сервиса и не планируется как отдельная библиотека, я обычно оставляю его в internal. В этом случае он остаётся частью приложения и не предназначен для импорта извне.
internal/domain
Бизнес-модели: пользователи, посты, сессии и т.п.
Здесь — только данные и бизнес-смысл, без знания о БД, HTTP или Telegram.
Пример:
internal/
└── domain/
├── user.go
├── session.go
└── post.go
Простейшая модель может выглядеть так:
type User struct {
ID int64
Email string
Password string
CreatedAt time.Time
}
В domain-моделях обычно нет SQL-тегов, JSON-тегов и прочих инфраструктурных деталей.
Domain-слой описывает что существует в предметной области.
internal/infra
Слой инфраструктуры содержит технические реализации работы с внешними системами.
Это могут быть:
- инициализация соединений с базой данных;
- клиенты Redis;
- Kafka consumer / producer;
- HTTP-клиенты внешних сервисов.
Пример структуры:
internal/
└── infra/
├── postgres/
│ └── pool.go
├── redis/
│ └── client.go
└── kafka/
├── consumer.go
└── producer.go
Например, инициализация пула соединений PostgreSQL:
// internal/infra/postgres/pool.go
func NewPool(cfg Config) (*pgxpool.Pool, error) {
poolConfig, err := pgxpool.ParseConfig(cfg.DSN)
if err != nil {
return nil, err
}
poolConfig.MaxConns = int32(cfg.MaxConns)
return pgxpool.NewWithConfig(context.Background(), poolConfig)
}
internal/repository
Репозитории — это адаптер между сервисами и хранилищем данных.
В отличие от инфраструктуры, репозиторий уже знает о доменных моделях, поэтому его обычно выделяют в отдельный слой.
internal/
└── repository/
├── user.go
├── session.go
└── post.go
type User struct {
db *pgxpool.Pool
}
func (r *User) GetByID(ctx context.Context, id int64) (*domain.User, error) {
var user domain.User
query := `SELECT id, email FROM users WHERE id = $1`
err := r.db.QueryRow(ctx, query, id).
Scan(&user.ID, &user.Email)
return &user, err
}
Здесь уже появляется:
- SQL-запрос
- таблица users
- модель domain.User
Поэтому такой код относится к слою доступа к данным.
Интерфейс репозитория при этом обычно располагается рядом с сервисом, который его использует:
// internal/service/user/interfaces.go
type UserRepository interface {
GetByID(ctx context.Context, id int64) (*domain.User, error)
}
internal/service
Бизнес-логика поверх домена и репозиториев.
Здесь живут use-case’ы: авторизация, создание постов, комментарии, лайки и т.п.
Пример структуры модуля:
internal/
└── service/
├── auth/
│ ├── errors.go
│ ├── interfaces.go
│ ├── mocks.go
│ ├── service.go
│ └── service_test.go
├── post/
│ ├── errors.go
│ ├── interfaces.go
│ ├── mocks.go
│ ├── service.go
│ └── service_test.go
Отдельные файлы вроде errors.go, interfaces.go позволяют держать основной service.go чище и читабельнее.
internal/delivery
Транспортный слой: HTTP-хендлеры, gRPC, Telegram-бот и всё, что отвечает за входящие запросы.
Пример:
internal/
└── delivery/
├── http/
│ ├── handlers/
│ ├── router.go
│ └── server.go
└── telegram/
├── handlers/
├── router.go
├── keyboards.go
└── dispatcher.go
Задача delivery — принять запрос, разобрать его, вызвать нужный сервис и вернуть ответ, не зная, как именно сервис хранит данные.
Пример разделения Kafka-инфраструктуры и обработчиков
Хороший пример различия между инфраструктурой и прикладной логикой — работа с брокерами сообщений, например Kafka.
Техническая реализация клиента Kafka относится к инфраструктуре. Она отвечает только за подключение к брокеру и получение сообщений.
Структура может выглядеть так:
internal/
└── infra/
└── kafka/
├── consumer.go
└── producer.go
Например, простой consumer может выглядеть так:
type Consumer struct {
reader *kafka.Reader
}
func (c *Consumer) Consume(ctx context.Context, handler func([]byte) error) error {
for {
msg, err := c.reader.ReadMessage(ctx)
if err != nil {
return err
}
if err := handler(msg.Value); err != nil {
return err
}
}
}
Этот код ничего не знает о бизнес-событиях, доменных моделях или сервисах. Он просто читает сообщения из Kafka и передаёт их обработчику.
Конкретные обработчики сообщений относятся уже к слою доставки (delivery), так как они являются точкой входа событий в приложение — аналогично HTTP-хендлерам.
Структура может выглядеть так:
internal/
└── delivery/
└── kafka/
└── order_created_handler.go
Пример обработчика:
type OrderCreatedEvent struct {
OrderID string `json:"order_id"`
}
type OrderCreatedHandler struct {
orderService *service.OrderService
}
func (h *OrderCreatedHandler) Handle(msg []byte) error {
var event OrderCreatedEvent
if err := json.Unmarshal(msg, &event); err != nil {
return err
}
return h.orderService.ProcessOrder(event.OrderID)
}
Такой обработчик уже знает:
- структуру входящего события;
- бизнес-смысл события;
- какой сервис должен его обработать.
Поэтому его логично относить к слою delivery, а не к инфраструктуре.
В итоге поток обработки сообщения выглядит примерно так:
Kafka -> delivery/kafka -> service -> repository
Сообщение приходит из Kafka, обрабатывается delivery-слоем, затем передаётся в сервис, который выполняет бизнес-логику и при необходимости работает с хранилищем данных.
internal/app
Сборка приложения: инициализация зависимостей, запуск серверов, воркеров и фоновых задач.
internal/
└── app/
├── shared.go
├── server.go
└── bot.go
Здесь создаются инстансы конфигов, логгера, БД, репозиториев и сервисов, после чего они прокидываются в delivery.
По сути, это точка сборки всего приложения.
Про зависимости между слоями
Я стараюсь держаться простого правила:
- domain не знает про HTTP, SQL, Redis и т.п.;
- service реализует бизнес-логику и работает с репозиториями через интерфейсы;
- delivery обращается к service;
- app собирает всё вместе и инициализирует зависимости.
Схематично это выглядит так:
delivery -> service -> domain
|
repository
То есть:
- delivery принимает входящие запросы (HTTP, gRPC, сообщения из очередей);
- service реализует бизнес-логику;
- repository работает с доменными моделями и хранилищем данных.
Например, HTTP-хендлер не должен напрямую работать с базой данных — он вызывает сервис, а сервис уже использует репозиторий.
Так легче менять части системы: например заменить PostgreSQL на другую БД или переписать HTTP-слой, не трогая домен и сервисы.
Итоговая структура (пример)
Если собрать всё вместе, типичный сервис у меня сейчас выглядит так:
myapp/
├── cmd/
│ ├── server/
│ └── bot/
├── configs/
│ └── config.yml
├── internal/
│ ├── app/
│ ├── config/
│ ├── delivery/
│ ├── domain/
│ ├── infra/
│ ├── logger/
│ ├── repository/
│ └── service/
├── migrations/
├── pkg/
├── tests/
└── web/
Это не стандарт из книжки, а рабочий каркас, который можно адаптировать под свои задачи.
В разных проектах и технологиях расположение и названия директорий могут отличаться:
delivery -> transport
handlers -> controllers
domain -> models / entities
service -> usecase
Главное — понимать, зачем нужен каждый слой, а не копировать структуру просто потому, что «так делают все».
Когда структура может быть проще
Если проект маленький (1–2 эндпоинта и одна таблица), я обычно не создаю все эти директории.
Иногда достаточно такой структуры:
internal/
handler/
repository/
service/
А всё остальное появляется по мере роста проекта.
Заключение
Со временем структура почти всегда эволюционирует: появляются новые сервисы, воркеры, очереди, фоновые задачи.
Но если изначально разделить код на домен, сервисы, инфраструктуру и транспорт, проект гораздо легче масштабировать и поддерживать.
Именно поэтому я чаще всего начинаю новые Go-сервисы с такого каркаса, а затем упрощаю или усложняю его в зависимости от масштаба проекта.