Модуль 6. Business Logic Layer

Цели модуля

После модуля студент сможет:

  • Проектировать доменные модели с поведением и инвариантами
  • Разделять валидацию формата и бизнес-правил
  • Проектировать сервисы с чёткой ответственностью
  • Оркестрировать бизнес-сценарии через use cases
  • Публиковать и обрабатывать domain events
  • Правильно пробрасывать ошибки через слои
  • Тестировать бизнес-логику изолированно

6.1 Роль бизнес-логики в архитектуре

Где живёт бизнес-логика

В Clean Architecture бизнес-логика находится во внутренних слоях:

text
internal/models/      # Доменные модели с поведением
internal/service/     # Бизнес-правила и операции
internal/usecase/     # Оркестрация бизнес-сценариев

Бизнес-логика НЕ знает о:

  • HTTP (net/http)
  • БД (database/sql, pgx)
  • Очередях (Kafka, RMQ)
  • Файловой системе
  • Внешних API

Почему бизнес-логика должна быть изолирована

  1. Тестируемость: тесты без БД, сети, файловой системы
  2. Стабильность: инфраструктура меняется, бизнес-правила реже
  3. Переиспользование: одна логика для HTTP, gRPC, CLI
  4. Понятность: новый разработчик читает бизнес-правила без инфраструктурного шума

Антипаттерны бизнес-логики

  1. Бизнес-логика в 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!
    }
}
  1. Бизнес-логика в репозиториях
go
// ПЛОХО: SQL с бизнес-правилами
func (r *RepoRepository) CreateActiveOnly(repo *Repo) error {
    query := "INSERT INTO repos ... WHERE owner_active = true"
    // бизнес-правило в SQL!
}
  1. Бизнес-логика размазана по слоям
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
  • Логика тривиальна
  • Прототипирование

Конструкторы и инварианты

Конструктор гарантирует что объект создан в валидном состоянии:

go
func 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 если есть конфликты

Защита инвариантов через:

  1. Конструкторы (создание валидного объекта)
  2. Методы с валидацией (изменение с проверкой)
  3. Приватные поля (нельзя изменить напрямую)
go
type 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
}

Валидация в доменной модели

Доменная модель валидирует свои инварианты:

go
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")
    }
    if r.ownerID == "" {
        return errors.New("owner id is required")
    }
    return nil
}

Правило

  • Формат: валидируется на входе (handler)
  • Инварианты: валидируются в доменной модели
  • Бизнес-правила: валидируются в usecase
  • Не дублируйте валидацию между слоями

6.4 Services: проектирование и ответственность

Ответственность Service

Service — набор связанных операций над одной сущностью или доменом.

go
type 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 — одна доменная область.

text
RepoService       # операции над репозиториями
UserService       # операции над пользователями
PullRequestService # операции над pull requests
OutboxService     # операции над outbox событиями

Если Service больше 300 строк — подумайте о разделении.

Зависимости Service

Service зависит от интерфейсов, не от реализаций:

go
type RepoService struct {
    repo   repository.RepoRepository  // интерфейс
    logger *slog.Logger
}

Service НЕ зависит от:

  • Других сервисов (для оркестрации есть usecase)
  • Конкретных адаптеров (postgres, s3)
  • HTTP, gRPC

6.5 Use Cases: оркестрация бизнес-сценариев

Ответственность Use Case

Use Case — один конкретный бизнес-сценарий с оркестрацией.

go
type CreateRepoUseCase struct {
    repoService   *service.RepoService
    userService   *service.UserService
    outboxService *service.OutboxService
    logger        *slog.Logger
}

Когда нужен Use Case

Use Case нужен когда:

  1. Сценарий затрагивает несколько сервисов
  2. Нужна оркестрация операций
  3. Есть бизнес-правила выходящие за рамки одной сущности
  4. Нужно публиковать domain events
  5. Нужна транзакционность кросс-сервисных операций

Пример: MergePullRequestUseCase

go
type 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 = один бизнес-сценарий.

text
CreateRepoUseCase        # создать репозиторий
MergePullRequestUseCase  # слить pull request
TransferOwnershipUseCase # передать владение репозиторием
ArchiveRepoUseCase       # архивировать репозиторий

Не создавайте Use Case для простого CRUD. Если операция — простой Get/Create без оркестрации, используйте Service напрямую.

Тестирование Use Cases

Use Cases тестируются с mock сервисами:

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

  1. Декомпозиция: сервисы не знают друг о друге
  2. Аудит: история изменений
  3. Интеграция: вебхуки, уведомления, аналитика
  4. Асинхронность: тяжёлые операции выполняются фоном

Outbox pattern для событий

Событие записывается в ту же транзакцию что и данные:

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

go
type 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 статусы

go
func (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")
)

Кастомные ошибки с контекстом

Для ошибок с дополнительным контекстом:

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

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

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

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

Привязка к шаблону проекта

Где бизнес-логика в шаблоне

text
internal/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

Что изучаем на примере шаблона

  1. Domain models с методами и валидацией
  2. Services с чёткой ответственностью
  3. Use Cases с оркестрацией
  4. Domain events через outbox
  5. 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 с поведением:

go
type 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:

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

  1. Получение PR
  2. Проверка статуса
  3. Проверка конфликтов
  4. Выполнение merge
  5. Обновление статуса
  6. Публикация события

Задание 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 "Проверь себя"

1. Как изолировать доменную логику от инфраструктуры?
2. Какие свойства outbox pattern делают доставку событий надёжной? — выберите все подходящие
3. Как правильно пробросить ошибку через слой?
4. Какая из этих проверок — бизнес-правило для usecase, а не валидация формата для handler?
5. Что делает эту модель Rich Domain Model?
go
func NewRepo(name, ownerID string) (*Repo, error) {
    if len(name) < 3 {
        return nil, ErrNameTooShort
    }
    return &Repo{name: name, ownerID: ownerID}, nil
}
6. Что из перечисленного — задача Use Case, а не Service?
7. Что не так с этим пробросом ошибки?
go
if err != nil {
    log.Error("merge failed", "err", err)
    return fmt.Errorf("merge failed: %s", err.Error())
}
8. Как защитить инварианты доменной модели?
9. Когда использовать outbox pattern для событий?
10. Что такое domain events?


✅ Итоги модуля

Студент должен:

  • Проектировать 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/ шаблона.