Гексагональная архитектура: зачем она нужна
Гексагональная архитектура: зачем она нужна
Гексагональная архитектура (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 - придётся дублировать код или вытаскивать логику задним числом.
Слоистая vs гексагональная архитектура
Классическая трёхслойная архитектура (Controller → Service → Repository) - шаг вперёд по сравнению с «всё в handler». Но у неё есть ограничение: зависимости идут сверху вниз, и сервисный слой часто напрямую импортирует конкретные реализации репозиториев.
В гексагональной архитектуре use-case зависит от интерфейса (порта), а не от конкретной реализации. Реализация (адаптер) подставляется снаружи.
Dependency Rule: домен не импортирует инфраструктуру
На практике это выглядит как правило импортов: доменный файл импортирует только стандартную библиотеку.
// 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; }
}
Структура проекта 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 (...), слои не дают пользы.
Мини-задание
- Найди в своём (или любом знакомом) проекте handler, где SQL и бизнес-правила живут вместе
- Выпиши отдельно: что относится к транспорту (HTTP), что к хранению (SQL), что к бизнес-логике
- Нарисуй на бумаге три круга (domain → use-case → adapter) и распредели код по ним
- Проверь импорты в доменном пакете - есть ли там
database/sqlилиnet/http?