Урок 19: Чистая архитектура — handler → service → repository

Урок 19. Чистая архитектура — handler → service → repository

🔄 Node.js → Go (ключевые аналоги):

📋 Что изучаем

📦 Инициализация проекта

mkdir go-clean-arch && cd go-clean-arch
go mod init go-clean-arch

go get github.com/google/uuid
go mod tidy

# Создаём структуру
mkdir -p internal/domain
mkdir -p internal/repository
mkdir -p internal/service
mkdir -p internal/transport/http
mkdir -p cmd/server

📁 Структура проекта (чистая архитектура)

go-clean-arch/ ├── cmd/ │ └── server/ │ └── main.go # Точка входа, DI ├── internal/ │ ├── domain/ # Бизнес-сущности (НЕ зависят ни от чего) │ │ ├── user.go # User, UserRepository (интерфейс!) │ │ └── errors.go # Бизнес-ошибки │ ├── repository/ # Реализация доступа к данным │ │ └── postgres/ # postgres.UserRepo (implements domain.UserRepository) │ │ └── user_repo.go │ ├── service/ # Бизнес-логика (Use Cases) │ │ └── user_service.go # UserService │ └── transport/ # Транспортный слой (HTTP, gRPC) │ └── http/ │ ├── handler.go # HTTP-хендлеры │ ├── dto.go # DTO для запросов/ответов │ └── router.go # Настройка маршрутов └── go.mod

🧠 Схема зависимостей

┌─────────────────────────────────────────────────────┐ │ cmd/server/main.go │ │ (композиция зависимостей) │ └────────┬──────────────────────────────┬─────────────┘ │ │ ┌────────▼──────────┐ ┌────────▼──────────┐ │ transport/http │ │ repository/ │ │ (handler, dto) │ │ postgres/ │ └────────┬──────────┘ └────────▲──────────┘ │ │ │ handler вызывает │ реализует │ service │ интерфейс │ │ ┌────────▼──────────────────────────────┴──────────┐ │ service/ │ │ (бизнес-логика) │ └────────┬─────────────────────────────────────────┘ │ │ service использует │ интерфейс репозитория │ ┌────────▼─────────────────────────────────────────┐ │ domain/ │ │ User (сущность) │ │ UserRepository (интерфейс) │ │ ErrNotFound, ErrValidation (ошибки) │ └──────────────────────────────────────────────────┘

ПРАВИЛО: Стрелки ВСЕГДА направлены ВНУТРЬ. domain НЕ импортирует ничего внешнего. service импортирует domain. repository/postgres импортирует domain. transport/http импортирует domain + service.

💻 Файл: internal/domain/user.go

// Пакет domain содержит ЧИСТЫЕ бизнес-сущности.
// Никаких зависимостей от БД, HTTP, фреймворков!
package domain

import (
    "context"
    "time"
)

// User — доменная модель. Никаких тегов json/bson!
// Это чистая бизнес-сущность, не привязанная к транспорту или БД.
type User struct {
    ID        string
    Email     string
    Name      string
    Password  string // ХЕШ пароля, никогда не plaintext
    Role      string
    Active    bool
    CreatedAt time.Time
    UpdatedAt time.Time
}

// UserRepository — ИНТЕРФЕЙС в доменном слое.
// Определяет КОНТРАКТ доступа к данным.
// Реализация будет в repository/postgres (или mocks для тестов).
// Это ПРАВИЛО ИНВЕРСИИ ЗАВИСИМОСТЕЙ (D из SOLID).
type UserRepository interface {
    Create(ctx context.Context, user *User) error
    GetByID(ctx context.Context, id string) (*User, error)
    GetByEmail(ctx context.Context, email string) (*User, error)
    List(ctx context.Context, offset, limit int) ([]User, error)
    Update(ctx context.Context, user *User) error
    Delete(ctx context.Context, id string) error
}

// CreateUserParams — параметры для создания пользователя
type CreateUserParams struct {
    Email    string
    Name     string
    Password string // plaintext, будет захеширован в сервисе
    Role     string
}

// UpdateUserParams — параметры для обновления
type UpdateUserParams struct {
    Name     *string // Указатель: nil = не обновлять
    Email    *string
    Password *string
    Role     *string
    Active   *bool
}

💻 Файл: internal/domain/errors.go

package domain

import "errors"

// Бизнес-ошибки уровня домена.
// Не зависят от HTTP/gRPC статусов.
var (
    ErrNotFound       = errors.New("resource not found")
    ErrAlreadyExists  = errors.New("resource already exists")
    ErrInvalidInput   = errors.New("invalid input")
    ErrUnauthorized   = errors.New("unauthorized")
    ErrInternalServer = errors.New("internal server error")
)

// ValidationError — ошибка валидации с деталями
type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return "validation: " + e.Field + "" + e.Message
}

💻 Файл: internal/repository/postgres/user_repo.go

package postgres

import (
    "context"
    "fmt"
    "sync"
    "time"

    "go-clean-arch/internal/domain"

    "github.com/google/uuid"
)

// UserRepo — РЕАЛИЗАЦИЯ интерфейса domain.UserRepository.
// В реальном проекте здесь был бы pgxpool.
// Сейчас — in-memory для демонстрации.
type UserRepo struct {
    mu    sync.RWMutex
    users map[string]*domain.User
}

// NewUserRepo — конструктор
func NewUserRepo() *UserRepo {
    return &UserRepo{
        users: make(map[string]*domain.User),
    }
}

// Проверка на этапе компиляции: UserRepo реализует UserRepository
var _ domain.UserRepository = (*UserRepo)(nil)

func (r *UserRepo) Create(ctx context.Context, user *domain.User) error {
    r.mu.Lock()
    defer r.mu.Unlock()

    // Проверяем уникальность email
    for _, u := range r.users {
        if u.Email == user.Email {
            return fmt.Errorf("%w: email %q", domain.ErrAlreadyExists, user.Email)
        }
    }

    if user.ID == "" {
        user.ID = uuid.New().String()
    }
    user.CreatedAt = time.Now()
    user.UpdatedAt = time.Now()

    r.users[user.ID] = user
    return nil
}

func (r *UserRepo) GetByID(ctx context.Context, id string) (*domain.User, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()

    user, ok := r.users[id]
    if !ok {
        return nil, fmt.Errorf("user %q: %w", id, domain.ErrNotFound)
    }
    return user, nil
}

func (r *UserRepo) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()

    for _, u := range r.users {
        if u.Email == email {
            return u, nil
        }
    }
    return nil, fmt.Errorf("email %q: %w", email, domain.ErrNotFound)
}

func (r *UserRepo) List(ctx context.Context, offset, limit int) ([]domain.User, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()

    var result []domain.User
    for _, u := range r.users {
        result = append(result, *u)
    }

    // Простая пагинация
    if offset >= len(result) {
        return []domain.User{}, nil
    }
    end := offset + limit
    if end > len(result) {
        end = len(result)
    }
    return result[offset:end], nil
}

func (r *UserRepo) Update(ctx context.Context, user *domain.User) error {
    r.mu.Lock()
    defer r.mu.Unlock()

    if _, ok := r.users[user.ID]; !ok {
        return fmt.Errorf("user %q: %w", user.ID, domain.ErrNotFound)
    }

    user.UpdatedAt = time.Now()
    r.users[user.ID] = user
    return nil
}

func (r *UserRepo) Delete(ctx context.Context, id string) error {
    r.mu.Lock()
    defer r.mu.Unlock()

    if _, ok := r.users[id]; !ok {
        return fmt.Errorf("user %q: %w", id, domain.ErrNotFound)
    }
    delete(r.users, id)
    return nil
}

💻 Файл: internal/service/user_service.go

package service

import (
    "context"
    "fmt"
    "strings"

    "go-clean-arch/internal/domain"
)

// UserService — СЕРВИСНЫЙ СЛОЙ.
// Содержит бизнес-логику (use cases).
// Зависит ТОЛЬКО от domain (интерфейс репозитория + модели).
type UserService struct {
    repo domain.UserRepository
}

// NewUserService — конструктор
func NewUserService(repo domain.UserRepository) *UserService {
    return &UserService{repo: repo}
}

// Register — регистрация нового пользователя
func (s *UserService) Register(ctx context.Context, params domain.CreateUserParams) (*domain.User, error) {
    // ВАЛИДАЦИЯ (бизнес-правила)
    if strings.TrimSpace(params.Email) == "" {
        return nil, &domain.ValidationError{Field: "email", Message: "must not be empty"}
    }
    if !strings.Contains(params.Email, "@") {
        return nil, &domain.ValidationError{Field: "email", Message: "invalid format"}
    }
    if len(params.Password) < 8 {
        return nil, &domain.ValidationError{Field: "password", Message: "must be at least 8 characters"}
    }
    if params.Name == "" {
        return nil, &domain.ValidationError{Field: "name", Message: "must not be empty"}
    }

    // Проверяем, не занят ли email
    existing, err := s.repo.GetByEmail(ctx, params.Email)
    if err == nil && existing != nil {
        return nil, fmt.Errorf("%w: email %q already registered", domain.ErrAlreadyExists, params.Email)
    }

    // ХЕШИРОВАНИЕ пароля (в реальности — bcrypt/argon2)
    hashedPassword := hashPassword(params.Password)

    user := &domain.User{
        Email:    params.Email,
        Name:     params.Name,
        Password: hashedPassword,
        Role:     params.Role,
        Active:   true,
    }

    if user.Role == "" {
        user.Role = "user" // Роль по умолчанию
    }

    if err := s.repo.Create(ctx, user); err != nil {
        return nil, fmt.Errorf("create user: %w", err)
    }

    return user, nil
}

// GetUser — получение пользователя
func (s *UserService) GetUser(ctx context.Context, id string) (*domain.User, error) {
    if id == "" {
        return nil, &domain.ValidationError{Field: "id", Message: "must not be empty"}
    }
    return s.repo.GetByID(ctx, id)
}

// ListUsers — список пользователей
func (s *UserService) ListUsers(ctx context.Context, offset, limit int) ([]domain.User, int, error) {
    if limit <= 0 || limit > 100 {
        limit = 10
    }
    if offset < 0 {
        offset = 0
    }

    users, err := s.repo.List(ctx, offset, limit)
    if err != nil {
        return nil, 0, err
    }

    // TODO: подсчёт общего количества через repo.Count()
    return users, len(users), nil
}

// UpdateUser — обновление пользователя
func (s *UserService) UpdateUser(ctx context.Context, id string, params domain.UpdateUserParams) (*domain.User, error) {
    user, err := s.repo.GetByID(ctx, id)
    if err != nil {
        return nil, err
    }

    if params.Name != nil {
        user.Name = *params.Name
    }
    if params.Email != nil {
        // Проверяем уникальность нового email
        if *params.Email != user.Email {
            existing, _ := s.repo.GetByEmail(ctx, *params.Email)
            if existing != nil {
                return nil, fmt.Errorf("%w: email %q", domain.ErrAlreadyExists, *params.Email)
            }
        }
        user.Email = *params.Email
    }
    if params.Password != nil {
        user.Password = hashPassword(*params.Password)
    }
    if params.Role != nil {
        user.Role = *params.Role
    }
    if params.Active != nil {
        user.Active = *params.Active
    }

    if err := s.repo.Update(ctx, user); err != nil {
        return nil, err
    }
    return user, nil
}

// DeleteUser — удаление пользователя
func (s *UserService) DeleteUser(ctx context.Context, id string) error {
    return s.repo.Delete(ctx, id)
}

// hashPassword — заглушка (в реальности — bcrypt.GenerateFromPassword)
func hashPassword(pass string) string {
    return "hashed_" + pass
}

💻 Файл: internal/transport/http/dto.go

package http

import (
    "go-clean-arch/internal/domain"
    "time"
)

// ╔══════════════════════════════════════════════════════════╗
// ║  DTO — Data Transfer Objects                          ║
// ║  Отделяем транспортный слой от доменного!              ║
// ╚══════════════════════════════════════════════════════════╝

// --- Запросы (от клиента) ---

type CreateUserRequest struct {
    Email    string `json:"email"`
    Name     string `json:"name"`
    Password string `json:"password"`
    Role     string `json:"role,omitempty"`
}

// ToDomain — конвертирует DTO в доменные параметры
func (r *CreateUserRequest) ToDomain() domain.CreateUserParams {
    return domain.CreateUserParams{
        Email:    r.Email,
        Name:     r.Name,
        Password: r.Password,
        Role:     r.Role,
    }
}

type UpdateUserRequest struct {
    Name     *string `json:"name,omitempty"`
    Email    *string `json:"email,omitempty"`
    Password *string `json:"password,omitempty"`
    Role     *string `json:"role,omitempty"`
    Active   *bool   `json:"active,omitempty"`
}

func (r *UpdateUserRequest) ToDomain() domain.UpdateUserParams {
    return domain.UpdateUserParams{
        Name:     r.Name,
        Email:    r.Email,
        Password: r.Password,
        Role:     r.Role,
        Active:   r.Active,
    }
}

// --- Ответы (клиенту) ---

type UserResponse struct {
    ID        string    `json:"id"`
    Email     string    `json:"email"`
    Name      string    `json:"name"`
    Role      string    `json:"role"`
    Active    bool      `json:"active"`
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
    // ВНИМАНИЕ: Password НЕ передаётся в ответе!
}

// FromDomain — конвертирует доменную модель в DTO ответа
func UserResponseFromDomain(u *domain.User) *UserResponse {
    return &UserResponse{
        ID:        u.ID,
        Email:     u.Email,
        Name:      u.Name,
        Role:      u.Role,
        Active:    u.Active,
        CreatedAt: u.CreatedAt,
        UpdatedAt: u.UpdatedAt,
    }
}

type ListUsersResponse struct {
    Users []UserResponse `json:"users"`
    Total int            `json:"total"`
}

type ErrorResponse struct {
    Error   string `json:"error"`
    Message string `json:"message"`
}

type ValidationErrorResponse struct {
    Error   string   `json:"error"`
    Details []string `json:"details"`
}

💻 Файл: internal/transport/http/handler.go

package http

import (
    "encoding/json"
    "errors"
    "log"
    "net/http"

    "go-clean-arch/internal/domain"
    "go-clean-arch/internal/service"
)

// UserHandler — HTTP-обработчики для пользователей.
// Зависит от сервиса, НЕ от репозитория напрямую.
type UserHandler struct {
    svc *service.UserService
}

func NewUserHandler(svc *service.UserService) *UserHandler {
    return &UserHandler{svc: svc}
}

// CreateUser — POST /api/users
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
    var req CreateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeError(w, http.StatusBadRequest, "Invalid JSON")
        return
    }

    // Конвертируем DTO → доменные параметры
    params := req.ToDomain()

    // Вызываем СЕРВИС (вся бизнес-логика там)
    user, err := h.svc.Register(r.Context(), params)
    if err != nil {
        handleServiceError(w, err)
        return
    }

    // Конвертируем доменную модель → DTO ответа
    resp := UserResponseFromDomain(user)
    writeJSON(w, http.StatusCreated, resp)
}

// GetUser — GET /api/users/{id}
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")

    user, err := h.svc.GetUser(r.Context(), id)
    if err != nil {
        handleServiceError(w, err)
        return
    }

    writeJSON(w, http.StatusOK, UserResponseFromDomain(user))
}

// ListUsers — GET /api/users
func (h *UserHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
    offset, limit := 0, 10
    // TODO: парсить из query-параметров

    users, total, err := h.svc.ListUsers(r.Context(), offset, limit)
    if err != nil {
        handleServiceError(w, err)
        return
    }

    resp := ListUsersResponse{Total: total}
    for _, u := range users {
        resp.Users = append(resp.Users, *UserResponseFromDomain(&u))
    }
    writeJSON(w, http.StatusOK, resp)
}

// UpdateUser — PUT /api/users/{id}
func (h *UserHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")

    var req UpdateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeError(w, http.StatusBadRequest, "Invalid JSON")
        return
    }

    params := req.ToDomain()
    user, err := h.svc.UpdateUser(r.Context(), id, params)
    if err != nil {
        handleServiceError(w, err)
        return
    }

    writeJSON(w, http.StatusOK, UserResponseFromDomain(user))
}

// DeleteUser — DELETE /api/users/{id}
func (h *UserHandler) DeleteUser(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")

    if err := h.svc.DeleteUser(r.Context(), id); err != nil {
        handleServiceError(w, err)
        return
    }

    w.WriteHeader(http.StatusNoContent)
}

// ╔══════════════════════════════════════════════════════════╗
// ║  Хелперы                                               ║
// ╚══════════════════════════════════════════════════════════╝

// handleServiceError — маппит бизнес-ошибки на HTTP-статусы
func handleServiceError(w http.ResponseWriter, err error) {
    var valErr *domain.ValidationError

    switch {
    case errors.Is(err, domain.ErrNotFound):
        writeError(w, http.StatusNotFound, err.Error())
    case errors.Is(err, domain.ErrAlreadyExists):
        writeError(w, http.StatusConflict, err.Error())
    case errors.Is(err, domain.ErrInvalidInput):
        writeError(w, http.StatusBadRequest, err.Error())
    case errors.As(err, &valErr):
        writeJSON(w, http.StatusUnprocessableEntity, ValidationErrorResponse{
            Error:   "validation_error",
            Details: []string{valErr.Error()},
        })
    case errors.Is(err, domain.ErrUnauthorized):
        writeError(w, http.StatusUnauthorized, err.Error())
    default:
        log.Printf("Неизвестная ошибка: %v", err)
        writeError(w, http.StatusInternalServerError, "Internal server error")
    }
}

func writeJSON(w http.ResponseWriter, status int, data any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(data)
}

func writeError(w http.ResponseWriter, status int, msg string) {
    writeJSON(w, status, ErrorResponse{
        Error:   http.StatusText(status),
        Message: msg,
    })
}

💻 Файл: internal/transport/http/router.go

package http

import (
    "net/http"
)

func SetupRouter(h *UserHandler) *http.ServeMux {
    mux := http.NewServeMux()

    mux.HandleFunc("GET /api/users", h.ListUsers)
    mux.HandleFunc("POST /api/users", h.CreateUser)
    mux.HandleFunc("GET /api/users/{id}", h.GetUser)
    mux.HandleFunc("PUT /api/users/{id}", h.UpdateUser)
    mux.HandleFunc("DELETE /api/users/{id}", h.DeleteUser)

    return mux
}

💻 Файл: cmd/server/main.go

package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    "go-clean-arch/internal/repository/postgres"
    "go-clean-arch/internal/service"
    transporthttp "go-clean-arch/internal/transport/http"
)

func main() {
    log.SetFlags(log.LstdFlags | log.Lmicroseconds)
    log.Println("🏗️ Чистая архитектура — запуск сервера")

    // ╔══════════════════════════════════════════════════════╗
    // ║  DI — Dependency Injection (ручная композиция)      ║
    // ╚══════════════════════════════════════════════════════╝

    // 1. Создаём репозиторий (внутренний слой)
    repo := postgres.NewUserRepo()

    // 2. Создаём сервис (зависит от интерфейса репозитория)
    svc := service.NewUserService(repo)

    // 3. Создаём обработчики (зависят от сервиса)
    handler := transporthttp.NewUserHandler(svc)

    // 4. Настраиваем роутер
    router := transporthttp.SetupRouter(handler)

    // 5. Запускаем HTTP-сервер
    server := &http.Server{
        Addr:         ":8080",
        Handler:      router,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
        IdleTimeout:  120 * time.Second,
    }

    go func() {
        log.Println("✅ Сервер на http://localhost:8080")
        if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("Сервер: %v", err)
        }
    }()

    // Graceful shutdown
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    log.Println("🛑 Выключение...")
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    server.Shutdown(ctx)
    log.Println("✅ Остановлен")
}

🚀 Запуск программы

# Запуск сервера
go run ./cmd/server/main.go

# Тестирование API
# Создание пользователя
curl -X POST http://localhost:8080/api/users \
  -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com","name":"Alice","password":"securepass123"}'

# Получение пользователя (подставьте ID)
curl http://localhost:8080/api/users/ВАШ_ID

# Список пользователей
curl http://localhost:8080/api/users

# Обновление
curl -X PUT http://localhost:8080/api/users/ВАШ_ID \
  -H "Content-Type: application/json" \
  -d '{"name":"Alice Updated"}'

# Удаление
curl -X DELETE http://localhost:8080/api/users/ВАШ_ID

📊 Слои чистой архитектуры

СлойДиректорияЗависит отСодержит
Domaininternal/domainНИ ОТ ЧЕГО (чистый Go)Сущности, интерфейсы, бизнес-ошибки
Repositoryinternal/repository/*domainРеализация доступа к данным
Serviceinternal/servicedomainБизнес-логика, use cases
Transportinternal/transport/*domain, serviceHTTP/gRPC хендлеры, DTO
Maincmd/serverВсе слоиDI, конфигурация, запуск
⚠️ Типичные ошибки:
💡 Практический совет:

💡 Best practices от сеньоров:

🔑 Ключевые концепции

Что нужно запомнить из этого урока:

💡 Для Node.js разработчика:

← Предыдущий урок Следующий урок →