Назад
Golang

Чистая архитектура и Hexagonal: Как избежать отложенного выстрела в ногу

Если вы когда-нибудь работали над проектом, который начинался как простой сервис, а через год превратился в монолитный спагетти-код, где бизнес-логика перемешана с HTTP-обработчиками, базой данных и внешними API - эта статья для вас.

Зачем это всё нужно?

Чтобы не было ошибки импортов конечно же
Чтобы все работало отдельно друг от друга, легко читалось, и менялось. Вот представь себе пк, ты же можешь поменять монитор на другой? С архитектурой должно быть также, ты должен быстро менять что-то "налету" и от этого не должен падать весь проект мгновенно. Я конечно же говорю про такие вещи как база данных(поменяли mongo на postgres), подключение(перекатились на grpc с http) и так далее

Эти подходы решают фундаментальные проблемы:

  • Тесная связанность - бизнес-логика зависит от фреймворков, баз данных, UI.
  • Сложность тестирования - нельзя протестировать логику без запуска всей инфраструктуры.
  • Трудности с изменениями - поменять базу данных или добавить новый интерфейс (gRPC вместо HTTP) требует рефакторинга всего кода.
  • Размытая доменная модель - разработчики не понимают бизнес, код не отражает реальные сущности предметной области.

Результат: проект становится кашей(не будем говорить какой именно😁)

Какие проблемы решают эти архитектуры?

  1. Независимость от внешнего мира
    Бизнес-логика не знает о Gin, Echo, PostgreSQL или RabbitMQ. Можно сменить любой внешний компонент без боли.

  2. Чёткое разделение ответственности
    Каждый слой знает только о том, что ему положено.

  3. Лёгкое тестирование
    Use cases и доменные сущности тестируются в изоляции - чистые unit-тесты без моков баз данных.

  4. Быстрый onboarding
    Новичок сразу видит, где лежит бизнес-логика, а где - инфраструктура.

Можно реализовать такую структуру:

internal/
├── application/          // можно хранить здесь dto, actions
├── transport/         // HTTP handlers, gRPC
└── repository/  // DB connections, external clients, config

Тут я бы хотел отметить роль dto, в каждом слое лучше добавлять отдельную сущность, которая будет ориентироваться на dto приложения, но при этом дополняться нужными тегами при необходимости. Dto самого приложения не должна быть связана с другими слоями и иметь тегов. Все другие слои будут конвертировать свою dto в application dto, чтобы работать с бизнес-логикой

Наглядный пример: Система заказов в интернет-магазине

Допустим, у нас есть сущность Order (заказ).

1. Application слой

// internal/domain/order.go
type Order struct {
    id         string
    customerID string
    items      []OrderItem
    status     OrderStatus
    total      Money
}

type OrderStatus string

const (
    StatusNew       OrderStatus = "new"
    StatusConfirmed OrderStatus = "confirmed"
    StatusShipped   OrderStatus = "shipped"
    StatusCancelled OrderStatus = "cancelled"
)

func (o *Order) CanConfirm() bool {
    return o.status == StatusNew
}

func (o *Order) Confirm() error {
    if !o.CanConfirm() {
        return errors.New("cannot confirm order in current status")
    }
    o.status = StatusConfirmed
    return nil
}

Здесь вся бизнес-логика внутри слоя приложеня. Никаких зависимостей от баз данных.

2. Интерфейс репозиторя

// internal/domain/order_repository.go
type OrderRepository interface {
    FindByID(ctx context.Context, id string) (*Order, error)
    Save(ctx context.Context, order *Order) error
}

3. Action

// internal/application/action/confirm_order/confirm_order.go
type ConfirmOrder struct {
    orderRepo OrderRepository
}
func NewConfirmOrder(orderRepo OrderRepository) *ConfirmOrder {
    return &ConfirmOrder{orderRepo: orderRepo}
}

func (a *ConfirmOrder) Execute(ctx context.Context, orderID string) error {
    order, err := a.orderRepo.FindByID(ctx, orderID)
    if err != nil {
        return err
    }

    if err := order.Confirm(); err != nil {
        return err
    }

    return a.orderRepo.Save(ctx, order)
}

Action знает только о домене и какие будут методы репозитория. Никакого HTTP, никакого SQL.

4. Транспортный слой

// internal/transport/http/handler.go
type ConfirmOrderAction interface {
    Execute(ctx context.Context, orderID string) error
}

type Handler struct {
    confirmOrder ConfirmOrderAction
}

func (h *Handler) ConfirmOrderHandler(w http.ResponseWriter, r *http.Request) {
    orderID := chi.URLParam(r, "id")
    
    err := h.confirmOrder.Execute(r.Context(), orderID)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }

    w.WriteHeader(http.StatusOK)
}

5. Реализация репозитория (инфраструктура)

// internal/repository/postgres/order/order_repo.go
type PostgresOrderRepository struct {
    db *sql.DB
}

func (r *PostgresOrderRepository) FindByID(ctx context.Context, id string) (*domain.Order, error) {
    // SQL-запросы...
}

func (r *PostgresOrderRepository) Save(ctx context.Context, order *domain.Order) error {
    // INSERT/UPDATE...
}

Если завтра захотим перейти на MongoDB — меняем только эту реализацию.

Почему стоит использовать именно в Go?

Go поощряет простоту и явные зависимости. Пакеты легко контролировать, интерфейсы минималистичны

Плюс сообщество активно продвигает эти подходы: посмотрите на проекты вроде go-clean-arch или реальные open-source сервисы (например, в области fintech).

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

1