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). Цель — код, который легко тестировать, менять провайдеров и масштабировать.

Слои и правило зависимостей

Слои Clean Architecture и направление зависимостей

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.

go
func (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) и переиспользует его для запросов:

go
func 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):

go
func (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:

go
func 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

  1. context.Context — первый аргумент во всех I/O методах.
  2. Конструкторы — New<Name>.
  3. Интерфейсы репозиториев — в application/repository (порты), реализации — в infrastructure (адаптеры).
  4. Ошибки оборачиваются с контекстом: fmt.Errorf("save failed: %w", err).
  5. Вся инициализация — в internal/app/modules.go (Composition Root).

Почему Clean Architecture в serverless

  • Тестируемость: мокаем адаптеры, домен и UseCase не трогаем.
  • Миграция: сменить YDB на другое хранилище = новые адаптеры, домен и UseCase не меняются.
  • Изоляция: UseCase не знает про Cloud Function, HTTP или Serverless Container — работает одинаково везде.

Ещё один UseCase: история сообщений

GetChatHistoryUseCase показывает пагинацию по курсору (в распределённой БД курсор эффективнее offset):

go
type 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 — спасение при смене БД или провайдера.

Упражнения

  1. Реализуйте CreateRoomUseCase: создать комнату (name, type, created_by) + добавить создателя как owner в room_participants в одной транзакции (TxManager.Do).
  2. Добавьте валидацию: имя комнаты 1–100 символов; type ∈ {p2p, group}.
  3. Напишите unit-тест для CreateRoomUseCase с in-memory моками репозиториев (без YDB).

Код из примера можно посмотреть в песочнице выше.


🧩 Проверь себя

1. Composition Root — это место, где...
2. Domain-сущности (Message, Room, User) в Clean Architecture — где они?
3. В каком слое живёт UseCase?
4. Dependency Rule в Clean Architecture — зависимости...
5. Adapter pattern для Repository — зачем?

🔗 Мостик к Модулю 5

Структура бэкенда готова: слои разделены, UseCase и Service живут в Application, репозитории — порты в Application, адаптеры — в Infrastructure. Но это скелет без мышц. В Модуле 5 нарастим первый продакшен-сценарий — авторизацию (JWT access + refresh, bcrypt), настроим API Gateway и проведём сквозной тест чата двумя пользователями через curl. Добавим Auth и API.