Модуль 6. Business Logic Layer
Цели модуля
После модуля студент сможет:
- Проектировать доменные модели с поведением и инвариантами
- Разделять валидацию формата и бизнес-правил
- Проектировать сервисы с чёткой ответственностью
- Оркестрировать бизнес-сценарии через use cases
- Публиковать и обрабатывать domain events
- Правильно пробрасывать ошибки через слои
- Тестировать бизнес-логику изолированно
6.1 Роль бизнес-логики в архитектуре
Где живёт бизнес-логика
В Clean Architecture бизнес-логика находится во внутренних слоях:
textinternal/models/ # Доменные модели с поведением internal/service/ # Бизнес-правила и операции internal/usecase/ # Оркестрация бизнес-сценариев
Бизнес-логика НЕ знает о:
- HTTP (net/http)
- БД (database/sql, pgx)
- Очередях (Kafka, RMQ)
- Файловой системе
- Внешних API
Почему бизнес-логика должна быть изолирована
- Тестируемость: тесты без БД, сети, файловой системы
- Стабильность: инфраструктура меняется, бизнес-правила реже
- Переиспользование: одна логика для HTTP, gRPC, CLI
- Понятность: новый разработчик читает бизнес-правила без инфраструктурного шума
Антипаттерны бизнес-логики
- Бизнес-логика в handlers
go// ПЛОХО: бизнес-правила в handler func (h *Handler) CreateRepo(w http.ResponseWriter, r *http.Request) { // Проверка существования владельца user, _ := h.db.Query("SELECT * FROM users WHERE id = $1", req.OwnerID) if user == nil { // бизнес-правило в handler! } }
- Бизнес-логика в репозиториях
go// ПЛОХО: SQL с бизнес-правилами func (r *RepoRepository) CreateActiveOnly(repo *Repo) error { query := "INSERT INTO repos ... WHERE owner_active = true" // бизнес-правило в SQL! }
- Бизнес-логика размазана по слоям
go// ПЛОХО: часть правил в handler, часть в service, часть в repository // Невозможно понять все правила в одном месте
Правильно: бизнес-логика в service и usecase, инфраструктура в adapters.
6.2 Domain Models с поведением
Анемичная модель vs Rich model
Анемичная модель: только данные, без поведения.
go// Анемичная модель (антипаттерн для сложного домена) type Repo struct { ID string Name string } // Вся логика в сервисах func (s *Service) RenameRepo(repo *Repo, newName string) { if len(newName) < 3 { return errors.New("name too short") } repo.Name = newName }
Rich model: данные + поведение + инварианты.
go// Rich model type Repo struct { ID string Name string OwnerID string IsPrivate bool CreatedAt time.Time UpdatedAt time.Time } func (r *Repo) Rename(newName string) error { if err := validateName(newName); err != nil { return fmt.Errorf("rename repo: %w", err) } r.Name = newName r.UpdatedAt = time.Now() return nil } func (r *Repo) MakePrivate() { r.IsPrivate = true r.UpdatedAt = time.Now() } func (r *Repo) Validate() error { if r.Name == "" { return errors.New("repo name is required") } if len(r.Name) < 3 || len(r.Name) > 100 { return errors.New("repo name must be 3-100 characters") } return nil }
Когда использовать Rich model
Используйте Rich model когда:
- Сложная бизнес-логика
- Инварианты которые должны соблюдаться всегда
- Множество операций над сущностью
- Домен с множеством правил
Используйте анемичную модель когда:
- Простой CRUD
- Логика тривиальна
- Прототипирование
Конструкторы и инварианты
Конструктор гарантирует что объект создан в валидном состоянии:
gofunc NewRepo(name, ownerID string, isPrivate bool) (*Repo, error) { if err := validateName(name); err != nil { return nil, fmt.Errorf("create repo: %w", err) } if ownerID == "" { return nil, errors.New("owner id is required") } now := time.Now() return &Repo{ ID: generateID(), Name: name, OwnerID: ownerID, IsPrivate: isPrivate, CreatedAt: now, UpdatedAt: now, }, nil }
После NewRepo() объект всегда валиден. Невозможно создать Repo с пустым именем или без владельца.
Защита инвариантов
Инвариант — правило которое всегда истинно для объекта.
Примеры инвариантов:
- Имя репозитория 3-100 символов
- Репозиторий всегда имеет владельца
- Pull request не может быть merged если есть конфликты
Защита инвариантов через:
- Конструкторы (создание валидного объекта)
- Методы с валидацией (изменение с проверкой)
- Приватные поля (нельзя изменить напрямую)
gotype Repo struct { id string // приватное: нельзя изменить извне name string // приватное: только через Rename() ownerID string createdAt time.Time } func (r *Repo) Name() string { return r.name } // getter func (r *Repo) Rename(newName string) error { // setter с валидацией // ... }
6.3 Validation strategies
Два уровня валидации
Уровень 1: Валидация формата (транспортный слой)
- Формат полей (required, min, max, email, uuid)
- Выполняется в handler через validator
- Ошибка: 400 Bad Request
Уровень 2: Валидация бизнес-правил (доменный слой)
- Существование связанных сущностей
- Бизнес-правила (уникальность, лимиты, статусы)
- Выполняется в service/usecase
- Ошибка: 409 Conflict, 422 Unprocessable Entity
Пример разделения
go// Уровень 1: формат (handler) type CreateRepoRequest struct { Name string `json:"name" validate:"required,min=3,max=100"` OwnerID string `json:"owner_id" validate:"required,uuid"` } func (h *Handler) CreateRepo(w http.ResponseWriter, r *http.Request) { var req CreateRepoRequest decodeJSON(r, &req) if err := validate.Struct(req); err != nil { // 400 Bad Request: формат невалиден h.writeValidationError(w, err) return } // Уровень 2: бизнес-правила (usecase) repo, err := h.createRepoUC.Execute(r.Context(), req) if err != nil { h.handleError(w, err) return } } // Уровень 2: бизнес-правила (usecase) func (uc *CreateRepoUseCase) Execute(ctx context.Context, req CreateRepoRequest) (*models.Repo, error) { // Проверка существования владельца user, err := uc.userService.GetUser(ctx, req.OwnerID) if err != nil { return nil, fmt.Errorf("get user: %w", err) } if user == nil { return nil, &ValidationError{Field: "owner_id", Message: "user not found"} } // Проверка лимита репозиториев count, err := uc.repoService.CountByOwner(ctx, req.OwnerID) if err != nil { return nil, fmt.Errorf("count repos: %w", err) } if count >= MaxReposPerUser { return nil, &ValidationError{Field: "owner_id", Message: "repo limit exceeded"} } // Проверка уникальности имени existing, err := uc.repoService.GetByName(ctx, req.Name, req.OwnerID) if err != nil { return nil, fmt.Errorf("check existing: %w", err) } if existing != nil { return nil, ErrRepoAlreadyExists } // Создание repo := req.ToDomain() if err := uc.repoService.Create(ctx, repo); err != nil { return nil, fmt.Errorf("create repo: %w", err) } return repo, nil }
Валидация в доменной модели
Доменная модель валидирует свои инварианты:
gofunc (r *Repo) Validate() error { if r.name == "" { return errors.New("repo name is required") } if len(r.name) < 3 || len(r.name) > 100 { return errors.New("repo name must be 3-100 characters") } if r.ownerID == "" { return errors.New("owner id is required") } return nil }
Правило
- Формат: валидируется на входе (handler)
- Инварианты: валидируются в доменной модели
- Бизнес-правила: валидируются в usecase
- Не дублируйте валидацию между слоями
6.4 Services: проектирование и ответственность
Ответственность Service
Service — набор связанных операций над одной сущностью или доменом.
gotype RepoService struct { repo repository.RepoRepository } func (s *RepoService) GetRepo(ctx context.Context, id string) (*models.Repo, error) func (s *RepoService) CreateRepo(ctx context.Context, repo *models.Repo) error func (s *RepoService) UpdateRepo(ctx context.Context, repo *models.Repo) error func (s *RepoService) DeleteRepo(ctx context.Context, id string) error func (s *RepoService) ListRepos(ctx context.Context, filter RepoFilter) ([]*models.Repo, error)
Что Service делает
- CRUD операции над сущностью
- Доменная валидация
- Проверка бизнес-правил для одной сущности
- Обработка ошибок с контекстом
Что Service НЕ делает
- Оркестрация нескольких сервисов (это usecase)
- HTTP обработка (это handler)
- SQL запросы напрямую (это repository)
- Публикация событий (это usecase или отдельный event service)
Размер Service
Правило: один Service — одна доменная область.
textRepoService # операции над репозиториями UserService # операции над пользователями PullRequestService # операции над pull requests OutboxService # операции над outbox событиями
Если Service больше 300 строк — подумайте о разделении.
Зависимости Service
Service зависит от интерфейсов, не от реализаций:
gotype RepoService struct { repo repository.RepoRepository // интерфейс logger *slog.Logger }
Service НЕ зависит от:
- Других сервисов (для оркестрации есть usecase)
- Конкретных адаптеров (postgres, s3)
- HTTP, gRPC
6.5 Use Cases: оркестрация бизнес-сценариев
Ответственность Use Case
Use Case — один конкретный бизнес-сценарий с оркестрацией.
gotype CreateRepoUseCase struct { repoService *service.RepoService userService *service.UserService outboxService *service.OutboxService logger *slog.Logger }
Когда нужен Use Case
Use Case нужен когда:
- Сценарий затрагивает несколько сервисов
- Нужна оркестрация операций
- Есть бизнес-правила выходящие за рамки одной сущности
- Нужно публиковать domain events
- Нужна транзакционность кросс-сервисных операций
Пример: MergePullRequestUseCase
gotype MergePullRequestUseCase struct { prService *service.PullRequestService repoService *service.RepoService gitAdapter repository.GitAdapter outbox *service.OutboxService logger *slog.Logger } func (uc *MergePullRequestUseCase) Execute(ctx context.Context, req MergePRRequest) error { // 1. Получение PR pr, err := uc.prService.GetByID(ctx, req.PRID) if err != nil { return fmt.Errorf("get pull request: %w", err) } // 2. Проверка статуса PR if pr.Status != models.PRStatusOpen { return ErrPRNotOpen } // 3. Проверка конфликтов hasConflicts, err := uc.gitAdapter.CheckConflicts(ctx, pr) if err != nil { return fmt.Errorf("check conflicts: %w", err) } if hasConflicts { return ErrPRHasConflicts } // 4. Выполнение merge if err := uc.gitAdapter.Merge(ctx, pr); err != nil { return fmt.Errorf("merge: %w", err) } // 5. Обновление статуса PR pr.MarkMerged() if err := uc.prService.Update(ctx, pr); err != nil { return fmt.Errorf("update pr: %w", err) } // 6. Публикация события event := models.NewOutboxEvent("pr.merged", pr) if err := uc.outbox.Publish(ctx, event); err != nil { return fmt.Errorf("publish event: %w", err) } uc.logger.Info("pull request merged", "pr_id", pr.ID) return nil }
Гранулярность Use Cases
Один Use Case = один бизнес-сценарий.
textCreateRepoUseCase # создать репозиторий MergePullRequestUseCase # слить pull request TransferOwnershipUseCase # передать владение репозиторием ArchiveRepoUseCase # архивировать репозиторий
Не создавайте Use Case для простого CRUD. Если операция — простой Get/Create без оркестрации, используйте Service напрямую.
Тестирование Use Cases
Use Cases тестируются с mock сервисами:
gofunc TestMergePullRequestUseCase_HasConflicts(t *testing.T) { // Arrange mockPRSvc := new(MockPullRequestService) mockGit := new(MockGitAdapter) mockPRSvc.On("GetByID", mock.Anything, "pr-123"). Return(&models.PullRequest{ID: "pr-123", Status: models.PRStatusOpen}, nil) mockGit.On("CheckConflicts", mock.Anything, mock.Anything). Return(true, nil) uc := NewMergePullRequestUseCase(mockPRSvc, nil, mockGit, nil, testLogger) // Act err := uc.Execute(ctx, MergePRRequest{PRID: "pr-123"}) // Assert assert.ErrorIs(t, err, ErrPRHasConflicts) mockGit.AssertNotCalled(t, "Merge", mock.Anything, mock.Anything) }
6.6 Domain events
Что такое domain events
Domain event — факт который произошёл в домене:
- RepoCreated
- PullRequestMerged
- UserDeleted
- OwnershipTransferred
Зачем нужны domain events
- Декомпозиция: сервисы не знают друг о друге
- Аудит: история изменений
- Интеграция: вебхуки, уведомления, аналитика
- Асинхронность: тяжёлые операции выполняются фоном
Outbox pattern для событий
Событие записывается в ту же транзакцию что и данные:
gofunc (uc *CreateRepoUseCase) Execute(ctx context.Context, req CreateRepoRequest) (*models.Repo, error) { repo := req.ToDomain() event := models.NewOutboxEvent("repo.created", repo) // Атомарно: репо + событие if err := uc.repoService.CreateWithEvent(ctx, repo, event); err != nil { return nil, fmt.Errorf("create repo: %w", err) } return repo, nil }
Отдельный воркер (outbox relay) читает события и доставляет:
go// internal/handlers/jobs/outbox_relay/handler.go func (h *Handler) Run(ctx context.Context) error { events, err := h.outboxService.GetPending(ctx, 100) if err != nil { return err } for _, event := range events { if err := h.publisher.Publish(ctx, event); err != nil { log.Error("publish event failed", "event_id", event.ID, "error", err) continue } if err := h.outboxService.MarkSent(ctx, event.ID); err != nil { return err } } return nil }
Структура domain event
gotype OutboxEvent struct { ID string Type string // "repo.created", "pr.merged" Payload json.RawMessage // данные события Status string // "pending", "sent", "failed" CreatedAt time.Time SentAt *time.Time } func NewOutboxEvent(eventType string, data interface{}) *OutboxEvent { payload, _ := json.Marshal(data) return &OutboxEvent{ ID: generateID(), Type: eventType, Payload: payload, Status: "pending", CreatedAt: time.Now(), } }
6.7 Error propagation across layers
Стратегия: оборачивание с контекстом
Каждый слой добавляет свой контекст к ошибке:
go// Repository: низкоуровневая ошибка return fmt.Errorf("query repo %s: %w", id, err) // Service: бизнес-контекст return fmt.Errorf("get repo: %w", err) // UseCase: сценарий return fmt.Errorf("create repo usecase: %w", err) // Handler: обработка и маппинг на HTTP h.handleError(w, err)
Правило: логирование на границе
Логируйте ошибку один раз на границе (handler). Внутри слоёв только оборачивайте.
go// ПЛОХО: логирование на каждом слое func (s *Service) GetRepo(ctx context.Context, id string) (*Repo, error) { repo, err := s.repo.GetByID(ctx, id) if err != nil { log.Error("failed to get repo", "error", err) // не здесь return nil, err } } // ПРАВИЛЬНО: логирование в handler func (h *Handler) GetRepo(w http.ResponseWriter, r *http.Request) { repo, err := h.usecase.Execute(r.Context(), id) if err != nil { if !errors.Is(err, service.ErrRepoNotFound) { log.Error("get repo failed", "error", err) // здесь } h.handleError(w, err) return } }
Маппинг ошибок на HTTP статусы
gofunc (h *Handler) handleError(w http.ResponseWriter, err error) { switch { case errors.Is(err, service.ErrRepoNotFound): writeProblem(w, http.StatusNotFound, "Not Found", "repo not found") case errors.Is(err, service.ErrRepoAlreadyExists): writeProblem(w, http.StatusConflict, "Conflict", "repo already exists") case errors.Is(err, service.ErrUnauthorized): writeProblem(w, http.StatusUnauthorized, "Unauthorized", "authentication required") case errors.Is(err, context.Canceled): return // клиент закрыл соединение default: h.logger.Error("unexpected error", "error", err) writeProblem(w, http.StatusInternalServerError, "Internal Server Error", "unexpected error") } }
Доменные ошибки
Определяйте доменные ошибки в service:
go// internal/service/errors.go package service import "errors" var ( ErrRepoNotFound = errors.New("repo not found") ErrRepoAlreadyExists = errors.New("repo already exists") ErrUserNotFound = errors.New("user not found") ErrPRNotOpen = errors.New("pull request is not open") ErrPRHasConflicts = errors.New("pull request has conflicts") )
Кастомные ошибки с контекстом
Для ошибок с дополнительным контекстом:
gotype ValidationError struct { Field string Message string } func (e *ValidationError) Error() string { return fmt.Sprintf("validation failed: %s - %s", e.Field, e.Message) } type LimitExceededError struct { Limit int Current int } func (e *LimitExceededError) Error() string { return fmt.Sprintf("limit exceeded: %d of %d", e.Current, e.Limit) }
6.8 Тестирование бизнес-логики
Стратегия тестирования
| Слой | Тип тестов | Зависимости |
|---|---|---|
| Models | Юнит-тесты | Нет |
| Service | Юнит-тесты | Mock repository |
| UseCase | Юнит-тесты | Mock services |
Тестирование Domain Models
gofunc TestRepo_Rename(t *testing.T) { tests := []struct { name string newName string wantErr bool }{ {"valid rename", "new-name", false}, {"too short", "ab", true}, {"too long", strings.Repeat("a", 101), true}, {"empty", "", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { repo := &Repo{ID: "1", Name: "old-name"} err := repo.Rename(tt.newName) if tt.wantErr { assert.Error(t, err) } else { assert.NoError(t, err) assert.Equal(t, tt.newName, repo.Name) } }) } }
Тестирование Service
gofunc TestRepoService_GetRepo_NotFound(t *testing.T) { mockRepo := new(MockRepoRepository) mockRepo.On("GetByID", mock.Anything, "repo-123").Return(nil, nil) svc := NewRepoService(mockRepo) repo, err := svc.GetRepo(context.Background(), "repo-123") assert.Nil(t, repo) assert.ErrorIs(t, err, ErrRepoNotFound) }
Тестирование UseCase
gofunc TestCreateRepoUseCase_LimitExceeded(t *testing.T) { mockRepoSvc := new(MockRepoService) mockUserSvc := new(MockUserService) mockUserSvc.On("GetUser", mock.Anything, "user-1"). Return(&models.User{ID: "user-1"}, nil) mockRepoSvc.On("CountByOwner", mock.Anything, "user-1"). Return(100, nil) // лимит uc := NewCreateRepoUseCase(mockRepoSvc, mockUserSvc, nil, testLogger) repo, err := uc.Execute(ctx, validRequest) assert.Nil(t, repo) var limitErr *LimitExceededError assert.True(t, errors.As(err, &limitErr)) }
Привязка к шаблону проекта
Где бизнес-логика в шаблоне
textinternal/models/ ├── repo.go # Доменная модель с Validate() ├── user.go ├── pull_request.go └── outbox_event.go internal/service/ ├── errors.go # Доменные ошибки ├── repo_service.go ├── user_service.go ├── pull_request_service.go └── outbox_service.go internal/usecase/ ├── create_repo_usecase.go ├── merge_pull_request_usecase.go ├── relay_outbox_usecase.go └── rotate_hmac_usecase.go
Что изучаем на примере шаблона
- Domain models с методами и валидацией
- Services с чёткой ответственностью
- Use Cases с оркестрацией
- Domain events через outbox
- Error propagation через слои
Задание: изучите internal/service/ и internal/usecase/
- Какие методы есть в RepoService?
- Какие use cases реализованы?
- Как пробрасываются ошибки?
- Есть ли domain events?
💱 Компромиссы
- Rich model vs анемичная модель: инварианты защищены конструктором
NewRepo()и приватными полями, но платим объёмом кода и маппингом; анемичная модель короче и годится для простого CRUD, однако правила размазываются по сервисам. - Валидация формата vs бизнес-правила:
validate:"required,min=3"в handler отсекает мусор быстрым 400, но не видит контекста; проверка лимита репозиториев и уникальности имени в usecase требует похода в БД и платит задержкой. - Service vs Use Case: service держит операции одной сущности и прост для тестирования с mock; use case оркестрирует несколько сервисов и outbox, но добавляет слой косвенности — для простого CRUD он избыточен.
- Outbox vs прямая публикация: событие пишется в ту же транзакцию, что
и данные (
CreateWithEvent), и не теряется при сбое, но платит таблицей, relay-воркером и повторными доставками; publish напрямую проще, однако теряет события между коммитом и отправкой. - Лог на границе vs лог в каждом слое: один
Errorв handler не даёт дублей и хранит весь контекст запроса; логирование внутри service и repository кажется наглядным, но плодит записи об одной и той же ошибке.
Практическое задание
Задание 1: Rich Domain Model
Реализуйте модель PullRequest с поведением:
gotype PullRequest struct { ID string RepoID string Title string Status PRStatus SourceBranch string TargetBranch string CreatedAt time.Time MergedAt *time.Time } func NewPullRequest(...) (*PullRequest, error) func (pr *PullRequest) Merge() error func (pr *PullRequest) Close() error func (pr *PullRequest) Reopen() error func (pr *PullRequest) Validate() error
Инварианты:
- Нельзя merge закрытый PR
- Нельзя закрыть уже merged PR
- Title 3-200 символов
Задание 2: Service с валидацией
Реализуйте PullRequestService:
gofunc (s *PullRequestService) GetByID(ctx context.Context, id string) (*models.PullRequest, error) func (s *PullRequestService) Create(ctx context.Context, pr *models.PullRequest) error func (s *PullRequestService) Update(ctx context.Context, pr *models.PullRequest) error func (s *PullRequestService) ListByRepo(ctx context.Context, repoID string, filter PRFilter) ([]*models.PullRequest, error)
Задание 3: Use Case с оркестрацией
Реализуйте MergePullRequestUseCase:
- Получение PR
- Проверка статуса
- Проверка конфликтов
- Выполнение merge
- Обновление статуса
- Публикация события
Задание 4: Domain events
Реализуйте публикацию событий:
- pr.created
- pr.merged
- pr.closed
Используйте outbox pattern.
Задание 5: Error propagation
Реализуйте обработку ошибок через все слои:
- Repository: SQL ошибки
- Service: доменные ошибки (ErrPRNotFound, ErrPRNotOpen)
- UseCase: ошибки сценария
- Handler: маппинг на HTTP статусы
Задание 6: Тесты
Напишите тесты:
- Юнит-тесты для PullRequest модели
- Юнит-тесты для PullRequestService с mock repository
- Юнит-тесты для MergePullRequestUseCase с mock services
Quiz "Проверь себя"
func NewRepo(name, ownerID string) (*Repo, error) {
if len(name) < 3 {
return nil, ErrNameTooShort
}
return &Repo{name: name, ownerID: ownerID}, nil
}if err != nil {
log.Error("merge failed", "err", err)
return fmt.Errorf("merge failed: %s", err.Error())
}✅ Итоги модуля
Студент должен:
- Проектировать Rich Domain Models с инвариантами
- Разделять валидацию формата и бизнес-правил
- Проектировать Services с чёткой ответственностью
- Оркестрировать сценарии через Use Cases
- Публиковать domain events через outbox
- Правильно пробрасывать ошибки через слои
- Тестировать бизнес-логику изолированно
- Ответить на все вопросы Quiz
🔗 Мостик к Модулю 7
Следующий модуль: Модуль 7. Observability - structured logging (slog/zap), request correlation, metrics (Prometheus), tracing (OpenTelemetry), PII masking, health checks. Привязка к internal/observability/ шаблона.