Мини-проект: Tasks API в hex стиле (Go + PHP)
Мини-проект: Tasks API в hex-стиле
Соберём полноценный API для управления задачами с нуля. Каждый слой - отдельный пакет. Зависимости направлены внутрь. Всё, что изучали в предыдущих уроках, здесь складывается в рабочий проект.
Структура проекта
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: entity Task
// internal/domain/task.go
package domain
import (
"time"
"github.com/google/uuid"
)
type Task struct {
ID string
UserID string
Title string
Description string
Completed bool
CreatedAt time.Time
CompletedAt *time.Time
}
// 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
}
// 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")
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;
}
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;
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'); }
}
Вся бизнес-логика - внутри методов entity. NewTask гарантирует, что невалидный Task не может быть создан. Complete() защищает инвариант «нельзя завершить дважды».
Порты: интерфейсы
// 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 зависит от обоих через интерфейсы.
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"
"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: уведомление (ошибку не блокируем).
_ = uc.notifier.TaskCompleted(ctx, userID, task.Title)
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;
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);
}
}
Адаптеры
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);
}
}
// 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 (
"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, без БД, за миллисекунды
Мини-задание
- Создай структуру папок по шаблону выше, скопируй код из урока
- Добавь use-case
DeleteTaskс проверкой владельца - Напиши 3 теста на
CompleteTask: успех, задача не найдена, чужая задача - Замени
LogNotifierнаSlackNotifier, который шлёт webhook (адаптер для внешнего сервиса) - Запусти
go vet ./...и убедись, что нет ошибок