Мини‑проект: Tasks API в hex стиле (Go + PHP)

Мини-проект: Tasks API в hex-стиле

Соберём полноценный API для управления задачами с нуля. Каждый слой - отдельный пакет. Зависимости направлены внутрь. Всё, что изучали в предыдущих уроках, здесь складывается в рабочий проект. Если по ходу станет непонятно, почему пакеты расставлены именно так, вернись к уроку Ports & Adapters: направление зависимостей задаёт именно он.

Структура проекта

tasks-api/
├── cmd/
│   └── server/
│       └── main.go              # точка входа, DI, запуск сервера
├── internal/
│   ├── domain/
│   │   ├── task.go              # entity Task + методы
│   │   └── errors.go            # доменные ошибки
│   ├── usecase/
│   │   ├── ports/
│   │   │   ├── task_repo.go     # интерфейс TaskRepository
│   │   │   └── notifier.go      # интерфейс TaskNotifier
│   │   ├── create_task.go       # use-case: создать задачу
│   │   ├── complete_task.go     # use-case: завершить задачу
│   │   └── list_tasks.go        # use-case: список задач
│   └── adapters/
│       ├── postgres/
│       │   └── task_repo.go     # PostgresTaskRepo
│       ├── inmemory/
│       │   └── task_repo.go     # InMemoryTaskRepo (тесты)
│       └── http/
│           ├── handler.go       # TaskHandler
│           └── response.go      # JSON helpers
├── go.mod
└── go.sum
`domain` не импортирует никого. `usecase` импортирует только `domain`. `adapters` импортирует `domain` и `usecase/ports`. `cmd` импортирует всех - здесь происходит сборка.

Слои проекта Tasks API: cmd собирает всё, adapters реализуют порты, use-case в центре, domain ниже всех

Domain: entity Task

// internal/domain/task.go
package domain

import (
    "time"

    "github.com/google/uuid"
)

// Task - доменная сущность с закрытыми полями.
//
// Поля неэкспортированы не для красоты: с экспортированными полями любой
// пакет пишет `domain.Task{}` литералом или `t.Title = ""` напрямую,
// и тогда утверждение «невалидный Task создать нельзя» становится
// неправдой - валидация в NewTask просто обходится.
type Task struct {
    id          string
    userID      string
    title       string
    description string
    completed   bool
    createdAt   time.Time
    completedAt *time.Time
}

// Геттеры: наружу отдаём значения, менять состояние можно только методами ниже.
func (t Task) ID() string             { return t.id }
func (t Task) UserID() string         { return t.userID }
func (t Task) Title() string          { return t.title }
func (t Task) Description() string    { return t.description }
func (t Task) Completed() bool        { return t.completed }
func (t Task) CreatedAt() time.Time   { return t.createdAt }
func (t Task) CompletedAt() *time.Time { return t.completedAt }

// NewTask создаёт задачу с валидацией.
func NewTask(userID, title, description string) (Task, error) {
    if userID == "" {
        return Task{}, ErrInvalidUserID
    }
    if title == "" || len(title) > 200 {
        return Task{}, ErrInvalidTitle
    }

    return Task{
        id:          uuid.NewString(),
        userID:      userID,
        title:       title,
        description: description,
        completed:   false,
        createdAt:   time.Now(),
    }, nil
}

// TaskFromStorage восстанавливает задачу из строки БД.
//
// Отдельная функция нужна потому, что закрытые поля иначе не заполнить
// из пакета-адаптера. Но «отдельная функция» не значит «дыра в инвариантах»:
// если сюда можно передать что угодно, проблема просто переехала из литерала
// структуры в вызов этой функции.
//
// Политика такая:
//
//   - структурные инварианты проверяем всегда (пустой id, пустой userID,
//     пустой или слишком длинный title). Их нарушение означает, что в базе
//     лежит мусор, и тащить его в домен нельзя - лучше явная ошибка
//     на чтении, чем непонятное поведение через три слоя;
//   - остальное считаем доверенным состоянием: время создания, флаг
//     выполнения и завершения пришли из нашей же записи, повторно
//     их «бизнес-проверять» бессмысленно. Например, задача может быть
//     completed - через NewTask такую не создать, и это нормально:
//     это результат вызова Complete() когда-то раньше.
//
// Что делать с уже испорченными строками, если они есть: чинить их
// миграцией, а не ослаблять проверку. Ошибка на чтении покажет, сколько
// таких строк и где.
func TaskFromStorage(
    id, userID, title, description string,
    completed bool,
    createdAt time.Time,
    completedAt *time.Time,
) (Task, error) {
    if id == "" || userID == "" {
        return Task{}, ErrCorruptedTask
    }
    if title == "" || len(title) > 200 {
        return Task{}, ErrCorruptedTask
    }

    return Task{
        id:          id,
        userID:      userID,
        title:       title,
        description: description,
        completed:   completed,
        createdAt:   createdAt,
        completedAt: completedAt,
    }, nil
}

// Complete отмечает задачу выполненной.
func (t *Task) Complete() error {
    if t.completed {
        return ErrTaskAlreadyCompleted
    }
    t.completed = true
    now := time.Now()
    t.completedAt = &now
    return nil
}

// SetTitle обновляет заголовок с валидацией.
func (t *Task) SetTitle(title string) error {
    if title == "" || len(title) > 200 {
        return ErrInvalidTitle
    }
    t.title = title
    return nil
}
// internal/domain/errors.go
package domain

import "errors"

var (
    ErrTaskNotFound         = errors.New("task not found")
    ErrTaskAlreadyCompleted = errors.New("task already completed")
    ErrInvalidTitle         = errors.New("invalid title: must be 1-200 characters")
    ErrInvalidUserID        = errors.New("invalid user ID")
    // ErrCorruptedTask - строка в базе не проходит структурные инварианты.
    // Отдельная ошибка, потому что причина другая: не пользователь ошибся,
    // а данные повреждены. Наружу это 500, а не 400.
    ErrCorruptedTask        = errors.New("corrupted task in storage")
    ErrAccessDenied         = errors.New("access denied")
)
<?php
// src/Domain/Task.php
declare(strict_types=1);

namespace App\Domain;

use DateTimeImmutable;
use Symfony\Component\Uid\Uuid;

final class Task
{
    private bool $completed = false;
    private ?DateTimeImmutable $completedAt = null;

    private function __construct(
        private readonly string $id,
        private readonly string $userId,
        private string $title,
        private string $description,
        private readonly DateTimeImmutable $createdAt,
    ) {}

    public static function create(string $userId, string $title, string $description): self
    {
        if ($userId === '') {
            throw TaskError::invalidUserId();
        }
        if ($title === '' || mb_strlen($title) > 200) {
            throw TaskError::invalidTitle();
        }

        return new self(
            id: Uuid::v4()->toRfc4122(),
            userId: $userId,
            title: $title,
            description: $description,
            createdAt: new DateTimeImmutable(),
        );
    }

    public function complete(): void
    {
        if ($this->completed) {
            throw TaskError::alreadyCompleted();
        }
        $this->completed = true;
        $this->completedAt = new DateTimeImmutable();
    }

    public function setTitle(string $title): void
    {
        if ($title === '' || mb_strlen($title) > 200) {
            throw TaskError::invalidTitle();
        }
        $this->title = $title;
    }

    /**
     * Восстановление из строки БД.
     *
     * Конструктор приватный, поэтому адаптеру нужна отдельная точка входа.
     * Структурные инварианты проверяются и здесь: если в базе пустой id
     * или пустой title, это повреждённые данные, и пускать их в домен
     * нельзя. Доверенным считается только то, что через бизнес-правила
     * пройти не может по определению - например, уже выставленный
     * $completed: он результат вызова complete() когда-то раньше.
     */
    public static function fromStorage(
        string $id,
        string $userId,
        string $title,
        bool $completed,
        string $description = '',
        ?DateTimeImmutable $createdAt = null,
        ?DateTimeImmutable $completedAt = null,
    ): self {
        if ($id === '' || $userId === '' || $title === '') {
            throw TaskError::corrupted($id);
        }

        $task = new self(
            id: $id,
            userId: $userId,
            title: $title,
            description: $description,
            createdAt: $createdAt ?? new DateTimeImmutable(),
        );
        $task->completed = $completed;
        $task->completedAt = $completedAt;

        return $task;
    }

    public function id(): string { return $this->id; }
    public function userId(): string { return $this->userId; }
    public function title(): string { return $this->title; }
    public function isCompleted(): bool { return $this->completed; }
}
<?php
// src/Domain/TaskError.php
declare(strict_types=1);

namespace App\Domain;

use DomainException;

final class TaskError extends DomainException
{
    public static function notFound(): self { return new self('task not found'); }
    public static function alreadyCompleted(): self { return new self('task already completed'); }
    public static function invalidTitle(): self { return new self('invalid title: must be 1-200 characters'); }
    public static function invalidUserId(): self { return new self('invalid user id'); }
    public static function accessDenied(): self { return new self('access denied'); }

    /** Строка в БД не проходит структурные инварианты: наружу это 500, а не 400. */
    public static function corrupted(string $id): self
    {
        return new self(sprintf('corrupted task in storage: id=%s', $id));
    }
}

Вся бизнес-логика - внутри методов entity. NewTask гарантирует, что невалидный Task не может быть создан. Complete() защищает инвариант «нельзя завершить дважды».

Раньше Go-структура здесь имела экспортированные поля, и утверждение выше было неправдой: `domain.Task{Title: ""}` собирается, `t.Completed = true` обходит `Complete()` вместе с проверкой «нельзя завершить дважды». Валидация в конструкторе защищает ровно до тех пор, пока конструктор - единственный путь. PHP-версия в этом же уроке была сделана правильно с самого начала (приватный конструктор), и расхождение между двумя языками в одном уроке учило разному.

Но закрыть поля - это только половина. Адаптеру БД надо как-то собрать объект из строки, и если сделать для этого функцию, принимающую что угодно, проблема просто переедет: вместо литерала структуры инварианты будут обходить через TaskFromStorage(...).

Поэтому у восстановления есть явная политика, и она написана рядом с функцией:

ЧтоКак обрабатываетсяПочему
пустой id, userID, title; слишком длинный titleошибка ErrCorruptedTaskэто не бизнес-случай, а повреждённые данные; тащить их в домен хуже, чем упасть на чтении
completed, completedAt, createdAtпринимаются как естьдоверенное состояние: результат вызовов, которые уже прошли валидацию раньше

Обратите внимание на вторую строку: через NewTask завершённую задачу создать нельзя, а TaskFromStorage её принимает - и это правильно. Восстановление воспроизводит прошлое состояние, а не создаёт новое. Требовать от него бизнес-правил создания значит запретить читать из базы всё, что было изменено после создания.

Что делать с уже испорченными строками в существующей базе: чинить миграцией, а не ослаблять проверку. Ошибка на чтении хотя бы покажет, сколько их и где.

Task здесь - сущность: у неё есть идентификатор и жизненный цикл, а два разных Task с одинаковым заголовком остаются разными задачами. Если захочется усилить модель и вынести, скажем, заголовок в самовалидирующийся тип вместо string, начни с урока Entity и Value Object. А само слово «инвариант» и приёмы его защиты (приватные поля, единственная точка входа для изменений) разобраны в уроке Aggregates и инварианты.

Порты: интерфейсы

// internal/usecase/ports/task_repo.go
package ports

import (
    "context"

    "tasks-api/internal/domain"
)

type TaskRepository interface {
    Create(ctx context.Context, task domain.Task) error
    GetByID(ctx context.Context, id string) (domain.Task, error)
    ListByUser(ctx context.Context, userID string) ([]domain.Task, error)
    Update(ctx context.Context, task domain.Task) error
}
// internal/usecase/ports/notifier.go
package ports

import "context"

type TaskNotifier interface {
    TaskCompleted(ctx context.Context, userID, taskTitle string) error
}
<?php
// src/Application/Port/TaskRepositoryPort.php
declare(strict_types=1);

namespace App\Application\Port;

use App\Domain\Task;

interface TaskRepositoryPort
{
    public function create(Task $task): void;

    public function getById(string $id): Task;

    /** @return list<Task> */
    public function listByUser(string $userId): array;

    public function update(Task $task): void;
}
<?php
// src/Application/Port/TaskNotifierPort.php
declare(strict_types=1);

namespace App\Application\Port;

interface TaskNotifierPort
{
    public function taskCompleted(string $userId, string $taskTitle): void;
}

Два порта - два контракта. Репозиторий для persistence, notifier для side-effect (email, push, webhook). Use-case зависит от обоих через интерфейсы. Обрати внимание, что оба интерфейса лежат в usecase/ports/, а не рядом с реализациями: почему размещение интерфейса определяет направление зависимостей, разобрано в уроке Репозитории и интерфейсы.

Use-cases

CreateTask

// internal/usecase/create_task.go
package usecase

import (
    "context"

    "tasks-api/internal/domain"
    "tasks-api/internal/usecase/ports"
)

type CreateTaskInput struct {
    UserID      string
    Title       string
    Description string
}

type CreateTask struct {
    repo ports.TaskRepository
}

func NewCreateTask(repo ports.TaskRepository) *CreateTask {
    return &CreateTask{repo: repo}
}

func (uc *CreateTask) Execute(ctx context.Context, in CreateTaskInput) (domain.Task, error) {
    task, err := domain.NewTask(in.UserID, in.Title, in.Description)
    if err != nil {
        return domain.Task{}, err
    }

    if err := uc.repo.Create(ctx, task); err != nil {
        return domain.Task{}, err
    }
    return task, nil
}
<?php
// src/Application/UseCase/CreateTaskUseCase.php
declare(strict_types=1);

namespace App\Application\UseCase;

use App\Application\Port\TaskRepositoryPort;
use App\Domain\Task;

final readonly class CreateTaskInput
{
    public function __construct(
        public string $userId,
        public string $title,
        public string $description,
    ) {}
}

final class CreateTaskUseCase
{
    public function __construct(
        private readonly TaskRepositoryPort $tasks,
    ) {}

    public function execute(CreateTaskInput $in): Task
    {
        $task = Task::create($in->userId, $in->title, $in->description);
        $this->tasks->create($task);
        return $task;
    }
}

CompleteTask

// internal/usecase/complete_task.go
package usecase

import (
    "context"
    "log/slog"

    "tasks-api/internal/domain"
    "tasks-api/internal/usecase/ports"
)

type CompleteTask struct {
    repo     ports.TaskRepository
    notifier ports.TaskNotifier
}

func NewCompleteTask(repo ports.TaskRepository, notifier ports.TaskNotifier) *CompleteTask {
    return &CompleteTask{repo: repo, notifier: notifier}
}

func (uc *CompleteTask) Execute(ctx context.Context, taskID, userID string) error {
    task, err := uc.repo.GetByID(ctx, taskID)
    if err != nil {
        return err
    }

    // Проверка владельца - доменное правило.
    if task.UserID() != userID {
        return domain.ErrAccessDenied
    }

    if err := task.Complete(); err != nil {
        return err
    }

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

    // Side-effect не блокирует операцию, но и не исчезает бесследно:
    // молча проглоченная ошибка - это отключённое уведомление, о котором
    // никто не узнает.
    if err := uc.notifier.TaskCompleted(ctx, userID, task.Title()); err != nil {
        slog.ErrorContext(ctx, "task completion notification failed",
            slog.String("task_id", taskID),
            slog.String("user_id", userID),
            slog.String("err", err.Error()),
        )
    }

    return nil
}
<?php
// src/Application/UseCase/CompleteTaskUseCase.php
declare(strict_types=1);

namespace App\Application\UseCase;

use App\Application\Port\TaskNotifierPort;
use App\Application\Port\TaskRepositoryPort;
use App\Domain\TaskError;
use Throwable;

final class CompleteTaskUseCase
{
    public function __construct(
        private readonly TaskRepositoryPort $tasks,
        private readonly TaskNotifierPort $notifier,
    ) {}

    public function execute(string $taskId, string $userId): void
    {
        $task = $this->tasks->getById($taskId);

        if ($task->userId() !== $userId) {
            throw TaskError::accessDenied();
        }

        $task->complete();
        $this->tasks->update($task);

        try {
            $this->notifier->taskCompleted($userId, $task->title());
        } catch (Throwable) {
            // side-effect failure does not block business operation
        }
    }
}

ListTasks

// internal/usecase/list_tasks.go
package usecase

import (
    "context"

    "tasks-api/internal/domain"
    "tasks-api/internal/usecase/ports"
)

type ListTasks struct {
    repo ports.TaskRepository
}

func NewListTasks(repo ports.TaskRepository) *ListTasks {
    return &ListTasks{repo: repo}
}

func (uc *ListTasks) Execute(ctx context.Context, userID string) ([]domain.Task, error) {
    return uc.repo.ListByUser(ctx, userID)
}
<?php
// src/Application/UseCase/ListTasksUseCase.php
declare(strict_types=1);

namespace App\Application\UseCase;

use App\Application\Port\TaskRepositoryPort;
use App\Domain\Task;

final class ListTasksUseCase
{
    public function __construct(
        private readonly TaskRepositoryPort $tasks,
    ) {}

    /** @return list<Task> */
    public function execute(string $userId): array
    {
        return $this->tasks->listByUser($userId);
    }
}

ListTasks получился в одну строку, и это нормально: сценарию нечего решать, он просто делегирует репозиторию. Толстеть use-case начинает, когда в Execute заводятся расчёты вместо вызовов - признаки такого перерождения и порог «40-50 строк» описаны в уроке Use Case: сценарии вместо «толстых сервисов».

Адаптеры

InMemoryTaskRepo

// internal/adapters/inmemory/task_repo.go
package inmemory

import (
    "context"
    "sync"

    "tasks-api/internal/domain"
)

type TaskRepo struct {
    mu    sync.RWMutex
    store map[string]domain.Task
}

func NewTaskRepo() *TaskRepo {
    return &TaskRepo{store: make(map[string]domain.Task)}
}

func (r *TaskRepo) Create(_ context.Context, task domain.Task) error {
    r.mu.Lock()
    defer r.mu.Unlock()
    r.store[task.ID()] = task
    return nil
}

func (r *TaskRepo) GetByID(_ context.Context, id string) (domain.Task, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()
    t, ok := r.store[id]
    if !ok {
        return domain.Task{}, domain.ErrTaskNotFound
    }
    return t, nil
}

func (r *TaskRepo) ListByUser(_ context.Context, userID string) ([]domain.Task, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()
    var result []domain.Task
    for _, t := range r.store {
        if t.UserID() == userID {
            result = append(result, t)
        }
    }
    return result, nil
}

func (r *TaskRepo) Update(_ context.Context, task domain.Task) error {
    r.mu.Lock()
    defer r.mu.Unlock()
    if _, ok := r.store[task.ID()]; !ok {
        return domain.ErrTaskNotFound
    }
    r.store[task.ID()] = task
    return nil
}
<?php
// src/Infrastructure/Persistence/InMemory/InMemoryTaskRepository.php
declare(strict_types=1);

namespace App\Infrastructure\Persistence\InMemory;

use App\Application\Port\TaskRepositoryPort;
use App\Domain\Task;
use App\Domain\TaskError;

final class InMemoryTaskRepository implements TaskRepositoryPort
{
    /** @var array<string, Task> */
    private array $store = [];

    public function create(Task $task): void
    {
        $this->store[$task->id()] = $task;
    }

    public function getById(string $id): Task
    {
        if (!isset($this->store[$id])) {
            throw TaskError::notFound();
        }
        return $this->store[$id];
    }

    /** @return list<Task> */
    public function listByUser(string $userId): array
    {
        $result = [];
        foreach ($this->store as $task) {
            if ($task->userId() === $userId) {
                $result[] = $task;
            }
        }
        return $result;
    }

    public function update(Task $task): void
    {
        if (!isset($this->store[$task->id()])) {
            throw TaskError::notFound();
        }
        $this->store[$task->id()] = $task;
    }
}

HTTP Handler

// internal/adapters/http/handler.go
package http

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

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

    "tasks-api/internal/domain"
    "tasks-api/internal/usecase"
)

type TaskHandler struct {
    create   *usecase.CreateTask
    complete *usecase.CompleteTask
    list     *usecase.ListTasks
}

func NewTaskHandler(c *usecase.CreateTask, comp *usecase.CompleteTask, l *usecase.ListTasks) *TaskHandler {
    return &TaskHandler{create: c, complete: comp, list: l}
}

func (h *TaskHandler) Routes(r chi.Router) {
    r.Post("/tasks", h.CreateTask)
    r.Post("/tasks/{id}/complete", h.CompleteTask)
    r.Get("/tasks", h.ListTasks)
}

func (h *TaskHandler) CreateTask(w http.ResponseWriter, r *http.Request) {
    var req struct {
        Title       string `json:"title"`
        Description string `json:"description"`
    }
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeError(w, http.StatusBadRequest, "invalid JSON")
        return
    }

    userID := r.Context().Value("userID").(string)

    task, err := h.create.Execute(r.Context(), usecase.CreateTaskInput{
        UserID:      userID,
        Title:       req.Title,
        Description: req.Description,
    })
    if err != nil {
        mapError(w, err)
        return
    }

    writeJSON(w, http.StatusCreated, task)
}

func (h *TaskHandler) CompleteTask(w http.ResponseWriter, r *http.Request) {
    taskID := chi.URLParam(r, "id")
    userID := r.Context().Value("userID").(string)

    if err := h.complete.Execute(r.Context(), taskID, userID); err != nil {
        mapError(w, err)
        return
    }
    w.WriteHeader(http.StatusNoContent)
}

func (h *TaskHandler) ListTasks(w http.ResponseWriter, r *http.Request) {
    userID := r.Context().Value("userID").(string)
    tasks, err := h.list.Execute(r.Context(), userID)
    if err != nil {
        mapError(w, err)
        return
    }
    writeJSON(w, http.StatusOK, tasks)
}
<?php
// src/Infrastructure/Http/Controller/TaskController.php
declare(strict_types=1);

namespace App\Infrastructure\Http\Controller;

use App\Application\UseCase\CompleteTaskUseCase;
use App\Application\UseCase\CreateTaskInput;
use App\Application\UseCase\CreateTaskUseCase;
use App\Application\UseCase\ListTasksUseCase;
use App\Domain\TaskError;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class TaskController
{
    public function __construct(
        private readonly CreateTaskUseCase $create,
        private readonly CompleteTaskUseCase $complete,
        private readonly ListTasksUseCase $list,
    ) {}

    #[Route('/tasks', methods: ['POST'])]
    public function createTask(Request $request): JsonResponse
    {
        $payload = json_decode($request->getContent(), true);
        if (!is_array($payload)) {
            return new JsonResponse(['error' => 'invalid JSON'], 400);
        }

        $userId = (string) $request->attributes->get('userId');

        try {
            $task = $this->create->execute(new CreateTaskInput(
                userId: $userId,
                title: (string) ($payload['title'] ?? ''),
                description: (string) ($payload['description'] ?? ''),
            ));
        } catch (TaskError $e) {
            return $this->mapError($e);
        }

        return new JsonResponse([
            'id' => $task->id(),
            'title' => $task->title(),
            'completed' => $task->isCompleted(),
        ], 201);
    }

    #[Route('/tasks/{id}/complete', methods: ['POST'])]
    public function completeTask(string $id, Request $request): Response
    {
        $userId = (string) $request->attributes->get('userId');

        try {
            $this->complete->execute($id, $userId);
        } catch (TaskError $e) {
            return $this->mapError($e);
        }

        return new Response(status: 204);
    }

    #[Route('/tasks', methods: ['GET'])]
    public function listTasks(Request $request): JsonResponse
    {
        $userId = (string) $request->attributes->get('userId');
        $tasks = $this->list->execute($userId);

        return new JsonResponse(array_map(
            static fn ($task) => [
                'id' => $task->id(),
                'title' => $task->title(),
                'completed' => $task->isCompleted(),
            ],
            $tasks,
        ));
    }

    private function mapError(TaskError $e): JsonResponse
    {
        $status = match ($e->getMessage()) {
            'task not found' => 404,
            'access denied' => 403,
            'task already completed' => 409,
            default => 400,
        };
        return new JsonResponse(['error' => $e->getMessage()], $status);
    }
}
Все доменные ошибки маппятся в HTTP-статусы в одной функции. Не разбрасывай `switch err` по handler-ам - потеряешь контроль.
// internal/adapters/http/response.go
package http

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

    "tasks-api/internal/domain"
)

func mapError(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),
        errors.Is(err, domain.ErrInvalidUserID):
        writeError(w, http.StatusBadRequest, err.Error())
    case errors.Is(err, domain.ErrTaskAlreadyCompleted):
        writeError(w, http.StatusConflict, err.Error())
    case errors.Is(err, domain.ErrAccessDenied):
        writeError(w, http.StatusForbidden, "access denied")
    default:
        writeError(w, http.StatusInternalServerError, "internal 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, map[string]string{"error": msg})
}
<?php
// src/Infrastructure/Http/EventListener/TaskExceptionListener.php
// Глобальный маппинг доменных исключений → HTTP-статусы в одном месте
declare(strict_types=1);

namespace App\Infrastructure\Http\EventListener;

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

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

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

        if ($e instanceof TaskError) {
            $status = match ($e->getMessage()) {
                'task not found' => 404,
                'access denied' => 403,
                'task already completed' => 409,
                default => 400, // invalid title / invalid user id
            };
            $event->setResponse(new JsonResponse(['error' => $e->getMessage()], $status));
            return;
        }

        // Неизвестное исключение - 500, детали в лог
        $this->logger->error('unhandled exception', ['exception' => $e]);
        $event->setResponse(new JsonResponse(['error' => 'internal error'], 500));
    }
}

Сборка: main.go

// cmd/server/main.go
package main

import (
    "context"
    "log/slog"
    "net/http"
    "os"

    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
    adapter "tasks-api/internal/adapters/http"
    "tasks-api/internal/adapters/inmemory"
    "tasks-api/internal/usecase"
)

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    slog.SetDefault(logger)

    // Adapters
    taskRepo := inmemory.NewTaskRepo()
    notifier := &LogNotifier{} // заглушка

    // Use-cases
    createTask := usecase.NewCreateTask(taskRepo)
    completeTask := usecase.NewCompleteTask(taskRepo, notifier)
    listTasks := usecase.NewListTasks(taskRepo)

    // HTTP
    handler := adapter.NewTaskHandler(createTask, completeTask, listTasks)

    r := chi.NewRouter()
    r.Use(middleware.Logger)
    r.Use(middleware.Recoverer)
    r.Route("/api", func(r chi.Router) {
        handler.Routes(r)
    })

    slog.Info("server starting", slog.String("addr", ":8080"))
    if err := http.ListenAndServe(":8080", r); err != nil {
        slog.Error("server failed", slog.String("err", err.Error()))
        os.Exit(1)
    }
}

// LogNotifier - заглушка для TaskNotifier.
type LogNotifier struct{}

func (n *LogNotifier) TaskCompleted(_ context.Context, userID, title string) error {
    slog.Info("task completed",
        slog.String("userID", userID),
        slog.String("title", title),
    )
    return nil
}
<?php
// src/Infrastructure/Notifier/LogNotifier.php - заглушка для TaskNotifierPort
declare(strict_types=1);

namespace App\Infrastructure\Notifier;

use App\Application\Port\TaskNotifierPort;
use Psr\Log\LoggerInterface;

final class LogNotifier implements TaskNotifierPort
{
    public function __construct(
        private readonly LoggerInterface $logger,
    ) {}

    public function taskCompleted(string $userId, string $taskTitle): void
    {
        $this->logger->info('task completed', [
            'userId' => $userId,
            'title' => $taskTitle,
        ]);
    }
}

В Symfony сборка зависимостей описывается декларативно в config/services.yaml - autowiring находит конструкторы и подставляет реализации по типу:

# config/services.yaml
services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\:
        resource: '../src/'

    # Связываем порты с реализациями (Domain/Application видит только интерфейсы)
    App\Application\Port\TaskRepositoryPort:
        alias: App\Infrastructure\Persistence\InMemory\InMemoryTaskRepository

    App\Application\Port\TaskNotifierPort:
        alias: App\Infrastructure\Notifier\LogNotifier

Вся сборка - в main.go / config/services.yaml. Только здесь создаются конкретные реализации и передаются в конструкторы. Ни один внутренний пакет не знает о других адаптерах. Чтобы переключиться с in-memory на Doctrine - меняется одна строка в services.yaml, use-case и тесты остаются как есть.

Как запустить

# Инициализация модуля
go mod init tasks-api
go mod tidy

# Запуск
go run ./cmd/server/

# Проверка
curl -X POST http://localhost:8080/api/tasks \
 -H "Content-Type: application/json" \
 -d '{"title": "Изучить hex-arch", "description": "Разобраться с портами и адаптерами"}'

curl http://localhost:8080/api/tasks

Чек-лист перед «отправкой»

Пройдись по проекту и проверь каждый пункт:

  • domain/ не импортирует ничего, кроме стандартной библиотеки
  • usecase/ импортирует только domain - никаких database/sql, net/http, chi
  • adapters/postgres/ импортирует domain и usecase/ports, но не adapters/http
  • adapters/http/ импортирует domain и usecase, но не adapters/postgres
  • Все доменные ошибки определены в domain/errors.go
  • Handler не содержит бизнес-логики (нет if task.Completed(), нет прямого вызова repo)
  • InMemoryRepo проходит те же тесты, что и PostgresRepo (одинаковый интерфейс)
  • Тесты use-case запускаются без Docker, без БД, за миллисекунды
Замени `inmemory.NewTaskRepo()` на `postgres.NewTaskRepo(pool)` в main.go - и проект работает с реальной базой. Ни один use-case и ни один тест не изменится. Это и есть гексагональная архитектура в действии.

Тесты к этим сценариям пишутся на InMemoryTaskRepo, без Docker и миграций; table-driven шаблон и правило «фейк для репозитория, мок для side-effect» - в уроке Тестирование use-case без базы данных. Дальше домен можно усилить: вместо плоской структуры Task завести объекты-значения, агрегат с дочерними объектами и доменные события. Тот же путь пройден на более богатой модели в мини-проекте трека ddd-lite.

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

  • Создай структуру папок по шаблону выше, скопируй код из урока
  • Добавь use-case DeleteTask с проверкой владельца
  • Напиши 3 теста на CompleteTask: успех, задача не найдена, чужая задача
  • Замени LogNotifier на SlackNotifier, который шлёт webhook (адаптер для внешнего сервиса)
  • Запусти go vet ./... и убедись, что нет ошибок

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