Большинство команд сталкиваются с одной и той же проблемой: проект начинался как простой 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 |
Конвертация - в репозитории (Record → Entity) и в хэндлере (Entity → DTO).
Оверинжиниринг: когда 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 как основа?
Полезен для изучения архитектурных решений, но не копируйте слепо. Начните с трёх слоёв, добавляйте сложность по мере роста боли.
Источники
DoWithLogic/golang-clean-architecture - пример реализации Clean Architecture с SOLID в Go. GitHub -
Evrone - go-clean-template - шаблон чистой архитектуры для Go-сервисов с описанием принципов -
Habr: Clean Architecture + DDD в Go - разбор типичных ошибок при применении CA в Go -
Habr (TimeWeb): Применение чистой архитектуры в Go - практическое руководство -
Avivcarmi.com - Finding The Best Go Project Structure, Part 2 - сравнение CA и Hexagonal, выбор гексагональной -
Robert C. Martin - Clean Architecture: A Craftsman's Guide to Software Structure and Design, Prentice Hall, 2017. ISBN: 978-0134494166.




.svg.webp)

