Backend на Go (Clean Architecture)
Перед началом: Модули 1–3 (serverless-архитектура и детальный дизайн). Инструменты: Go 1.21+, Clean Architecture,
context, интерфейсы. Время: ~8 ч.
🔥 Инцидент: смена YDB на PostgreSQL — и 20 файлов в мусорку
Бэкенд на Go растёт. Сначала был один пакет chat — auth, messages, websocket, YDB жили вместе. Потом
добавили функции: каждый пакет лезёт в базу напрямую, SQL-запросы размазаны по хендлерам. Команда не видит
проблемы — пока работает.
Затем — требование: заменить YDB на PostgreSQL (compliance, on-premise). Открываем код: SQL-вызовы в auth,
messages, rooms, websocket, notifications. Меняем — переписываем 20 файлов, попутно ломаем
бизнес-логику. Тесты падают, потому что моки завязаны на YDB-клиента. Один деплой — два месяца работы.
Clean Architecture решает: Repository → UseCase → Handler. Бизнес-логика не знает про YDB. Меняем инфраструктуру — трогаем один слой адаптеров. В этом модуле — как её построить.
Из M2–M3: мы выбрали serverless и спроектировали архитектуру (10 компонентов). Теперь переходим к коду — начиная с ядра: Clean Architecture, UseCase, Composition Root.
📖 Теория
В этом модуле строим бэкенд чата на Go по Clean Architecture под Yandex Cloud (Cloud Functions / Serverless Container). Цель — код, который легко тестировать, менять провайдеров и масштабировать.
Слои и правило зависимостей
Dependency Rule: зависимости всегда направлены внутрь — внешние слои зависят от внутренних,
а Domain не зависит ни от кого.
- Domain — сущности (
Message,Room,User) и доменные ошибки. Чистый Go, без фреймворков и облака. - Application —
UseCase(сценарии),Service(координаторы), интерфейсы репозиториев (порты). - Infrastructure — адаптеры: HTTP-хендлеры, реализация репозиториев на YDB, публикатор в YMQ.
Важный нюанс раскладки: UseCase живёт в Application, а не в Domain. В Domain — только сущности и доменные ошибки. Это частая ошибка в примерах — держите слои строго.
Структура проекта
/chat-message-service
├── cmd/
│ └── main.go # точка входа
├── internal/
│ ├── app/
│ │ └── modules.go # Composition Root (DI)
│ ├── domain/
│ │ └── models.go # сущности, доменные ошибки
│ ├── application/
│ │ ├── usecase/ # сценарии (send_message, …)
│ │ ├── service/ # координаторы (message_service)
│ │ └── repository/ # интерфейсы репозиториев (порты)
│ └── infrastructure/
│ ├── adapters/
│ │ ├── ydb/ # реализация репозитория на YDB
│ │ └── ymq/ # публикатор событий в YMQ
│ └── delivery/
│ └── http/ # HTTP-хендлеры
Три модели организации Application
Выбирайте слой сервисов осознанно — не плодите его «для каждого репозитория».
Модель 1. UseCase → Repository (самая частая в Go). Для простых сценариев (получить комнату по ID, последние сообщения) — минимум слоёв, проще тестировать.
Модель 2. UseCase → Service → Repository. Когда нужна координация: несколько репозиториев в одной транзакции, внешние вызовы, сложная валидация. Пример — «создать комнату и сразу добавить создателя как участника» в одной YDB-транзакции.
Модель 3. Service как оркестратор внешних вызовов. «Отправить сообщение → сохранить в YDB → публикация в YMQ → обновить счётчики». Service управляет порядком, повторами, таймаутами; UseCase — точка входа и валидация.
В этом курсе используем модель 2:
SendMessageUseCase→MessageService→ репозитории.
Пример: MessageService
В песочнице ниже — MessageService, который валидирует сообщение, сохраняет в YDB и публикует событие
в YMQ. UseCase оркестрирует сценарий, Service хранит бизнес-правила, репозитории — порты, адаптеры —
реализации под YDB/YMQ.
gofunc (s *MessageService) ProcessMessage(ctx context.Context, msg domain.Message) error { if len(msg.Text) == 0 || len(msg.Text) > 5000 { return fmt.Errorf("invalid message length") } if err := s.msgRepo.Save(ctx, msg); err != nil { return fmt.Errorf("failed to save: %w", err) } if err := s.publisher.Publish(ctx, msg); err != nil { return fmt.Errorf("failed to publish: %w", err) } return nil }
Composition Root (DI)
internal/app/modules.go собирает граф зависимостей один раз (на «холодном старте» в serverless)
и переиспользует его для запросов:
gofunc NewModules(ctx context.Context) (*Modules, error) { db, _ := ydb.Open(ctx, os.Getenv("YDB_CONNECTION_STRING")) ymqClient := sqs.NewFromConfig(awsCfg, func(o *sqs.Options) { o.BaseEndpoint = aws.String("https://message-queue.api.cloud.yandex.net") }) msgRepo := ydb.NewYDBMessageRepository(db) eventPub := ymq.NewYMQPublisher(ymqClient, queueURL) msgService := service.NewMessageService(msgRepo, eventPub) sendMessageUC := usecase.NewSendMessageUseCase(msgService) handler := http.NewHandler(sendMessageUC) return &Modules{HTTPHandler: handler}, nil }
Метрики в UseCase
Метрики — не «после», а часть сценария. Оборачиваем UseCase таймером и счётчиком ошибок (Prometheus/OpenTelemetry):
gofunc (u *SendMessageUseCase) Execute(ctx context.Context, msg domain.Message) error { start := time.Now() err := u.service.ProcessMessage(ctx, msg) metrics.UseCaseTimer(ctx, "send_message", time.Since(start), err != nil) return err }
Так мы видим usecase.duration с атрибутами usecase, error — и можем отличить здоровый сценарий
от проблемного (подробнее — в модуле по наблюдаемому).
Выбор API
| Сценарий | Транспорт | Почему |
|---|---|---|
| CRUD комнат, история, автокомплит | REST (HTTP) | простота, кэширование, удобство для API Gateway |
| Real-time доставка сообщений | WebSocket | постоянное соединение, низкая задержка (в Serverless Container) |
| Внутренние сервис→сервис вызовы (позже) | gRPC/Connect | строгие контракты, бинарный протокол |
Для MVP чата REST + WebSocket достаточно. gRPC рассматриваем при переходе к микросервисам (модуль 9).
Тестируемость
UseCase тестируется без базы — через in-memory реализацию порта. Это главное преимущество Clean Arch:
gofunc TestSendMessage(t *testing.T) { repo := newInMemoryMessageRepo() // заглушка репозитория pub := newFakePublisher() // заглушка публикатора uc := usecase.NewSendMessageUseCase(service.NewMessageService(repo, pub)) err := uc.Execute(ctx, domain.Message{Text: "hi"}) require.NoError(t, err) require.Len(t, repo.messages, 1) }
Правила кодирования на Go
context.Context— первый аргумент во всех I/O методах.- Конструкторы —
New<Name>. - Интерфейсы репозиториев — в
application/repository(порты), реализации — вinfrastructure(адаптеры). - Ошибки оборачиваются с контекстом:
fmt.Errorf("save failed: %w", err). - Вся инициализация — в
internal/app/modules.go(Composition Root).
Почему Clean Architecture в serverless
- Тестируемость: мокаем адаптеры, домен и UseCase не трогаем.
- Миграция: сменить YDB на другое хранилище = новые адаптеры, домен и UseCase не меняются.
- Изоляция: UseCase не знает про Cloud Function, HTTP или Serverless Container — работает одинаково везде.
Ещё один UseCase: история сообщений
GetChatHistoryUseCase показывает пагинацию по курсору (в распределённой БД курсор эффективнее offset):
gotype GetChatHistoryUseCase struct { repo repository.MessageRepository } type GetHistoryRequest struct { RoomID string Cursor string // created_at последнего сообщения (пустой = первая страница) Limit int } func (uc *GetChatHistoryUseCase) Execute(ctx context.Context, req GetHistoryRequest) ([]domain.Message, string, error) { if req.Limit <= 0 || req.Limit > 100 { req.Limit = 50 } messages, nextCursor, err := uc.repo.GetByRoom(ctx, req.RoomID, req.Cursor, req.Limit) if err != nil { return nil, "", fmt.Errorf("get history: %w", err) } return messages, nextCursor, nil }
Курсор vs offset:
OFFSET 1000 LIMIT 20в YDB сканирует 1020 строк. Курсор (WHERE created_at < @cursor) сканирует только 20 — быстрее и дешевле.
Зависимости (go.mod)
require (
github.com/aws/aws-sdk-go-v2 v1.30.0 // YMQ (SQS-совместимая)
github.com/aws/aws-sdk-go-v2/service/sqs v1.34.0
github.com/golang-jwt/jwt/v5 v5.2.1 // JWT
github.com/google/uuid v1.6.0 // ID генерация
golang.org/x/crypto v0.25.0 // bcrypt
go.opentelemetry.io/otel v1.28.0 // метрики/трейсы
)
Компромиссы (Trade-offs)
| Решение | Плюс | Минус |
|---|---|---|
| Clean Architecture (3 слоя) | тестируемость, мигрируемость | больше файлов, порог входа |
| UseCase → Repository (модель 1) | минимум слоёв, простота | не хватает для сложных сценариев |
| UseCase → Service → Repo (модель 2) | координация, транзакции | больше кода |
| Курсорная пагинация | дёшево в YDB (20 строк) | нельзя прыгнуть на страницу N |
| Интерфейсы репозиториев в Application | мокаем без базы | DI-граф в Composition Root |
| Composition Root на холодном старте | переиспользование графа | первая загрузка медленнее |
Вывод модуля: Clean Architecture — это инвестиция в мигрируемость и тестируемость. Для MVP — лишний слой, для production — спасение при смене БД или провайдера.
Упражнения
- Реализуйте
CreateRoomUseCase: создать комнату (name,type,created_by) + добавить создателя какownerвroom_participantsв одной транзакции (TxManager.Do). - Добавьте валидацию: имя комнаты 1–100 символов;
type∈ {p2p,group}. - Напишите unit-тест для
CreateRoomUseCaseс in-memory моками репозиториев (без YDB).
Код из примера можно посмотреть в песочнице выше.
🧩 Проверь себя
🔗 Мостик к Модулю 5
Структура бэкенда готова: слои разделены, UseCase и Service живут в Application, репозитории — порты в Application, адаптеры — в Infrastructure. Но это скелет без мышц. В Модуле 5 нарастим первый продакшен-сценарий — авторизацию (JWT access + refresh, bcrypt), настроим API Gateway и проведём сквозной тест чата двумя пользователями через curl. Добавим Auth и API.