Знакомьтесь: ваш код в изоляции от внешнего мира
Вы когда-нибудь переписывали бизнес-логику только потому, что поменяли базу данных с 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