Go и Hexagonal архитектура: пишем код, который не страшно менять

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Знакомьтесь: ваш код в изоляции от внешнего мира

Вы когда-нибудь переписывали бизнес-логику только потому, что поменяли базу данных с PostgreSQL на MongoDB? Или просыпались в холодном поту от мысли, что нужно добавить новый HTTP-эндпоинт, а всё приложение — это один гигантский main.go на 3000 строк?

Если да — добро пожаловать в клуб. Если нет — вы либо пишете на Go недавно, либо уже используете Hexagonal архитектуру (она же Ports & Adapters).

В этой статье мы разберём, как построить приложение на Go, которое не боится перемен. Ни смена базы данных, ни переход с REST на gRPC, ни новый фреймворк не заставят вас переписывать ядро системы. Только код. Только Go. Без воды.

Что такое Hexagonal архитектура простыми словами

Представьте, что ваше приложение — это космический корабль. В центре — рубка управления (бизнес-логика). Вокруг — стыковочные порты. Через порты вы подключаете разные модули: двигатели (база данных), сенсоры (HTTP-запросы), связь с Землёй (очереди сообщений).

Если вы решите заменить двигатели — вы просто отстыковываете старые и пристыковываете новые. Рубка управления даже не заметит подмены. Вот это и есть Hexagonal архитектура.

Основные понятия:

  • Port (Порт) — интерфейс, который определяет, как внешний мир может взаимодействовать с ядром. Например, UserRepository с методами Save и FindByID.
  • Adapter (Адаптер) — конкретная реализация порта. Один порт может иметь много адаптеров: PostgreSQLAdapter, InMemoryAdapter, MockAdapter для тестов.
  • Core (Ядро) — бизнес-логика, которая ничего не знает о внешнем мире. Она работает только через порты.

Такой подход даёт три суперспособности: тестируемость (ядра без БД), гибкость (замена любой детали) и чёткие границы ответственности.

Почему Go идеально подходит для Hexagonal

Go не навязывает ООП, но даёт интерфейсы — единственное, что нужно для Ports & Adapters. Интерфейсы в Go неявные: структура автоматически удовлетворяет интерфейсу, если реализует его методы. Это значит, что вы можете подменить адаптер, не меняя ни строчки в ядре.

Плюс Go компилируется в бинарник, что делает архитектуру особенно полезной для микросервисов. А встроенное тестирование и бенчмарки позволяют сразу проверить изоляцию компонентов.

Структура проекта: как организовать папки

Не существует единого стандарта, но вот проверенная схема, которую используют в продакшн-проектах:

myapp/
├── cmd/
│   └── app/
│       └── main.go          # Точка входа, сборка зависимостей
├── internal/
│   ├── core/
│   │   ├── domain/          # Сущности, value objects
│   │   ├── ports/           # Интерфейсы (порты)
│   │   └── services/        # Бизнес-логика (use cases)
│   └── adapters/
│       ├── api/             # HTTP/gRPC адаптеры
│       ├── db/              # Реализации репозиториев
│       └── mq/              # Очереди сообщений
└── go.mod

Важно: папка internal запрещает импорт извне — идеально для защиты ядра. Адаптеры лежат отдельно и могут быть легко заменены.

Пишем код: от интерфейсов до реализации

Давайте создадим простой сервис для работы с пользователями. Начнём с ядра.

Шаг 1. Домен и порты

// internal/core/domain/user.go
package domain

import "time"

type User struct {
    ID        string
    Email     string
    Name      string
    CreatedAt time.Time
}

// internal/core/ports/repositories.go
package ports

import "myapp/internal/core/domain"

type UserRepository interface {
    Save(user domain.User) error
    FindByID(id string) (domain.User, error)
    FindByEmail(email string) (domain.User, error)
}

type EmailService interface {
    SendWelcome(email string, name string) error
}

Обратите внимание: интерфейсы определяет тот, кто их использует, а не тот, кто реализует. Ядро знает, какие данные ему нужны, и описывает это через порты.

Шаг 2. Бизнес-логика (Use Case)

// internal/core/services/user_service.go
package services

import (
    "errors"
    "myapp/internal/core/domain"
    "myapp/internal/core/ports"
)

type UserService struct {
    repo ports.UserRepository
    email ports.EmailService
}

func NewUserService(repo ports.UserRepository, email ports.EmailService) *UserService {
    return &UserService{repo: repo, email: email}
}

func (s *UserService) Register(email, name string) (*domain.User, error) {
    // Бизнес-правило: email должен быть уникальным
    existing, _ := s.repo.FindByEmail(email)
    if existing.Email != "" {
        return nil, errors.New("email already exists")
    }

    user := domain.User{
        ID:    generateID(),
        Email: email,
        Name:  name,
    }

    if err := s.repo.Save(user); err != nil {
        return nil, err
    }

    // Отправка приветствия — через порт, без знания деталей
    if err := s.email.SendWelcome(email, name); err != nil {
        // Логируем, но не прерываем регистрацию
        // В реальности — отправляем в очередь для повторной попытки
    }

    return &user, nil
}

func generateID() string {
    // простая генерация UUID
    return "usr_" + time.Now().Format("20060102150405")
}

Видите магию? UserService ничего не знает про PostgreSQL, RabbitMQ или HTTP. Он просто вызывает методы интерфейсов. Это значит, что мы можем тестировать регистрацию без базы данных.

Шаг 3. Адаптеры: PostgreSQL и Mock

Теперь реализуем порт UserRepository для PostgreSQL:

// internal/adapters/db/postgres_user_repo.go
package db

import (
    "database/sql"
    "myapp/internal/core/domain"
)

type PostgresUserRepo struct {
    db *sql.DB
}

func NewPostgresUserRepo(db *sql.DB) *PostgresUserRepo {
    return &PostgresUserRepo{db: db}
}

func (r *PostgresUserRepo) Save(user domain.User) error {
    _, err := r.db.Exec(
        "INSERT INTO users (id, email, name, created_at) VALUES ($1, $2, $3, $4)",
        user.ID, user.Email, user.Name, user.CreatedAt,
    )
    return err
}

func (r *PostgresUserRepo) FindByID(id string) (domain.User, error) {
    var u domain.User
    err := r.db.QueryRow("SELECT id, email, name, created_at FROM users WHERE id = $1", id).
        Scan(&u.ID, &u.Email, &u.Name, &u.CreatedAt)
    return u, err
}

func (r *PostgresUser                

Блоги

Рекомендаций по книгам