Гексагональная архитектура: зачем она нужна

Гексагональная архитектура: зачем она нужна

Гексагональная архитектура (Hexagonal Architecture, она же Ports & Adapters) - подход к организации кода, предложенный Алистером Кокбёрном в 2005 году. Идея простая: бизнес-логика живёт в центре приложения и ничего не знает о базе данных, HTTP, очередях или файловой системе. Вся инфраструктура подключается снаружи через чётко определённые интерфейсы. Перекликается с DDD.

Проблема: «жирный» handler

Представь типичный backend-проект на старте. Один handler/controller делает всё: принимает HTTP-запрос, валидирует данные, лезет в базу, отправляет email, формирует ответ. Выглядит это примерно так:

func CreateOrderHandler(w http.ResponseWriter, r *http.Request) {
    var req struct {
        UserID    int64   `json:"user_id"`
        ProductID int64   `json:"product_id"`
        Quantity  int     `json:"quantity"`
    }
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "bad request", 400)
        return
    }

    // Валидация - бизнес-логика прямо в handler
    if req.Quantity <= 0 || req.Quantity > 100 {
        http.Error(w, "invalid quantity", 400)
        return
    }

    // SQL - инфраструктура прямо в handler
    var price float64
    err := db.QueryRow("SELECT price FROM products WHERE id = $1", req.ProductID).Scan(&price)
    if err != nil {
        http.Error(w, "product not found", 404)
        return
    }

    total := price * float64(req.Quantity)

    // Ещё SQL
    _, err = db.Exec(
        "INSERT INTO orders (user_id, product_id, quantity, total) VALUES ($1,$2,$3,$4)",
        req.UserID, req.ProductID, req.Quantity, total,
    )
    if err != nil {
        http.Error(w, "internal error", 500)
        return
    }

    // Отправка email - ещё одна инфраструктурная деталь
    smtp.SendMail(smtpAddr, auth, "shop@example.com",
        []string{userEmail}, []byte("Your order is placed!"))

    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(map[string]float64{"total": total})
}
<?php
// Аналогичный «толстый» controller в Symfony
final class OrderController
{
    public function __construct(
        private readonly \PDO $pdo,
        private readonly \Symfony\Component\Mailer\MailerInterface $mailer,
    ) {}

    public function createOrder(Request $request): JsonResponse
    {
        $req = json_decode($request->getContent(), true);
        $userId = (int) ($req['user_id'] ?? 0);
        $productId = (int) ($req['product_id'] ?? 0);
        $quantity = (int) ($req['quantity'] ?? 0);

        // Валидация - бизнес-логика прямо в controller
        if ($quantity <= 0 || $quantity > 100) {
            return new JsonResponse(['error' => 'invalid quantity'], 400);
        }

        // SQL - инфраструктура прямо в controller
        $stmt = $this->pdo->prepare('SELECT price FROM products WHERE id = :id');
        $stmt->execute(['id' => $productId]);
        $price = $stmt->fetchColumn();
        if ($price === false) {
            return new JsonResponse(['error' => 'product not found'], 404);
        }

        $total = (float) $price * $quantity;

        // Ещё SQL
        $insert = $this->pdo->prepare(
            'INSERT INTO orders (user_id, product_id, quantity, total) VALUES (:u, :p, :q, :t)',
        );
        $insert->execute(['u' => $userId, 'p' => $productId, 'q' => $quantity, 't' => $total]);

        // Отправка email - ещё одна инфраструктурная деталь
        $this->mailer->send((new \Symfony\Component\Mime\Email())
 ->from('shop@example.com')
 ->to('user@example.com')
 ->text('Your order is placed!'));

        return new JsonResponse(['total' => $total], 201);
    }
}

70 строк. Один handler. Три ответственности. И это ещё простой случай.

Почему это больно

Такой код создаёт проблемы по трём направлениям:

Тестирование. Чтобы протестировать логику расчёта total, нужно поднять HTTP-сервер, базу данных и SMTP-сервер. Юнит-тест написать невозможно - всё склеено.

Замена инфраструктуры. Решили перейти с PostgreSQL на MongoDB? Или отправлять уведомления через Telegram вместо email? Придётся переписывать handler, а вместе с ним рискуешь сломать бизнес-логику.

Повторное использование. Если тот же сценарий «создать заказ» нужен из gRPC или CLI - придётся дублировать код или вытаскивать логику задним числом.

Если в одном файле ты видишь `import "database/sql"`, `import "net/http"` и бизнес-правила - это сигнал, что слои смешались. Любое изменение в одном месте рискует сломать другое.

Слоистая vs гексагональная архитектура

Классическая трёхслойная архитектура (Controller → Service → Repository) - шаг вперёд по сравнению с «всё в handler». Но у неё есть ограничение: зависимости идут сверху вниз, и сервисный слой часто напрямую импортирует конкретные реализации репозиториев.

Сравнение слоистой и гексагональной архитектуры: направление зависимостей

В гексагональной архитектуре use-case зависит от интерфейса (порта), а не от конкретной реализации. Реализация (адаптер) подставляется снаружи.

Dependency Rule: домен не импортирует инфраструктуру

Главное правило - **направление зависимостей**. Внутренние слои не знают о внешних: домен лежит в центре, вокруг него use-cases, затем порты, и только снаружи адаптеры с HTTP, SQL, SMTP. Стрелка зависимости всегда указывает внутрь.

На практике это выглядит как правило импортов: доменный файл импортирует только стандартную библиотеку.

// domain/order.go - НЕЛЬЗЯ импортировать database/sql, net/http
package domain

type Order struct {
    ID        int64
    UserID    int64
    ProductID int64
    Quantity  int
    Total     float64
}

func NewOrder(userID, productID int64, quantity int, price float64) (*Order, error) {
    if quantity <= 0 || quantity > 100 {
        return nil, ErrInvalidQuantity
    }
    return &Order{
        UserID:    userID,
        ProductID: productID,
        Quantity:  quantity,
        Total:     price * float64(quantity),
    }, nil
}
<?php
// src/Domain/Order.php - НЕЛЬЗЯ импортировать PDO, Doctrine, Symfony\HttpFoundation
declare(strict_types=1);

namespace App\Domain;

final class Order
{
    private function __construct(
        private readonly int $userId,
        private readonly int $productId,
        private readonly int $quantity,
        private readonly float $total,
    ) {}

    public static function create(int $userId, int $productId, int $quantity, float $price): self
    {
        if ($quantity <= 0 || $quantity > 100) {
            throw new \DomainException('invalid quantity');
        }
        return new self(
            userId: $userId,
            productId: $productId,
            quantity: $quantity,
            total: $price * $quantity,
        );
    }

    public function total(): float { return $this->total; }
}
Открой файл из пакета `domain` и посмотри на импорты. Если там `database/sql`, `net/http`, `github.com/lib/pq` - правило нарушено. Домен должен импортировать только стандартные пакеты вроде `errors`, `fmt`, `time`.

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

В BackendStart гексагональная архитектура реализована так:

backend/
├── internal/
│   ├── domain/          # Ядро: entities, value objects, ошибки
│   │   ├── user.go
│   │   ├── progress.go
│   │   └── errors.go
│   ├── app/             # Use-cases: сценарии бизнес-логики
│   │   ├── complete_lesson.go
│   │   └── register_user.go
│   ├── port/            # Порты: интерфейсы для репозиториев и сервисов
│   │   ├── user_repo.go
│   │   └── progress_repo.go
│   └── adapter/         # Адаптеры: реализации портов
│       ├── postgres/    # Работа с БД
│       ├── http/        # HTTP handlers
│       └── memory/      # In-memory реализации для тестов

Каждый пакет знает только о том, что внутри него и глубже. adapter/postgres импортирует port и domain. app импортирует port и domain. Но domain не импортирует ничего из проекта.

Когда hex-архитектура избыточна

Гексагональная архитектура - не серебряная пуля. Она добавляет интерфейсы, маппинг между слоями и больше файлов. Это оправдано не всегда:

  • Одноразовый скрипт - миграция данных, CLI-утилита на 100 строк. Интерфейсы тут только мешают.
  • Прототип / MVP - когда важно быстро проверить идею, а не построить идеальную архитектуру.
  • Простой CRUD без логики - если вся «бизнес-логика» это INSERT INTO ... VALUES (...), слои не дают пользы.
Если проект будет жить дольше 3 месяцев и над ним работает больше одного человека - гексагональная архитектура почти наверняка окупится. Если это скрипт на один вечер - не усложняй.

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

  • Найди в своём (или любом знакомом) проекте handler, где SQL и бизнес-правила живут вместе
  • Выпиши отдельно: что относится к транспорту (HTTP), что к хранению (SQL), что к бизнес-логике
  • Нарисуй на бумаге три круга (domain → use-case → adapter) и распредели код по ним
  • Проверь импорты в доменном пакете - есть ли там database/sql или net/http?

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