Модуль 3. Clean Architecture Deep Dive (ключевой модуль)

Цели модуля

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

  • Объяснять Dependency Rule и Dependency Inversion на конкретных примерах
  • Различать Use Cases и Services, понимать когда что использовать
  • Проектировать DTO и мапперы для разделения API-контракта и домена
  • Собирать зависимости через DI-контейнер
  • Проследить полный путь запроса через все слои
  • Тестировать каждый слой изолированно
  • Применять Clean Architecture на реальном проекте

3.1 Dependency Rule в деталях

Формулировка правила

Зависимости направлены только внутрь. Внешние слои зависят от внутренних, но не наоборот.

Внутренние слои:

  • Domain Models (ядро)
  • Business Rules (сервисы, use cases)
  • Interface Adapters (интерфейсы репозиториев)

Внешние слои:

  • Frameworks & Drivers (PostgreSQL, HTTP, Kafka)
  • UI, внешние сервисы

Что это значит на практике

Файл internal/models/repo.go НЕ импортирует:

  • database/sql
  • net/http
  • github.com/jackc/pgx
  • любой адаптер

Файл internal/service/repo_service.go НЕ импортирует:

  • internal/adapters/postgres
  • net/http
  • любой конкретный адаптер

Файл internal/service/repo_service.go ИМПОРТИРУЕТ:

  • internal/models (доменные модели)
  • internal/repository (интерфейсы)

Проверка Dependency Rule

Команда для проверки импортов:

bash
go list -f '{{.ImportPath}} -> {{join .Imports "\n"}}' ./internal/service/

Если сервис импортирует adapters/postgres — нарушение Dependency Rule.

Почему это важно

Без Dependency Rule:

  • Изменение БД требует изменения бизнес-логики
  • Невозможно тестировать без реальной инфраструктуры
  • Код становится связанным (coupled)
  • Рефакторинг становится рискованным

С Dependency Rule:

  • Бизнес-логика стабильна при смене инфраструктуры
  • Тесты быстрые (mock вместо БД)
  • Рефакторинг безопасен
  • Новые разработчики понимают структуру

3.2 Dependency Inversion Principle

Формулировка

Модули верхнего уровня не зависят от модулей нижнего уровня. Оба зависят от абстракций (интерфейсов).

Абстракции не зависят от деталей. Детали зависят от абстракций.

Без Dependency Inversion (Layered)

go
// service зависит от конкретной реализации
package service

import "myapp/adapters/postgres"

type RepoService struct {
    repo *postgres.RepoRepository  // конкретный тип
}

Проблема: сервис знает о PostgreSQL. Поменять БД = менять сервис.

С Dependency Inversion (Clean Architecture)

go
// Интерфейс в internal/repository/
package repository

type RepoRepository interface {
    Create(ctx context.Context, repo *models.Repo) error
    GetByID(ctx context.Context, id string) (*models.Repo, error)
}

// Сервис зависит от интерфейса
package service

import "myapp/internal/repository"

type RepoService struct {
    repo repository.RepoRepository  // интерфейс
}

// Реализация в internal/adapters/postgres/
package postgres

import "myapp/internal/repository"

type RepoRepository struct {
    pool *pgxpool.Pool
}

// PostgresRepoRepository удовлетворяет repository.RepoRepository
// автоматически (duck typing в Go)

Кто определяет интерфейс

Важный принцип: интерфейс определяет потребитель, а не реализация.

go
// ПЛОХО: интерфейс в пакете адаптера
package postgres
type RepoRepository interface { ... }

// ПРАВИЛЬНО: интерфейс в пакете потребителя
package repository
type RepoRepository interface { ... }

Сервис (потребитель) определяет что ему нужно. Адаптер (реализация) удовлетворяет этот интерфейс.

Пример из шаблона

go
// internal/repository/repo_repository.go
package repository

import (
    "context"
    "myapp/internal/models"
)

type RepoRepository interface {
    Create(ctx context.Context, repo *models.Repo) error
    GetByID(ctx context.Context, id string) (*models.Repo, error)
    Update(ctx context.Context, repo *models.Repo) error
    Delete(ctx context.Context, id string) error
    List(ctx context.Context, filter RepoFilter) ([]*models.Repo, error)
}

type RepoFilter struct {
    OwnerID string
    Limit   int
    Offset  int
}

// internal/adapters/postgres/repo_repository.go
package postgres

import (
    "context"
    "github.com/jackc/pgx/v5/pgxpool"
    "myapp/internal/models"
)

type RepoRepository struct {
    pool *pgxpool.Pool
}

func NewRepoRepository(pool *pgxpool.Pool) *RepoRepository {
    return &RepoRepository{pool: pool}
}

func (r *RepoRepository) Create(ctx context.Context, repo *models.Repo) error {
    query := "INSERT INTO repos (id, name, owner_id) VALUES ($1, $2, $3)"
    _, err := r.pool.Exec(ctx, query, repo.ID, repo.Name, repo.OwnerID)
    return err
}

// ... остальные методы

3.3 Use Cases vs Services

Разница

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

АспектServiceUse Case
ГранулярностьCRUD над сущностьюОдин сценарий
ПримерRepoService (Create, Get, Update, Delete)CreateRepoUseCase
ОркестрацияНетДа (несколько сервисов)
ТранзакцииВнутри одного репозиторияКросс-сервисные
ПереиспользованиеВысокоеНизкое

Пример Service

go
// internal/service/repo_service.go
package service

type RepoService struct {
    repo repository.RepoRepository
}

func NewRepoService(repo repository.RepoRepository) *RepoService {
    return &RepoService{repo: repo}
}

func (s *RepoService) GetRepo(ctx context.Context, id string) (*models.Repo, error) {
    repo, err := s.repo.GetByID(ctx, id)
    if err != nil {
        return nil, fmt.Errorf("get repo %s: %w", id, err)
    }
    if repo == nil {
        return nil, ErrRepoNotFound
    }
    return repo, nil
}

func (s *RepoService) CreateRepo(ctx context.Context, repo *models.Repo) error {
    return s.repo.Create(ctx, repo)
}

Пример Use Case

go
// internal/usecase/create_repo_usecase.go
package usecase

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

func NewCreateRepoUseCase(
    repoSvc *service.RepoService,
    outboxSvc *service.OutboxService,
    logger *slog.Logger,
) *CreateRepoUseCase {
    return &CreateRepoUseCase{
        repoService:   repoSvc,
        outboxService: outboxSvc,
        logger:        logger,
    }
}

func (uc *CreateRepoUseCase) Execute(ctx context.Context, req CreateRepoRequest) (*models.Repo, error) {
    // 1. Валидация бизнес-правил
    if err := uc.validateBusinessRules(ctx, req); err != nil {
        return nil, fmt.Errorf("validate: %w", err)
    }

    // 2. Создание доменной модели
    repo := models.NewRepo(req.Name, req.OwnerID, req.IsPrivate)

    // 3. Создание события для outbox
    event := models.NewOutboxEvent("repo.created", repo)

    // 4. Атомарная операция: репо + событие
    if err := uc.repoService.CreateWithEvent(ctx, repo, event); err != nil {
        return nil, fmt.Errorf("create repo: %w", err)
    }

    uc.logger.Info("repo created", "repo_id", repo.ID)
    return repo, nil
}

Когда что использовать

Используйте Service когда:

  • Простой CRUD
  • Операции над одной сущностью
  • Нет оркестрации нескольких сервисов

Используйте Use Case когда:

  • Сценарий затрагивает несколько сервисов
  • Нужна оркестрация
  • Есть бизнес-правила выходящие за рамки одной сущности
  • Нужно публиковать события

Правило

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

go
// Простой CRUD — Service достаточно
func (h *Handler) GetRepo(w http.ResponseWriter, r *http.Request) {
    repo, err := h.repoService.GetRepo(r.Context(), id)
}

// Сложный сценарий — Use Case
func (h *Handler) CreateRepo(w http.ResponseWriter, r *http.Request) {
    repo, err := h.createRepoUC.Execute(r.Context(), req)
}

3.4 DTO vs Domain Models

Зачем разделять

Domain Model — внутреннее представление сущности. DTO (Data Transfer Object) — контракт API.

Причины разделения:

  • API может меняться независимо от домена
  • Не все поля домена должны быть видны клиенту
  • Валидация API отличается от валидации домена
  • Версионирование API не затрагивает домен

Пример разделения

go
// Domain Model: internal/models/repo.go
type Repo struct {
    ID        string
    Name      string
    OwnerID   string
    IsPrivate bool
    CreatedAt time.Time
    UpdatedAt time.Time
    // Внутренние поля, невидимые клиенту
    internalFlags int
    deletedAt     *time.Time
}

// DTO: internal/handlers/http/api/v1/dto.go
type CreateRepoRequest struct {
    Name      string `json:"name" validate:"required,min=3,max=100"`
    OwnerID   string `json:"owner_id" validate:"required,uuid"`
    IsPrivate bool   `json:"is_private"`
}

type RepoResponse struct {
    ID        string    `json:"id"`
    Name      string    `json:"name"`
    OwnerID   string    `json:"owner_id"`
    IsPrivate bool      `json:"is_private"`
    CreatedAt time.Time `json:"created_at"`
    // Нет internalFlags, deletedAt
}

Мапперы

Мапперы преобразуют DTO в Domain Model и обратно.

go
// internal/handlers/http/api/v1/mapper.go
package v1

import "myapp/internal/models"

// DTO -> Domain
func (r *CreateRepoRequest) ToDomain() *models.Repo {
    return models.NewRepo(r.Name, r.OwnerID, r.IsPrivate)
}

// Domain -> DTO
func NewRepoResponse(repo *models.Repo) *RepoResponse {
    return &RepoResponse{
        ID:        repo.ID,
        Name:      repo.Name,
        OwnerID:   repo.OwnerID,
        IsPrivate: repo.IsPrivate,
        CreatedAt: repo.CreatedAt,
    }
}

Где живут DTO и мапперы

DTO и мапперы живут в транспортном слое (handlers), не в домене.

text
internal/handlers/http/api/v1/
├── handler.go       # HTTP хэндлеры
├── dto.go           # DTO (контракт API)
├── mapper.go        # Мапперы DTO <-> Domain
└── routes.go        # Регистрация роутов

Домен (models) не знает о DTO. DTO не содержат бизнес-логики.

Когда можно не разделять

Для маленьких проектов и прототипов можно использовать Domain Model напрямую в ответах. Но при росте проекта разделение становится необходимым.

Правило: если API публичный или есть несколько клиентов — разделяйте.


3.5 DI-контейнер и сборка зависимостей

Что такое DI

Dependency Injection — подстановка зависимостей извне, а не создание их внутри.

Без DI (плохо):

go
type RepoService struct {
    repo *postgres.RepoRepository
}

func NewRepoService() *RepoService {
    pool, _ := pgxpool.New(ctx, dsn)  // жёсткая привязка
    return &RepoService{repo: postgres.NewRepoRepository(pool)}
}

С DI (правильно):

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

func NewRepoService(repo repository.RepoRepository) *RepoService {
    return &RepoService{repo: repo}  // подставляется извне
}

Ручной DI (рекомендуется для Go)

В Go идиоматично использовать ручной DI через конструкторы. Фреймворки (wire, fx) нужны только для очень больших проектов.

go
// internal/app/modules.go
package app

func Build(ctx context.Context, cfg *config.Config) (*App, error) {
    // 1. Инфраструктура
    pool, err := postgres.NewDB(ctx, cfg.PostgresDSN)
    if err != nil {
        return nil, fmt.Errorf("connect to postgres: %w", err)
    }

    logger := slog.Default()

    // 2. Репозитории (адаптеры)
    repoRepo := postgres.NewRepoRepository(pool)
    outboxRepo := postgres.NewOutboxRepository(pool)

    // 3. Сервисы
    repoService := service.NewRepoService(repoRepo)
    outboxService := service.NewOutboxService(outboxRepo)

    // 4. Use Cases
    createRepoUC := usecase.NewCreateRepoUseCase(repoService, outboxService, logger)
    getRepoUC := usecase.NewGetRepoUseCase(repoService)

    // 5. Handlers
    handler := v1.NewHandler(createRepoUC, getRepoUC, logger)

    // 6. Router
    mux := http.NewServeMux()
    v1.RegisterRoutes(mux, handler)

    return &App{
        Router: buildMiddleware(mux, logger),
        Pool:   pool,
    }, nil
}

Структура App

go
// internal/app/app.go
package app

type App struct {
    Router http.Handler
    Pool   *pgxpool.Pool
}

func (a *App) Close() error {
    a.Pool.Close()
    return nil
}

Порядок сборки

Снизу вверх: инфраструктура -> репозитории -> сервисы -> use cases -> handlers.

text
postgres.NewDB
    -> postgres.NewRepoRepository
        -> service.NewRepoService
            -> usecase.NewCreateRepoUseCase
                -> v1.NewHandler
                    -> http.ServeMux

Тестирование с DI

go
func TestCreateRepoUseCase(t *testing.T) {
    // Подставляем mock вместо реального репозитория
    mockRepoRepo := new(MockRepoRepository)
    mockOutboxRepo := new(MockOutboxRepository)

    repoService := service.NewRepoService(mockRepoRepo)
    outboxService := service.NewOutboxService(mockOutboxRepo)
    logger := slog.New(slog.NewTextHandler(io.Discard, nil))

    uc := usecase.NewCreateRepoUseCase(repoService, outboxService, logger)

    // Тестируем без реальной БД
    repo, err := uc.Execute(ctx, req)
    assert.NoError(t, err)
}

3.6 Путь запроса через все слои

Полный путь запроса CreateRepo

Разберём полный путь HTTP-запроса через все слои шаблона.

Шаг 1: HTTP запрос приходит в handler

go
// internal/handlers/http/api/v1/handler.go
func (h *Handler) CreateRepo(w http.ResponseWriter, r *http.Request) {
    var req CreateRepoRequest
    if err := decodeJSON(r, &req); err != nil {
        h.writeError(w, http.StatusBadRequest, "invalid request body")
        return
    }

    // Валидация формата (теги)
    if err := validate.Struct(req); err != nil {
        h.writeValidationError(w, err)
        return
    }

    // Вызов use case
    repo, err := h.createRepoUC.Execute(r.Context(), req)
    if err != nil {
        h.handleError(w, err)
        return
    }

    h.writeJSON(w, http.StatusCreated, NewRepoResponse(repo))
}

Шаг 2: Use Case оркестрирует бизнес-сценарий

go
// internal/usecase/create_repo_usecase.go
func (uc *CreateRepoUseCase) Execute(ctx context.Context, req CreateRepoRequest) (*models.Repo, error) {
    // Бизнес-валидация
    if err := uc.validateBusinessRules(ctx, req); err != nil {
        return nil, err
    }

    // Создание доменной модели
    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
}

Шаг 3: Service содержит бизнес-логику

go
// internal/service/repo_service.go
func (s *RepoService) CreateWithEvent(ctx context.Context, repo *models.Repo, event *models.OutboxEvent) error {
    // Доменная валидация
    if err := repo.Validate(); err != nil {
        return fmt.Errorf("validate repo: %w", err)
    }

    // Проверка уникальности
    existing, err := s.repo.GetByName(ctx, repo.Name, repo.OwnerID)
    if err != nil {
        return fmt.Errorf("check existing: %w", err)
    }
    if existing != nil {
        return ErrRepoAlreadyExists
    }

    // Атомарная операция через репозиторий
    return s.repo.CreateWithEvent(ctx, repo, event)
}

Шаг 4: Repository (интерфейс) определяет контракт

go
// internal/repository/repo_repository.go
type RepoRepository interface {
    Create(ctx context.Context, repo *models.Repo) error
    CreateWithEvent(ctx context.Context, repo *models.Repo, event *models.OutboxEvent) error
    GetByName(ctx context.Context, name, ownerID string) (*models.Repo, error)
    GetByID(ctx context.Context, id string) (*models.Repo, error)
}

Шаг 5: Adapter (реализация) выполняет SQL

go
// internal/adapters/postgres/repo_repository.go
func (r *RepoRepository) CreateWithEvent(ctx context.Context, repo *models.Repo, event *models.OutboxEvent) error {
    tx, err := r.pool.Begin(ctx)
    if err != nil {
        return fmt.Errorf("begin tx: %w", err)
    }
    defer tx.Rollback(ctx)

    // INSERT repo
    query := "INSERT INTO repos (id, name, owner_id, is_private) VALUES ($1, $2, $3, $4)"
    _, err = tx.Exec(ctx, query, repo.ID, repo.Name, repo.OwnerID, repo.IsPrivate)
    if err != nil {
        return fmt.Errorf("insert repo: %w", err)
    }

    // INSERT event
    eventQuery := "INSERT INTO outbox_events (id, type, payload) VALUES ($1, $2, $3)"
    _, err = tx.Exec(ctx, eventQuery, event.ID, event.Type, event.Payload)
    if err != nil {
        return fmt.Errorf("insert event: %w", err)
    }

    return tx.Commit(ctx)
}

Шаг 6: PostgreSQL выполняет запросы

sql
INSERT INTO repos (id, name, owner_id, is_private) VALUES (...);
INSERT INTO outbox_events (id, type, payload) VALUES (...);
COMMIT;

Шаг 7: Ответ возвращается обратно через слои

text
PostgreSQL -> Adapter -> Service -> UseCase -> Handler -> HTTP Response

Диаграмма пути запроса

text
HTTP Request
    |
    v
Handler (internal/handlers/http/api/v1/handler.go)
    | декодирует JSON, валидирует формат
    v
UseCase (internal/usecase/create_repo_usecase.go)
    | бизнес-валидация, оркестрация
    v
Service (internal/service/repo_service.go)
    | доменная валидация, бизнес-правила
    v
Repository Interface (internal/repository/repo_repository.go)
    | абстракция
    v
Adapter (internal/adapters/postgres/repo_repository.go)
    | SQL, транзакции
    v
PostgreSQL

Что делает каждый слой

СлойОтветственностьНЕ делает
HandlerHTTP, JSON, формат валидацияБизнес-логика, SQL
UseCaseОркестрация, бизнес-сценарийSQL, HTTP детали
ServiceБизнес-правила, валидация доменаSQL, HTTP детали
RepositoryИнтерфейс (контракт)Реализация
AdapterSQL, транзакцииБизнес-логика

3.7 Тестирование в Clean Architecture

Стратегия тестирования по слоям

СлойТип тестовЗависимости
ModelsЮнит-тестыНет
ServiceЮнит-тестыMock репозитория
UseCaseЮнит-тестыMock сервисов
HandlerИнтеграционныеMock use cases
AdapterИнтеграционныеРеальная БД (testcontainers)

Тестирование Service с mock репозиторием

go
func TestRepoService_GetRepo_NotFound(t *testing.T) {
    // Arrange: mock возвращает nil
    mockRepo := new(MockRepoRepository)
    mockRepo.On("GetByID", mock.Anything, "repo-123").Return(nil, nil)

    svc := service.NewRepoService(mockRepo)

    // Act
    repo, err := svc.GetRepo(context.Background(), "repo-123")

    // Assert
    assert.Nil(t, repo)
    assert.ErrorIs(t, err, service.ErrRepoNotFound)
    mockRepo.AssertExpectations(t)
}

Тестирование UseCase с mock сервисами

go
func TestCreateRepoUseCase_AlreadyExists(t *testing.T) {
    // Arrange
    mockRepoSvc := new(MockRepoService)
    mockRepoSvc.On("CreateWithEvent", mock.Anything, mock.Anything, mock.Anything).
        Return(service.ErrRepoAlreadyExists)
    uc := usecase.NewCreateRepoUseCase(mockRepoSvc, nil, testLogger)

    // Act
    repo, err := uc.Execute(ctx, validRequest)

    // Assert
    assert.Nil(t, repo)
    assert.ErrorIs(t, err, service.ErrRepoAlreadyExists)
}

Тестирование Adapter с testcontainers

go
func TestPostgresRepoRepository_Create(t *testing.T) {
    // Arrange: реальная БД в Docker
    pool := setupTestDB(t)
    defer pool.Close()

    repo := models.NewRepo("test-repo", "user-1", false)
    repoRepo := postgres.NewRepoRepository(pool)

    // Act
    err := repoRepo.Create(context.Background(), repo)

    // Assert
    assert.NoError(t, err)

    // Проверяем что запись реально создана
    got, err := repoRepo.GetByID(context.Background(), repo.ID)
    assert.NoError(t, err)
    assert.Equal(t, "test-repo", got.Name)
}

Тестирование Handler с httptest

go
func TestHandler_CreateRepo_ValidationError(t *testing.T) {
    // Arrange
    mockUC := new(MockCreateRepoUseCase)
    h := v1.NewHandler(mockUC, nil, testLogger)

    body := strings.NewReader(`{"name": ""}`)  // невалидный
    req := httptest.NewRequest("POST", "/api/v1/repos", body)
    req.Header.Set("Content-Type", "application/json")
    w := httptest.NewRecorder()

    // Act
    h.CreateRepo(w, req)

    // Assert
    assert.Equal(t, http.StatusBadRequest, w.Code)
    assert.Contains(t, w.Body.String(), "validation")
}

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

Структура шаблона с объяснением

internal/
├── models/          # Ядро: доменные модели
│   ├── repo.go      # Repo struct + Validate() + NewRepo()
│   ├── user.go
│   └── outbox_event.go
│
├── repository/      # Абстракции: интерфейсы
│   ├── repo_repository.go
│   └── outbox_repository.go
│
├── service/         # Бизнес-логика
│   ├── repo_service.go
│   └── outbox_service.go
│
├── usecase/         # Бизнес-сценарии
│   ├── create_repo_usecase.go
│   └── get_repo_usecase.go
│
├── adapters/        # Инфраструктура
│   └── postgres/
│       ├── repo_repository.go
│       └── connection.go
│
├── handlers/        # Транспорт
│   └── http/api/v1/
│       ├── handler.go
│       ├── dto.go
│       ├── mapper.go
│       └── routes.go
│
└── app/             # Сборка (DI)
    └── modules.go

Задание: проследите путь запроса

Для запроса GET /api/v1/repos/{id} проследите путь через:

  1. Handler: какой метод вызывается?
  2. UseCase или Service: что вызывается?
  3. Repository Interface: какой метод интерфейса?
  4. Adapter: какой SQL выполняется?

💱 Компромиссы

  • Use Case vs Service: CreateRepoUseCase оркестрирует сценарий и пишет репозиторий + outbox-событие атомарно, но класс на каждый сценарий означает низкое переиспользование; RepoService компактен для CRUD, но не тянет кросс-сервисные транзакции.
  • DTO vs Domain Model в ответе handler'а: маппер NewRepoResponse прячет internalFlags и deletedAt от клиента ценой дублирования полей; Domain Model напрямую экономит код прототипа, но любое изменение внутреннего представления ломает API-контракт.
  • Интерфейс у потребителя vs у реализации: RepoRepository объявлен в internal/repository рядом с потребителем — Dependency Rule и duck typing соблюдаются, но интерфейсы плодятся по одному на потребителя; интерфейс в пакете postgres короче, но жёстко сшивает слои.
  • Ручной DI vs wire/fx: конструкторы в app.Build() прозрачны — весь граф зависимостей перед глазами, но пишется руками; wire/fx собирают граф сами ценой зависимости, кодогенерации или рефлексии и порога входа для новичков.
  • Mock vs testcontainers по слоям: mock репозитория даёт быстрые юнит-тесты RepoService и use case без Docker, но не ловит ошибки SQL; testcontainers проверяет адаптер на реальном PostgreSQL ценой медленных интеграционных тестов.

Практическое задание

Задание 1: Рефакторинг Layered -> Clean Architecture

Дан код:

go
// service/repo_service.go
type RepoService struct {
    db *sql.DB
}

func (s *RepoService) GetRepo(id string) (*Repo, error) {
    row := s.db.QueryRow("SELECT * FROM repos WHERE id = $1", id)
    // ...
}

Рефакторите:

  1. Выделите интерфейс RepoRepository в internal/repository/
  2. Реализацию переместите в internal/adapters/postgres/
  3. Сервис должен зависеть от интерфейса
  4. Добавьте DI через конструктор

Задание 2: Разделение DTO и Domain Model

Дан handler который возвращает Domain Model напрямую:

go
func (h *Handler) GetRepo(w http.ResponseWriter, r *http.Request) {
    repo, _ := h.service.GetRepo(r.Context(), id)
    json.NewEncoder(w).Encode(repo)  // Domain Model в ответе
}

Рефакторите:

  1. Создайте RepoResponse DTO
  2. Напишите маппер NewRepoResponse(repo)
  3. Handler должен возвращать DTO

Задание 3: Use Case с оркестрацией

Реализуйте MergePullRequestUseCase который:

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

Задание 4: DI-контейнер

Напишите Build() функцию которая собирает все зависимости:

go
func Build(ctx context.Context, cfg *config.Config) (*App, error) {
    // 1. Инфраструктура
    // 2. Репозитории
    // 3. Сервисы
    // 4. Use Cases
    // 5. Handlers
    // 6. Router
}

Задание 5: Тесты для каждого слоя

Напишите:

  1. Юнит-тест для RepoService с mock репозиторием
  2. Юнит-тест для CreateRepoUseCase с mock сервисами
  3. Интеграционный тест для PostgresRepoRepository с testcontainers
  4. HTTP-тест для Handler с httptest

Задание 6: Нарушение Dependency Rule

Найдите нарушения в коде:

go
// service/repo_service.go
package service

import (
    "myapp/adapters/postgres"  // нарушение?
    "net/http"                  // нарушение?
)

type RepoService struct {
    repo *postgres.RepoRepository  // нарушение?
}

Исправьте все нарушения.


Quiz "Проверь себя"

1. Что вернёт handler, если DTO полностью совпадает с Domain Model?
2. Зачем нужны DTO, если есть Domain Models?
3. Почему usecase не должен знать о postgres?
4. Как протестировать usecase без реальной БД?
5. Когда правильнее выделить Use Case, а не добавлять метод в Service?
6. Что не так с этим кодом usecase?
go
type MergePRUseCase struct {
    db *pgxpool.Pool
}
7. Что произойдёт, если нарушить Dependency Rule? — выберите все подходящие
8. В чём суть Dependency Inversion?
9. В каком порядке DI-контейнер собирает этот граф зависимостей?
go
router := http.NewServeMux()
handler := NewHandler(createPRUC)
createPRUC := usecase.NewMergePR(mergeSvc)
mergeSvc := service.NewMergeService(repo)
repo := postgres.NewPRRepository(pool)

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

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

  • Объяснять Dependency Rule и Dependency Inversion
  • Различать Use Cases и Services
  • Проектировать DTO и мапперы
  • Собирать зависимости через DI
  • Проследить путь запроса через все слои
  • Тестировать каждый слой изолированно
  • Находить и исправлять нарушения Dependency Rule
  • Ответить на все вопросы Quiz

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

Следующий модуль: Модуль 4. Configuration Management - viper (YAML + env), валидация при старте (fail fast), секреты, конфиги для local/dev/prod. Привязка к internal/config/ и configs/ шаблона.