Модуль 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
Команда для проверки импортов:
bashgo 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 — один конкретный бизнес-сценарий.
| Аспект | Service | Use 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), не в домене.
textinternal/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 (плохо):
gotype RepoService struct { repo *postgres.RepoRepository } func NewRepoService() *RepoService { pool, _ := pgxpool.New(ctx, dsn) // жёсткая привязка return &RepoService{repo: postgres.NewRepoRepository(pool)} }
С DI (правильно):
gotype 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.
textpostgres.NewDB -> postgres.NewRepoRepository -> service.NewRepoService -> usecase.NewCreateRepoUseCase -> v1.NewHandler -> http.ServeMux
Тестирование с DI
gofunc 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 выполняет запросы
sqlINSERT INTO repos (id, name, owner_id, is_private) VALUES (...); INSERT INTO outbox_events (id, type, payload) VALUES (...); COMMIT;
Шаг 7: Ответ возвращается обратно через слои
textPostgreSQL -> Adapter -> Service -> UseCase -> Handler -> HTTP Response
Диаграмма пути запроса
textHTTP 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
Что делает каждый слой
| Слой | Ответственность | НЕ делает |
|---|---|---|
| Handler | HTTP, JSON, формат валидация | Бизнес-логика, SQL |
| UseCase | Оркестрация, бизнес-сценарий | SQL, HTTP детали |
| Service | Бизнес-правила, валидация домена | SQL, HTTP детали |
| Repository | Интерфейс (контракт) | Реализация |
| Adapter | SQL, транзакции | Бизнес-логика |
3.7 Тестирование в Clean Architecture
Стратегия тестирования по слоям
| Слой | Тип тестов | Зависимости |
|---|---|---|
| Models | Юнит-тесты | Нет |
| Service | Юнит-тесты | Mock репозитория |
| UseCase | Юнит-тесты | Mock сервисов |
| Handler | Интеграционные | Mock use cases |
| Adapter | Интеграционные | Реальная БД (testcontainers) |
Тестирование Service с mock репозиторием
gofunc 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 сервисами
gofunc 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
gofunc 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
gofunc 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} проследите путь через:
- Handler: какой метод вызывается?
- UseCase или Service: что вызывается?
- Repository Interface: какой метод интерфейса?
- 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) // ... }
Рефакторите:
- Выделите интерфейс RepoRepository в internal/repository/
- Реализацию переместите в internal/adapters/postgres/
- Сервис должен зависеть от интерфейса
- Добавьте DI через конструктор
Задание 2: Разделение DTO и Domain Model
Дан handler который возвращает Domain Model напрямую:
gofunc (h *Handler) GetRepo(w http.ResponseWriter, r *http.Request) { repo, _ := h.service.GetRepo(r.Context(), id) json.NewEncoder(w).Encode(repo) // Domain Model в ответе }
Рефакторите:
- Создайте RepoResponse DTO
- Напишите маппер NewRepoResponse(repo)
- Handler должен возвращать DTO
Задание 3: Use Case с оркестрацией
Реализуйте MergePullRequestUseCase который:
- Проверяет что PR существует
- Проверяет что нет конфликтов
- Выполняет merge
- Публикует событие "pr.merged"
- Обновляет статус репозитория
Задание 4: DI-контейнер
Напишите Build() функцию которая собирает все зависимости:
gofunc Build(ctx context.Context, cfg *config.Config) (*App, error) { // 1. Инфраструктура // 2. Репозитории // 3. Сервисы // 4. Use Cases // 5. Handlers // 6. Router }
Задание 5: Тесты для каждого слоя
Напишите:
- Юнит-тест для RepoService с mock репозиторием
- Юнит-тест для CreateRepoUseCase с mock сервисами
- Интеграционный тест для PostgresRepoRepository с testcontainers
- 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 "Проверь себя"
type MergePRUseCase struct {
db *pgxpool.Pool
}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/ шаблона.