Как я сейчас структурирую сервис на 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
├── 20260302121545_create_comments.sql
└── 20260306120308_create_refresh_tokens.sql

С этого обычно начинается инфраструктура любого более-менее серьёзного сервиса.


pkg

Код, который я потенциально могу переиспользовать в других проектах.

Например:

  • телеграм-клиент для отправки сообщений;
  • retry / backoff утилиты;
  • небольшой HTTP-клиент с таймаутами и ретраями;
  • helpers работы со временем.

Пример:

pkg/
└── telegram/
    ├── client.go
    └── client_test.go

Если понимаю, что пакет живёт только внутри конкретного приложения, оставляю его в internal, а не в pkg.


tests

Интеграционные тесты и тестовые данные.

tests/
└── integration/
    └── repository/
        ├── user_test.go
        ├── post_test.go

Здесь обычно лежат тесты, которые поднимают реальную базу данных, HTTP-сервер или другие внешние зависимости.


web

Фронтенд или статика: HTML-лендинг, SPA (React/Vue) и прочее.

Например:

web/
├── index.html
├── assets/
└── dist/

Самое интересное — internal

Вся внутренняя логика приложения, которая не должна импортироваться снаружи.

Папка internal — это особенность Go. Пакеты внутри неё нельзя импортировать из других модулей, поэтому она помогает явно отделить внутренний код приложения от публичных библиотек.

Базовый скелет:

internal/
├── app/
├── config/
├── domain/
├── infra/
├── logger/
├── service/
└── delivery/

Разберём папки по очереди.


internal/config

Здесь я собираю конфигурацию: читаю config.yml + .env, валидирую и отдаю уже готовую структуру.

internal/
└── config/
    └── config.go

Идея в том, чтобы остальные части кода работали с нормальной Go-структурой, а не парсили YAML или ENV каждый раз сами.


internal/logger

Единая настройка логгера: уровни, формат, вывод, поля по умолчанию.

internal/
└── logger/
    └── logger.go

Логгер инициализируется один раз и дальше прокидывается по слоям, а не создаётся «по месту» в каждом пакете.


internal/domain

Бизнес-модели: пользователи, посты, комментарии и т.п.

Здесь — только данные и бизнес-смысл, без знания о БД, HTTP или Telegram.

Пример:

internal/
└── domain/
    ├── user.go
    ├── refresh_token.go
    ├── post.go
    └── comment.go

Простейшая модель может выглядеть так:

type User struct {
    ID        int64
    Email     string
    Password  string
    CreatedAt time.Time
}

В domain-моделях обычно нет SQL-тегов, JSON-тегов и прочих инфраструктурных деталей.

Domain-слой описывает что существует в предметной области.


internal/infra

Доступ к данным и внешним сервисам: БД, кэш, файловые хранилища, брокеры сообщений и т.д.

Пример:

internal/
└── infra/
    ├── postgres/
    │   ├── pool.go
    │   ├── errors.go
    │   ├── post_repository.go
    │   ├── user_repository.go
    │   └── refresh_token_repository.go
    ├── redis/
    │   └── ...
    └── kafka/
        └── ...

Обычно здесь реализуются репозитории — адаптеры, которые переводят операции сервиса в конкретные запросы к базе данных.

То есть сервис говорит:

“создай пользователя”

а repository превращает это в SQL-запрос.

Например.

Интерфейс, который нужен сервису:

// internal/service/user/interfaces.go
type UserRepository interface {
    GetByID(ctx context.Context, id int64) (*domain.User, error)
}

А реализация находится в infra:

// internal/infra/postgres/user_repository.go
type UserRepo struct {
    db *pgxpool.Pool // или *sql.DB
}

func (r *UserRepo) GetByID(ctx context.Context, id int64) (*domain.User, error) {
    var user domain.User

    query := `SELECT id, name FROM users WHERE id = $1`
    err := r.db.QueryRow(ctx, query, id).
        Scan(&user.ID, &user.Name)

    return &user, err
}

Так сервис зависит от интерфейса, а не от конкретной базы данных. Благодаря этому можно, например, заменить PostgreSQL на другую БД или подставить мок в тестах.

Важно

Интерфейсы следует помещать в пакет, где они используются, а не в пакет, где реализуются. Это упрощает читаемость кода и снижает зависимость пакетов друг от друга.


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 — принять запрос, разобрать его, вызвать нужный сервис и вернуть ответ, не зная, как именно сервис хранит данные.


internal/app

Сборка приложения: инициализация зависимостей, запуск серверов, воркеров и фоновых задач.

internal/
└── app/
    ├── shared.go
    ├── server.go
    └── bot.go

Здесь создаются инстансы конфигов, логгера, БД, репозиториев и сервисов, после чего они прокидываются в delivery.

По сути, это точка сборки всего приложения.


Про зависимости между слоями

Я стараюсь держаться простого правила:

  • domain не знает про HTTP, SQL, Redis и т.п.;
  • service говорит с infra через интерфейсы;
  • delivery обращается к service;
  • app собирает всё вместе.

Схематично это выглядит так:

delivery -> service -> domain
                |
              infra

Например, HTTP-хендлер не должен напрямую работать с базой данных.

Так легче менять части системы: например заменить PostgreSQL на другую БД или переписать HTTP-слой, не трогая домен и сервисы.


Итоговая структура (пример)

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

myapp/
├── cmd/
│   └── app/
│       └── main.go
├── configs/
│   └── config.yml
├── internal/
│   ├── app/
│   ├── config/
│   ├── domain/
│   ├── infra/
│   ├── logger/
│   ├── service/
│   └── delivery/
├── migrations/
├── pkg/
├── tests/
└── web/

Это не стандарт из книжки, а рабочий каркас, который можно адаптировать под свои задачи.

В разных проектах и технологиях расположение и названия директорий могут отличаться:

delivery  -> transport
handlers  -> controllers
domain    -> models / entities
service   -> usecase

Главное — понимать, зачем нужен каждый слой, а не копировать структуру просто потому, что «так делают все».


Когда структура может быть проще

Если проект маленький (1–2 эндпоинта и одна таблица), я обычно не создаю все эти директории.

Иногда достаточно такой структуры:

internal/
  handler/
  repository/
  service/

А всё остальное появляется по мере роста проекта.


Заключение

Со временем структура почти всегда эволюционирует: появляются новые сервисы, воркеры, очереди, фоновые задачи.

Но если изначально разделить код на домен, сервисы, инфраструктуру и транспорт, проект гораздо легче масштабировать и поддерживать.

Именно поэтому я чаще всего начинаю новые Go-сервисы с такого каркаса.