HTTP адаптер: handler/controller как тонкий слой
HTTP адаптер: handler как тонкий слой
HTTP handler - это первичный адаптер. Он принимает внешний запрос и преобразует его в вызов use-case. Вся ответственность handler сводится к трём шагам:
- Распарсить входящий запрос (JSON body, URL-параметры, заголовки).
- Вызвать use-case с доменными типами.
- Сформировать 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(...)] на каждом методе
}
Полный пример: 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));
}
}
Валидация: 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-адаптера. Типичная цепочка обработки запроса:
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
GetTask, который принимает ID из URL, вызывает use-case и возвращает JSON - Реализуй функцию
writeUseCaseErrorдля своего проекта - маппинг минимум 3 доменных ошибок в HTTP-статусы - Проверь свои handler-ы: есть ли в них
if-ы, которые относятся к бизнес-логике? Вынеси их в use-case - Убедись, что handler не импортирует пакет
adapters/postgresилиdatabase/sql