Мини-проект: 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` не импортирует никого. `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"
)

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);
    }
}
Все доменные ошибки маппятся в 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 (
    "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 и ни один тест не изменится. Это и есть гексагональная архитектура в действии.

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

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

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