Чистая архитектура и Hexagonal: Как избежать отложенного выстрела в ногу
Если вы когда-нибудь работали над проектом, который начинался как простой сервис, а через год превратился в монолитный спагетти-код, где бизнес-логика перемешана с HTTP-обработчиками, базой данных и внешними API - эта статья для вас.
Зачем это всё нужно?
Чтобы не было ошибки импортов конечно же
Чтобы все работало отдельно друг от друга, легко читалось, и менялось. Вот представь себе пк, ты же можешь поменять монитор на другой? С архитектурой должно быть также, ты должен быстро менять что-то "налету" и от этого не должен падать весь проект мгновенно. Я конечно же говорю про такие вещи как база данных(поменяли mongo на postgres), подключение(перекатились на grpc с http) и так далее
Эти подходы решают фундаментальные проблемы:
- Тесная связанность - бизнес-логика зависит от фреймворков, баз данных, UI.
- Сложность тестирования - нельзя протестировать логику без запуска всей инфраструктуры.
- Трудности с изменениями - поменять базу данных или добавить новый интерфейс (gRPC вместо HTTP) требует рефакторинга всего кода.
- Размытая доменная модель - разработчики не понимают бизнес, код не отражает реальные сущности предметной области.
Результат: проект становится кашей(не будем говорить какой именно😁)
Какие проблемы решают эти архитектуры?
-
Независимость от внешнего мира
Бизнес-логика не знает о Gin, Echo, PostgreSQL или RabbitMQ. Можно сменить любой внешний компонент без боли. -
Чёткое разделение ответственности
Каждый слой знает только о том, что ему положено. -
Лёгкое тестирование
Use cases и доменные сущности тестируются в изоляции - чистые unit-тесты без моков баз данных. -
Быстрый 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).
Также, не забывайте про использование интерфейсов, это быстро, и может сэкономить время в дальнейшем, ну и из них можно генерить моки, что довольно удобно:)