Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
💻
Технологии
Опубликовано:
14.08.2026
Обновлено:
14.08.2026

Clean Architecture на Go: структурируем проект без лишней сложности

Артём Целин

Большинство команд сталкиваются с одной и той же проблемой: проект начинался как простой REST API, а через полгода превратился в лапшу, где HTTP-обработчики обращаются напрямую к PostgreSQL, а бизнес-логика размазана по 20 файлам. Clean Architecture решает это - но только если применять её без фанатизма.

В этом гайде - конкретная структура папок, примеры кода и честный ответ на вопрос "когда CA нужна, а когда лучше не трогать".

Что такое Clean Architecture и зачем она в Go

Clean Architecture - это набор правил разбивки кода на слои с одним главным правилом: зависимости всегда направлены к центру. Внешние слои (HTTP, БД, Kafka) знают о внутренних, но не наоборот. Бизнес-логика не знает, что снаружи PostgreSQL, а не MongoDB.

Автор концепции - Роберт Мартин (Uncle Bob), описал её в книге Clean Architecture: A Craftsman's Guide (2017). В Go эта идея реализуется через интерфейсы: use case объявляет интерфейс репозитория, а конкретная реализация - дело инфраструктурного слоя.

Принцип зависимостей: всё смотрит внутрь

Dependency Rule - центральное правило CA: исходный код во внешнем слое может ссылаться только на код из более внутреннего слоя. Никогда наоборот. На практике это значит: handler импортирует usecase, usecase импортирует domain. domain не импортирует ничего из проекта.

Это предотвращает главную болезнь кодовых баз - сильную связанность, когда изменение одной строки в репозитории ломает 15 хэндлеров.

Три ближайших "родственника": Hexagonal, Onion, Ports & Adapters

Hexagonal Architecture (порты и адаптеры), Onion и Clean Architecture - разные названия одной идеи. Разница косметическая:

Паттерн Центр Внешние слои Особенность
Clean Architecture Domain + Use Cases Interface Adapters, Frameworks Строгие кольца, контролируемые переходы
Hexagonal Business Logic Ports (inbound) + Adapters (outbound) Менее догматичен, легче стартовать
Onion Architecture Domain Model Application, Infrastructure Акцент на domain layer

В Go-сообществе чаще используют Hexagonal как менее жёсткий вариант. Но независимо от названия - правило одно: ядро изолировано от инфраструктуры.

Когда CA нужна, а когда нет (YAGNI)

Принцип YAGNI ("не понадобится") актуален для архитектуры не меньше, чем для фич. Если проект - скрипт или MVP на выходные, три слоя только замедляют.

Чеклист: CA оправдана, когда хотя бы два пункта из списка:

  • В команде 2+ разработчика
  • Нужно покрыть unit-тестами бизнес-логику без БД
  • Планируется замена транспорта (HTTP → gRPC) или хранилища
  • Бизнес-правила сложнее CRUD

Стандартная структура папок Go-проекта

Go не диктует структуру папок жёстко, но у сообщества выработался неофициальный стандарт.

myservice/
├── cmd/
│ └── myservice/
│ └── main.go ← точка входа
├── internal/
│ ├── domain/ ← сущности, интерфейсы репозиториев
│ │ ├── order.go
│ │ └── repository.go ← interface OrderRepository
│ ├── usecase/ ← бизнес-логика
│ │ └── order_service.go
│ ├── repository/ ← реализация: postgres, redis
│ │ └── order_pg.go
│ └── handler/ ← HTTP/gRPC контроллеры
│ └── order_handler.go
├── pkg/ ← экспортируемые утилиты
├── config/
├── migrations/
└── go.mod

cmd/, internal/, pkg/ - зачем три папки

cmd/ - только точки входа (main.go). Здесь собирается граф зависимостей: создаём репозитории, use case, хэндлеры и запускаем сервер.

internal/ - весь прикладной код. Ключевое: пакеты внутри internal/ недоступны для сторонних модулей Go. Это защита от случайного использования внутренних деталей.

pkg/ - код, который можно шарить между сервисами (логгер, общие утилиты). Если шарить нечего - папки pkg/ может не быть.

Раскладка слоёв внутри internal/

Внутри internal/ четыре каталога отражают четыре слоя CA:

  • domain/ - доменные сущности (Order, User) и интерфейсы репозиториев. Нет импортов из других внутренних пакетов.
  • usecase/ - бизнес-логика. Импортирует domain, использует интерфейсы. Не знает о HTTP или PostgreSQL.
  • repository/ - реализации: OrderPgRepository имплементирует domain.OrderRepository через PostgreSQL.
  • handler/ - HTTP/gRPC контроллеры. Декодируют запрос → вызывают use case → сериализуют ответ.

Зависимости: handler → usecase → domain ← repository.

DTO, конфиг и миграции

DTO-структуры отделены от доменных: CreateOrderRequest - DTO для HTTP, Order - доменная сущность. Конвертация - в хэндлере или в отдельном маппере. Конфиг и миграции живут на верхнем уровне, за пределами слоёв - они не несут бизнес-логики.

Dependency Inversion через интерфейсы Go

Это сердце Clean Architecture в Go. Без интерфейсов нет инверсии зависимостей - а без инверсии нет изоляции слоёв.

Почему интерфейс объявляется там, где он используется

В Go принято объявлять интерфейс в пакете-потребителе. Пример:

// internal/domain/repository.go
// Интерфейс объявлен в DOMAIN - именно здесь его используют
type OrderRepository interface {
	Save(ctx context.Context, order Order) error
	FindByID(ctx context.Context, id string) (Order, error)
}
// internal/repository/order_pg.go
// Реализация в ИНФРАСТРУКТУРНОМ пакете - НЕЯВНО реализует интерфейс
type OrderPgRepository struct { db *sql.DB }

func (r *OrderPgRepository) Save(ctx context.Context, order domain.Order) error { ... }

func (r *OrderPgRepository) FindByID(ctx context.Context, id string) (domain.Order, error) { ... }
// internal/usecase/order_service.go
type OrderService struct {
    repo domain.OrderRepository // зависит от ИНТЕРФЕЙСА, не от конкретной реализации
}

Это предотвращает циклические импорты - один из главных врагов Go-проектов.

Ручной DI против wire/fx

Зависимости собираются в main.go:

// cmd/myservice/main.go
func main() {
    db := postgres.Connect(cfg.DSN)

    orderRepo := repository.NewOrderPgRepository(db)
    orderService := usecase.NewOrderService(orderRepo)
    orderHandler := handler.NewOrderHandler(orderService)

    r := gin.Default()
    orderHandler.Register(r)
    r.Run(cfg.Port)
}

Для большинства сервисов этого достаточно. google/wire оправдан при 10+ компонентах (генерирует этот код автоматически). uber-go/fx - выбор крупных монорепозиториев, но добавляет магию, которую сложнее дебажить.

Авторская ремарка: я проверял оба подхода - ручной DI вижу в продакшн-коде Avito и Ozon уровня middle/senior. Собеседующие там часто спрашивают именно "объясни, как ты подключаешь зависимости".

Как интерфейсы упрощают тесты

Use case тестируется без поднятия БД:

// internal/usecase/order_service_test.go
type mockOrderRepo struct{ mock.Mock }

func (m *mockOrderRepo) Save(ctx context.Context, order domain.Order) error {
    args := m.Called(ctx, order)
    return args.Error(0)
}

func TestCreateOrder_Success(t *testing.T) {
    repo := new(mockOrderRepo)
    repo.On("Save", mock.Anything, mock.Anything).Return(nil)

    svc := NewOrderService(repo)
    err := svc.CreateOrder(context.Background(), CreateOrderInput{...})

    assert.NoError(t, err)
    repo.AssertExpectations(t)
}

Типичные ошибки и как их избежать

Видел сотни Go-проектов. Одни и те же ошибки повторяются вне зависимости от размера компании.

Анемичная модель: 200 строк if-ов в сервисе

Анемичная доменная модель - когда Order это просто struct с полями, а вся логика в OrderService. Признак: методы ValidateOrder(), CanShip(), CalculateTotal() живут в сервисе.

Это создаёт "Services Hell": сервисы разрастаются, начинают импортировать друг друга, появляются циклы.

Решение - богатая доменная модель:

// internal/domain/order.go
type Order struct {
	id     OrderID
	items  []OrderItem
	status OrderStatus
}

// Логика ВНУТРИ сущности - бизнес-правила защищены
func NewOrder(items []OrderItem) (Order, error) {
	if len(items) == 0 {
		return Order{}, ErrEmptyOrder
	}
	return Order{id: newID(), items: items, status: StatusDraft}, nil
}

func (o *Order) Ship() error {
	if o.status != StatusPaid {
		return ErrNotPaid
	}
	o.status = StatusShipped
	return nil
}

Инвариант (заказ нельзя отгрузить без оплаты) защищён внутри - его нельзя нарушить снаружи.

Протекание деталей инфраструктуры в домен

Частая ошибка - GORM-теги или json:"..." в доменной структуре:

// ПЛОХО: домен знает о БД
type Order struct {
	ID     string `gorm:"primaryKey" json:"id"`
	Status string `gorm:"column:status" json:"status"`
}

Решение: разделить три типа структур.

Тип Где живёт Назначение
domain.Order internal/domain/ Бизнес-логика, инварианты
repository.OrderRecord internal/repository/ Маппинг на БД, GORM/sql теги
handler.OrderResponse internal/handler/ JSON-ответ API, DTO

Конвертация - в репозитории (RecordEntity) и в хэндлере (EntityDTO).

Оверинжиниринг: когда CA вредит

Три слоя (Handler → Service → Repository) закрывают 80% задач. DDD, CQRS и Anti-Corruption Layer добавляют ровно тогда, когда появляется конкретная боль:

  • DDD (Value Objects, Aggregates) - когда в сервисе больше 150 строк валидации. Заменяем if-ы конструкторами с ошибками.

  • Anti-Corruption Layer - когда интегрируемся с внешним API с чужими структурами. Адаптер переводит "чужой язык" в наш доменный.

  • CQRS-lite - когда запрос списка тормозит, потому что таскает 30 полей ради 4. Вводим плоскую Read-модель, хэндлер читает её напрямую.

Авторская ремарка: самый частый вопрос на код-ревью - "зачем здесь ACL?". Если не можешь ответить одним предложением про конкретную боль - убирай.

Практический пример: эволюция Go-сервиса

Возьмём реальную траекторию: сервис управления заказами от скрипта до production-ready архитектуры. Ниже - три акта эволюции.

Акт 1: скрипт → три слоя

Боль: появился второй транспорт - нужен HTTP API рядом с CLI-обработчиком. Бизнес-логика в main.go больше не работает.

Решение:

internal/
├── handler/ ← HTTP: декодировать запрос, вызвать сервис, вернуть JSON
├── usecase/ ← бизнес-логика: создать заказ, проверить остатки
└── repository/ ← данные: сохранить/получить из PostgreSQL

Теперь можно добавить Kafka-consumer рядом с HTTP-хэндлером - оба вызывают один и тот же use case. Логика не дублируется.

Акт 2: добавляем DDD - Value Objects и Aggregate

Боль: в заказ можно добавить товар с количеством -5. В сервисе 200 строк if-ов.

Решение: вводим Quantity как Value Object:

type Quantity struct{ value int }

func NewQuantity(v int) (Quantity, error) {
	if v <= 0 {
		return Quantity{}, errors.New("количество должно быть больше нуля")
	}
	return Quantity{value: v}, nil
}

Order становится Aggregate - единственная точка входа для изменений. Метод AddItem(item Item, qty Quantity) защищает инварианты. Unit-тест проверяет логику без моков, без БД - мгновенно.

Акт 3: CQRS-lite - разделяем чтение и запись

Боль: список заказов грузится 3 секунды. Domain-модель Order тянет 30 полей через JOIN-ы, хотя в таблице нужно показать 4 колонки.

Решение:

// Read-модель - плоская структура, только для чтения
type OrderListItem struct {
	ID        string
	CreatedAt time.Time
	Status    string
	Total     float64
}

// ReadRepository - отдельный интерфейс, только SELECT
type OrderReadRepository interface {
	ListOrders(ctx context.Context, filter OrderFilter) ([]OrderListItem, error)
}

Хэндлер GET /orders читает напрямую из ReadRepository - без прохода через use case и domain. Write-путь (POST /orders) остаётся через полный стек.

Результат: запрос ускоряется в 10–20 раз (убираем лишние JOIN-ы), domain-модель не загрязняется полями для отображения.

Альтернативное мнение

Часть Go-разработчиков считает Clean Architecture избыточной для большинства сервисов в Рунете. Аргумент: Go спроектирован как простой язык, и добавление 4 слоёв + интерфейсов на каждое действие противоречит философии "explicit over magic". Разумная альтернатива - "functional core, imperative shell": чистые функции для бизнес-логики, минимум абстракций, тесты на уровне HTTP. Это работает, пока команда небольшая и домен несложный.

Нетривиальный факт

Evrone опубликовал go-clean-template на GitHub как открытый шаблон для Go-сервисов - он набрал тысячи звёзд и стал де-факто точкой отсчёта для обсуждений архитектуры в русскоязычном Go-сообществе. При этом авторы шаблона честно предупреждают: "не копируйте слепо, адаптируйте под задачу". Именно эта честность и сделала шаблон популярным.

FAQ

Чем Clean Architecture отличается от гексагональной архитектуры в Go?

Идея одинаковая: бизнес-логика изолирована от инфраструктуры. Clean Architecture строже в терминологии (кольца, Use Cases, Entities). Hexagonal (порты и адаптеры) проще в старте и менее догматична. В Go-сообществе оба термина часто используют взаимозаменяемо.

Обязательно ли использовать wire или fx для DI в Go?

Нет. Для большинства сервисов достаточно ручного DI в main.go. wire оправдан при 10+ компонентах, fx - в крупных монорепозиториях. Ручной DI прозрачнее и проще отлаживать.

Где объявлять интерфейсы репозиториев?

В пакете-потребителе - в domain/ или usecase/. Реализация в repository/ неявно удовлетворяет интерфейсу. Это предотвращает циклические импорты.

Что такое анемичная модель и почему это проблема?

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

Когда добавлять CQRS в Go-проект?

Когда запросы на чтение тормозят из-за тяжёлой domain-модели. Вводите отдельную Read-модель и ReadRepository.

Как тестировать use case без БД?

Через мок-репозиторий, реализующий интерфейс domain. Библиотека testify/mock упрощает создание моков. БД для unit-тестов не нужна.

Нужен ли go-clean-template от Evrone как основа?

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

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также