Мини‑проект: 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: 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() защищает инвариант «нельзя завершить дважды».
Но закрыть поля - это только половина. Адаптеру БД надо как-то собрать
объект из строки, и если сделать для этого функцию, принимающую что угодно,
проблема просто переедет: вместо литерала структуры инварианты будут
обходить через 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);
}
}
// 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, без БД, за миллисекунды
Тесты к этим сценариям пишутся на InMemoryTaskRepo, без Docker и миграций; table-driven шаблон и правило «фейк для репозитория, мок для side-effect» - в уроке Тестирование use-case без базы данных. Дальше домен можно усилить: вместо плоской структуры Task завести объекты-значения, агрегат с дочерними объектами и доменные события. Тот же путь пройден на более богатой модели в мини-проекте трека ddd-lite.
Мини-задание
- Создай структуру папок по шаблону выше, скопируй код из урока
- Добавь use-case
DeleteTaskс проверкой владельца - Напиши 3 теста на
CompleteTask: успех, задача не найдена, чужая задача - Замени
LogNotifierнаSlackNotifier, который шлёт webhook (адаптер для внешнего сервиса) - Запусти
go vet ./...и убедись, что нет ошибок