HTTP адаптер: handler/controller как тонкий слой

HTTP адаптер: handler как тонкий слой

HTTP handler - это первичный адаптер. Он принимает внешний запрос и преобразует его в вызов use-case. Вся ответственность handler сводится к трём шагам:

  1. Распарсить входящий запрос (JSON body, URL-параметры, заголовки).
  2. Вызвать use-case с доменными типами.
  3. Сформировать HTTP-ответ (статус код, JSON body).

Если в handler появляется бизнес-логика (проверка прав, вычисление скидки, валидация бизнес-правил) - значит, handler раздулся и превратился в «серверный комбайн».

Структура handler

Handler получает use-case через конструктор - dependency injection без магии.

// internal/adapters/http/task_handler.go
package http

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

    "github.com/go-chi/chi/v5"

    "myapp/internal/usecase"
)

type TaskHandler struct {
    createTask *usecase.CreateTask
    listTasks  *usecase.ListTasks
}

func NewTaskHandler(ct *usecase.CreateTask, lt *usecase.ListTasks) *TaskHandler {
    return &TaskHandler{
        createTask: ct,
        listTasks:  lt,
    }
}

func (h *TaskHandler) Routes(r chi.Router) {
    r.Post("/tasks", h.Create)
    r.Get("/tasks", h.List)
}
<?php
// src/Infrastructure/Http/Controller/TaskController.php
declare(strict_types=1);

namespace App\Infrastructure\Http\Controller;

use App\Application\UseCase\CreateTaskUseCase;
use App\Application\UseCase\ListTasksUseCase;
use Symfony\Component\Routing\Attribute\Route;

final class TaskController
{
    public function __construct(
        private readonly CreateTaskUseCase $createTask,
        private readonly ListTasksUseCase $listTasks,
    ) {}

    // Маршруты задаются Symfony Routing через атрибуты #[Route(...)] на каждом методе
}
Можно сделать отдельный handler на каждый use-case (`CreateTaskHandler`, `ListTasksHandler`). Но на практике удобнее группировать по ресурсу: `TaskHandler` с методами `Create`, `List`, `Get`, `Complete`. Важно, чтобы каждый метод оставался тонким.

Полный пример: Create

type CreateTaskRequest struct {
    Title string `json:"title"`
}

type TaskResponse struct {
    ID        string `json:"id"`
    Title     string `json:"title"`
    Completed bool   `json:"completed"`
    CreatedAt string `json:"createdAt"`
}

func (h *TaskHandler) Create(w http.ResponseWriter, r *http.Request) {
    // 1. Парсим запрос.
    var req CreateTaskRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeError(w, http.StatusBadRequest, "invalid JSON")
        return
    }

    // 2. Достаём userID из контекста (установлен middleware).
    userID := r.Context().Value(userIDKey).(string)

    // 3. Вызываем use-case.
    task, err := h.createTask.Execute(r.Context(), usecase.CreateTaskInput{
        UserID: userID,
        Title:  req.Title,
    })
    if err != nil {
        writeUseCaseError(w, err)
        return
    }

    // 4. Формируем ответ.
    writeJSON(w, http.StatusCreated, TaskResponse{
        ID:        task.ID,
        Title:     task.Title,
        Completed: task.Completed,
        CreatedAt: task.CreatedAt.Format("2006-01-02T15:04:05Z"),
    })
}
<?php
// src/Infrastructure/Http/Controller/TaskController.php
declare(strict_types=1);

#[Route('/tasks', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
    // 1. Парсим запрос.
    $payload = json_decode($request->getContent(), true);
    if (!is_array($payload)) {
        return new JsonResponse(['error' => 'invalid JSON'], 400);
    }

    // 2. Достаём userId из request attributes (положил middleware).
    $userId = (string) $request->attributes->get('userId');

    // 3. Вызываем use-case.
    try {
        $task = $this->createTask->execute(new CreateTaskInput(
            userId: $userId,
            title: (string) ($payload['title'] ?? ''),
            description: '',
        ));
    } catch (\App\Domain\TaskError $e) {
        return $this->writeUseCaseError($e);
    }

    // 4. Формируем ответ.
    return new JsonResponse([
        'id' => $task->id(),
        'title' => $task->title(),
        'completed' => $task->isCompleted(),
    ], 201);
}

Обрати внимание: handler не знает, как генерируется ID, какие правила валидации применяются к Title, куда сохраняется задача. Он знает только про HTTP.

Маппинг доменных ошибок в HTTP-статусы

Use-case возвращает доменные ошибки (domain.ErrTaskNotFound, domain.ErrInvalidTitle). Handler превращает их в HTTP-коды.

func writeUseCaseError(w http.ResponseWriter, err error) {
    switch {
    case errors.Is(err, domain.ErrTaskNotFound):
        writeError(w, http.StatusNotFound, "task not found")
    case errors.Is(err, domain.ErrInvalidTitle):
        writeError(w, http.StatusBadRequest, err.Error())
    case errors.Is(err, domain.ErrTaskAlreadyCompleted):
        writeError(w, http.StatusConflict, "task already completed")
    default:
        writeError(w, http.StatusInternalServerError, "internal error")
    }
}

func writeError(w http.ResponseWriter, status int, msg string) {
    writeJSON(w, status, map[string]string{"error": msg})
}

func writeJSON(w http.ResponseWriter, status int, data any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    _ = json.NewEncoder(w).Encode(data)
}
<?php
// src/Infrastructure/Http/EventListener/DomainExceptionListener.php
declare(strict_types=1);

namespace App\Infrastructure\Http\EventListener;

use App\Domain\TaskAlreadyCompletedError;
use App\Domain\TaskNotFoundError;
use App\Domain\InvalidTitleError;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Attribute\AsEventListener;
use Psr\Log\LoggerInterface;

#[AsEventListener]
final class DomainExceptionListener
{
    public function __construct(
        private readonly LoggerInterface $logger,
    ) {}

    public function __invoke(ExceptionEvent $event): void
    {
        $e = $event->getThrowable();

        $response = match (true) {
            $e instanceof TaskNotFoundError =>
                new JsonResponse(['error' => 'task not found'], 404),
            $e instanceof InvalidTitleError =>
                new JsonResponse(['error' => $e->getMessage()], 400),
            $e instanceof TaskAlreadyCompletedError =>
                new JsonResponse(['error' => 'task already completed'], 409),
            default => null,
        };

        if ($response !== null) {
            $event->setResponse($response);
            return;
        }

        // Unknown exception - 500, детали в лог, не в ответ
        $this->logger->error('unhandled exception', ['exception' => $e]);
        $event->setResponse(new JsonResponse(['error' => 'internal error'], 500));
    }
}
Внутренние ошибки (`connection refused`, `sql: no rows`) могут содержать детали инфраструктуры. Для 500 всегда отдавай generic-сообщение, а полную ошибку логируй серверно через `slog.Error`.

Валидация: handler vs domain

Есть два уровня валидации:

ГдеЧто проверяемПример
HandlerФормат запросаJSON корректен, обязательные поля присутствуют
Domain/Use-caseБизнес-правилаTitle не пустой, длина <= 200 символов, у пользователя < 100 задач
// В handler - только формат:
if req.Title == "" {
    writeError(w, http.StatusBadRequest, "title is required")
    return
}

// В domain - бизнес-правило:
func (t *Task) SetTitle(title string) error {
    if len(title) > 200 {
        return ErrInvalidTitle
    }
    t.Title = title
    return nil
}
<?php
declare(strict_types=1);

// В controller - только формат запроса
if (!isset($payload['title']) || !is_string($payload['title'])) {
    return new JsonResponse(['error' => 'title is required'], 400);
}

// В domain - бизнес-правило
final class Task
{
    public function setTitle(string $title): void
    {
        if (mb_strlen($title) > 200) {
            throw InvalidTitleError::tooLong(200);
        }
        $this->title = $title;
    }
}

Не дублируй бизнес-валидацию в handler. Если правило «максимум 200 символов» есть и в handler, и в domain - при изменении лимита придётся менять два места.

Middleware и цепочка вызовов

Middleware - это тоже часть HTTP-адаптера. Типичная цепочка обработки запроса:

Цепочка обработки HTTP-запроса через middleware к use-case

func main() {
    r := chi.NewRouter()

    r.Use(middleware.Logger)
    r.Use(middleware.Recoverer)

    r.Route("/api", func(r chi.Router) {
        r.Use(AuthMiddleware(tokenService))

        taskHandler := http.NewTaskHandler(createTaskUC, listTasksUC)
        taskHandler.Routes(r)
    })
}
<?php
// src/Infrastructure/Http/EventListener/AuthListener.php
declare(strict_types=1);

namespace App\Infrastructure\Http\EventListener;

use App\Application\Port\TokenServicePort;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Exception\UnauthorizedHttpException;

#[AsEventListener(priority: 10)]
final class AuthListener
{
    public function __construct(
        private readonly TokenServicePort $tokens,
    ) {}

    public function __invoke(RequestEvent $event): void
    {
        $request = $event->getRequest();
        if (!str_starts_with($request->getPathInfo(), '/api')) {
            return;
        }

        $token = $request->headers->get('Authorization');
        $userId = $this->tokens->verify((string) $token);

        // Кладём userId в attributes - controller достанет через $request->attributes->get('userId')
        $request->attributes->set('userId', $userId);
    }
}

Auth middleware/listener извлекает токен, валидирует его и кладёт userID в context / request attributes. Handler/controller достаёт userID оттуда. Use-case получает userID как обычный параметр. Ни middleware, ни use-case не знают друг о друге.

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

// Плохо: handler решает, можно ли завершить задачу.
func (h *TaskHandler) Complete(w http.ResponseWriter, r *http.Request) {
    task, _ := h.repo.GetByID(r.Context(), chi.URLParam(r, "id"))

    if task.Completed {
        writeError(w, http.StatusConflict, "already done")
        return
    }
    if task.UserID != currentUserID(r) {
        writeError(w, http.StatusForbidden, "not your task")
        return
    }

    task.Completed = true
    _ = h.repo.Update(r.Context(), task)
    writeJSON(w, http.StatusOK, task)
}
<?php
// Плохо: controller знает про репозиторий, проверяет владельца и статус
#[Route('/tasks/{id}/complete', methods: ['POST'])]
public function completeBad(string $id, Request $request): JsonResponse
{
    $task = $this->tasks->getById($id);

    if ($task->isCompleted()) {
        return new JsonResponse(['error' => 'already done'], 409);
    }
    if ($task->userId() !== (string) $request->attributes->get('userId')) {
        return new JsonResponse(['error' => 'not your task'], 403);
    }

    // прямой вызов repo + мутация - бизнес-логика в HTTP-слое
    $task->complete();
    $this->tasks->update($task);

    return new JsonResponse(['ok' => true]);
}

Здесь handler напрямую работает с репозиторием и содержит бизнес-логику (проверка владельца, проверка статуса). При добавлении нового правила (например, «нельзя завершить задачу без описания») придётся менять handler. Правильный вариант:

// Хорошо: handler тонкий, логика в use-case.
func (h *TaskHandler) Complete(w http.ResponseWriter, r *http.Request) {
    taskID := chi.URLParam(r, "id")
    userID := currentUserID(r)

    err := h.completeTask.Execute(r.Context(), taskID, userID)
    if err != nil {
        writeUseCaseError(w, err)
        return
    }

    w.WriteHeader(http.StatusNoContent)
}
<?php
// Хорошо: controller тонкий, логика в use-case
#[Route('/tasks/{id}/complete', methods: ['POST'])]
public function complete(string $id, Request $request): Response
{
    try {
        $this->completeTask->execute(
            taskId: $id,
            userId: (string) $request->attributes->get('userId'),
        );
    } catch (\App\Domain\TaskError $e) {
        return $this->writeUseCaseError($e);
    }

    return new Response(status: 204);
}
Если handler зависит от `ports.TaskRepository` напрямую - это нарушение архитектуры. Handler зависит от use-case, use-case зависит от порта, порт реализуется адаптером. Три слоя, направление зависимостей всегда внутрь.

Мини-задание

  • Напиши handler GetTask, который принимает ID из URL, вызывает use-case и возвращает JSON
  • Реализуй функцию writeUseCaseError для своего проекта - маппинг минимум 3 доменных ошибок в HTTP-статусы
  • Проверь свои handler-ы: есть ли в них if-ы, которые относятся к бизнес-логике? Вынеси их в use-case
  • Убедись, что handler не импортирует пакет adapters/postgres или database/sql

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.