Как я сейчас структурирую сервис на 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-сервисы с такого каркаса, а затем упрощаю или усложняю его в зависимости от масштаба проекта.