Архитектура продуктового Go-сервиса
- воскресенье, 30 марта 2025 г. в 00:00:07
В статье рассматривается подход к построению архитектуры сервиса с использованием языка Go для продуктовых команд, ориентированных на решение бизнес-задач. Особое внимание уделяется вопросам выделения слоев в приложении и обеспечению низкой связанности между ними. Приведённые рекомендации могут быть менее актуальны для команд, занимающихся инфраструктурной разработкой.
Цель статьи — систематизировать и поделиться накопленными знаниями. Описанный подход основан на первоисточнике, но адаптирован и переосмыслен с учётом практического опыта автора.
По историческим причинам в Go не распространена практика работы с фреймворками, как в других языках программирования, где структура проекта часто определяется конкретным фреймворком. В сообществе существует довольно популярный подход к организации структуры проекта, однако он не содержит чётких рекомендаций по построению иерархии пакетов, описывающих внутреннюю бизнес-логику. Ниже приведён пример того, как может выглядеть иерархия пакетов, описывающих внутреннюю бизнес-логику сервиса:
/project
│
├── cmd
│ ├── app1
│ │ └── ... // -> инициализация и запуск app1
│ ├── app2
│ │ └── ... // -> инициализация и запуск app2
│ │
│ └── main.go // -> входная точка приложения
│
├── internals
│ │
│ ├── adapter // -> корневой пакет адаптеров
│ │ └── ...
│ │
│ ├── domain // -> корневой пакет бизнес-логики
│ │ └── ...
│ │
│ └── usecase // -> корневой пакет юзкейсов
│ └── ...
Основной интерес представляет пакет internals, который включает в себя следующие пакеты: adapter, domain и usecase, каждый из которых будет подробно рассмотрен далее.
Это центральный слой приложения. На этом уровне объявляются все необходимые сущности и типы данных, которые фокусируются исключительно на бизнес-логике.
Сущности, определённые в этом слое, не должны знать:
Кто и где их вызывает.
Как и где они хранятся.
Как они могут быть преобразованы в различные форматы (JSON, XML, Protobuf).
О любых внешних зависимостях.
Можно выделить следующие типы сущностей:
Модель — сущность, которая имеет поведение, ее поля объявляются приватными, а взаимодействие с ней происходит исключительно через ее API.
DTO (data transfer object) — сущность, которая не имеет поведения, ее поля объявляются публичными.
Рассмотрим работу с моделью на примере отмены заказа:
package domain // => ./internals/domain
type CancelOrder struct {
id int64
status OrderStatus
canceledAt time.Time
}
func NewCancelOrder(id int64, status OrderStatus) (CancelOrder, error) {
if id == 0 {...}
if status.IsEmpty() {...}
return CancelOrder{id: id, status: status}, nil
}
// API
func (c *CancelOrder) Cancel() error {
if c.status.IsCanceled() {
return ErrOrderAlreadyCanceled
}
c.status = OrderCanceled
c.canceledAt = time.Now()
return nil
}
// Getters
func (c *CancelOrder) ID() int64 { return c.id }
func (c *CancelOrder) Status() OrderStatus { return c.status }
func (c *CancelOrder) CanceledAt() time.Time { return c.canceledAt }
Если необходимо только передать информацию о заказе, то такая сущность будет являться DTO с публичными полями и без поведения:
package domain // => ./internals/domain
type GetOrder struct {
ID int64
Amount int64
Status OrderStatus
}
При таком подходе к описанию бизнес-логики она полностью изолирована от внешнего мира и легко покрывается unit-тестами.
Рекомендация
При работе с сущностями, будь то модели или DTO, следует придерживаться принципа единственной ответственности, т.е. «одна сущность — одно поведение». В противном случае сущность становится избыточной с точки зрения как данных, так и доступного поведения, что неизбежно приводит к следующим проблемам:
Увеличение когнитивной нагрузки на разработчика, так как усложняется понимание кодовой базы.
Увеличение нагрузки на инфраструктуру: сложные запросы в БД, увеличение объема сетевого трафика, процессы анмаршаллинга.
Нарушение изоляции данных: различные методы модели получают доступ к изменению данных, к которым у них не должно быть доступа, что может приводить к критическим ошибкам.
Усложнение реализации и поддержки unit-тестов.
В иерархии слоев приложения usecase следует за domain. Его можно противопоставить слою, который часто называют service. Оба слоя выполняют одну и ту же функцию — обеспечивают взаимодействие бизнес-логики с внешним миром.
Фундаментальное отличие между service и usecase заключается в том, что service, как правило, реализует набор методов, тогда как usecase сосредоточен на выполнении одной конкретной задачи, обеспечивая высокую изоляцию и строгое соблюдение принципа единственной ответственности. В итоге service, который в процессе рефакторинга стремится к более высокой степени изоляции и всё больше ориентируется на соблюдение принципа единственной ответственности, рано или поздно вырождается в usecase.
Внешний мир для usecase — это всего лишь набор интерфейсов, которые оперируют сущностями из слоя domain и нет никаких деталей о том, кто и как реализует эти интерфейсы. Задача usecase, как было сказано выше, связать поведение бизнес-логики и внешний мир.
Рассмотрим usecase на примере создания заказа:
package create // => ./internals/usecase/order/create
// ProductsGetter — интерфейс, описывающий процесс получения продуктов из корзины пользователя.
type ProductsGetter interface {
GetProductsFromBasket(ctx context.Context, userID, basketID int64) ([]domain.GetProduct, error)
}
// OrderCreator — интерфейс, описывающий процесс создания заказа.
type OrderCreator interface {
CreateOrder(ctx context.Context, order domain.CreateOrder) error
}
type Usecase struct {
productsGetter ProductsGetter
orderCreator OrderCreator
}
func NewUsecase(productsGetter ProductsGetter, orderCreator OrderCreator) (*Usecase, error) {
if productsGetter == nil {...}
if orderCreator == nil {...}
return &Usecase{productsGetter: productsGetter, orderCreator: orderCreator}, nil
}
func (u *Usecase) Execute(ctx context.Context, userID, basketID int64) error {
// Получаем продукты из корзины
products, err := u.productsGetter.GetProductsFromBasket(ctx, userID, basketID)
if err != nil {...}
// Инициализируем новый заказ для пользователя с продуктами из его корзины.
// Логика, которая описывает инициализацию заказа скрыта в слое domain.
// Например: инициализация статуса, даты создания, подсчёт общей суммы заказа.
order, err := domain.NewCreateOrder(userID, products)
if err != nil {...}
// Создаем заказ
if err = u.orderCreator.CreateOrder(ctx, order); err != nil {...}
return nil
}
Рекомендация
При проектировании usecase:
Следует придерживаться принципа единственной ответственности, т.е. «один usecase — одно действие».
Работайте со всеми внешними зависимостями через интерфейсы. Это упростит написание unit-тестов, позволяя использовать mock-объекты для имитации поведения.
Объявляйте интерфейсы, ориентируясь на принцип единственной ответственности, т.е. «один интерфейс — один метод». Это повысит читаемость кода, упростит управление зависимостями, а также облегчит написание unit-тестов и инициализацию mock-объектов.
Размещайте интерфейсы, описывающие внешние зависимости, рядом с соответствующим usecase. Избегайте экспортируемых интерфейсов в угоду слабой связанности между пакетами, даже если это приводит к их дублированию.
Размещайте mock-объекты, имитирующие внешние зависимости, рядом с соответствующим usecase. Избегайте экспортируемых mock-объектов из глобального пакета в угоду слабой связанности между пакетами.
Задача adapter слоя проста: реализовать интерфейсы, которые были объявлены в слое usecase для работы с внешним миром. Следовательно, adapter знает всё о сущностях domain и о внешнем мире, которым может быть любая внешняя система: база данных, очередь сообщений или сторонний сервис.
Хорошим примером adapter является широко распространённый паттерн репозиторий, который обязан скрывать все детали реализации работы с хранилищем, чем бы оно ни являлось: РСУБД, NoSQL БД, REST API или обычный текстовый файл. Таким образом, в зону ответственности репозитория входит преобразование сущности из слоя domain во внутренние структуры данных, соответствующие схеме хранения во внешней системе, а также все детали работы с её API.
Рассмотрим пример репозитория на основе РСУБД:
package repository // => ./internals/adapter/repository
type Repository struct {
db *sql.DB
}
func NewRepository(db *sql.DB) *Repository { return &Repository{db: db} }
// getOrder — внутренняя структура данных репозитория для получения заказа.
type getOrder struct {
ID int64 `db:"id"`
Amount int64 `db:"amount"`
Status string `db:"status"`
}
func (r *Repository) GetOrder(ctx context.Context, id int64) (domain.GetOrder, error) {
// 1. Выполняется SQL-запрос.
// 2. Анмаршаллинг во внутреннюю структуру данных getOrder.
// 3. Конвертация getOrder в domain.GetOrder.
// 4. Возвращается результат domain.GetOrder.
}
Рекомендация
При проектировании репозитория:
Откажитесь от подхода "один репозиторий — одна таблица", так как задача репозитория — инкапсулировать всю логику работы бизнес-сущностей с хранилищем, а не просто оборачивать его примитивы. Такой подход раскрывает детали реализации: схему данных, взаимосвязи таблиц и коллекций.
Откажитесь от использования общих структур данных репозитория, ответственных за получение или изменение данных. Такой подход приводит к избыточным запросам и создаёт риск бесконтрольной перезаписи. Вместо этого описывайте кастомные структуры данных под каждый запрос.
Сделайте выбор в пользу написания интеграционных тестов вместо попыток имитировать поведение сложных объектов, таких как база данных или другие внешние системы.
Распространённой проблемой при проектировании интерфейсов является «утечка абстракции». Joel Spolsky в своей статье утверждает, что «любая нетривиальная абстракция в какой-то степени протекает». Задача разработчика — минимизировать уровень «утечки», обеспечив качественный интерфейс.
На практике ярким предвестником «утечки абстракции» является попытка разработчиков найти ответы на следующие вопросы:
Как вызвать два существующих метода репозитория в рамках одной транзакции?
Как вызвать методы двух разных репозиториев в рамках одной транзакции?
Очень популярным решением этой проблемы является введение абстракции менеджера транзакций, который является проводником «утечки» ACID-транзакции в интерфейс репозитория. Существуют различные его реализации, которые будут рассмотрены ниже на примере создания заказа и продуктов.
Пример с явной передачей транзакции в качестве аргумента:
// Tx — интерфейс транзакции.
type Tx interface {...}
// TxManager — интерфейс, позволяющий объявить начало транзакции.
type TxManager interface {
BeginTx() (Tx, error)
}
// ProductsCreator — интерфейс, описывающий процесс создания продуктов для заказа.
// Принимает транзакцию в качестве аргумента.
type ProductsCreator interface {
CreateProducts(ctx context.Context, tx Tx, products []domain.CreateProduct) error
}
// OrderCreator — интерфейс, описывающий процесс создания заказа.
// Принимает транзакцию в качестве аргумента.
type OrderCreator interface {
CreateOrder(ctx context.Context, tx Tx, order domain.CreateOrder) error
}
Пример с неявной передачей транзакции через контекст:
// TxManager — интерфейс, позволяющий объявить транзакционный блок.
// При вызове метода транзакция будет внедрена в ctx с помощью injectTx().
type TxManager interface {
WithTx(context.Context, func(ctx context.Context) error) error
}
// ProductsCreator — интерфейс, описывающий процесс создания продуктов для заказа.
// Под капотом проверяет наличие транзакции в ctx с помощью extractTx().
type ProductsCreator interface {
CreateProducts(ctx context.Context, products []domain.CreateProduct) error
}
// OrderCreator — интерфейс, описывающий процесс создания заказа.
// Под капотом проверяет наличие транзакции в ctx с помощью extractTx().
type OrderCreator interface {
CreateOrder(ctx context.Context, order domain.CreateOrder) error
}
// txKey — ключ для хранения транзакции в контексте.
type txKey struct{}
// injectTx — внедряет транзакцию в ctx по ключу txKey.
func injectTx(ctx context.Context, tx *sql.Tx) context.Context {...}
// extractTx — проверяет транзакцию в ctx по ключу txKey.
func extractTx(ctx context.Context) (*sql.Tx, bool) {...}
В результате такая «утечка» приводит к следующим проблемам:
Появляется возможность передать транзакцию по всему стеку вызовов сервиса, что значительно усложняет управление транзакциями и увеличивает риск ошибок.
Раскрываются детали реализации конкретной технологии, из-за чего вызывающий код начинает учитывать «магические» свойства ACID-транзакции. Разработчики, в свою очередь, опираются на эту абстракцию при проектировании интерфейсов, создавая жёсткую зависимость между конкретной технологией и кодовой базой.
Появляется возможность интерпретировать ACID-транзакцию как «бизнес-транзакцию», что приводит к фундаментальным ошибкам при разработке сервисов и организации межсервисного взаимодействия. Ситуация усугубляется тем, что в рамках открытой транзакции могут происходить сетевые вызовы в сторонние сервисы со всеми сопутствующими проблемами. В результате появляются долгоживущие транзакции, создающие высокую нагрузку на механизмы базы данных.
По мнению автора, корректным решением в данном случае, исключающим «утечку абстракции», является введение агрегата — сущности, объединяющей дочерние объекты для атомарных операций, а также объявление интерфейса для работы с ним.
Рассмотрим агрегат на примере создания заказа и продуктов:
// Функции инициализации и методы сущностей опускаются для простоты.
// CreateOrder — описывает создание заказа и продуктов, которые с ним связаны.
type CreateOrder struct {
id int64
createdAt time.Time
amount int64
userID int64
status OrderStatus
products []CreateProduct
}
// CreateProduct — описывает создание продукта.
type CreateProduct struct {
id int64
createdAt time.Time
amount int64
}
Интерфейс для взаимодействия с агрегатом, не допускающий «утечки» и скрывающий все детали реализации, будет выглядеть следующим образом:
// OrderCreator — интерфейс, описывающий процесс создания заказа и продуктов, которые с ним связаны.
type OrderCreator interface {
CreateOrder(ctx context.Context, order domain.CreateOrder) error
}
Распространённый случай — когда разработчику требуется сначала получить данные из репозитория, затем выполнить необходимую бизнес-логику на основе полученных данных и сохранить результат, но при этом делать всё в рамках одной транзакции.
Ситуация аналогична: вместо использования менеджера транзакций со всеми вытекающими последствиями, рассмотренными выше, корректным подходом является использование агрегата в связке с функцией как объектом первого порядка.
Рассмотрим пример отмены заказа:
package cancel // => ./internals/usecase/order/cancel
// CancelFunc — тип, описывающий функцию, которая позволяет получить заказ и выполнить его отмену.
type CancelFunc func(order domain.CancelOrder) (domain.CancelOrder, error)
// OrderCanceler — интерфейс, описывающий процесс отмены заказа.
type OrderCanceler interface {
CancelOrder(ctx context.Context, orderID int64, fn CancelFunc) error
}
type Usecase struct {
orderCanceler OrderCanceler
}
func (u *Usecase) Execute(ctx context.Context, orderID int64) error {
// Отменяем заказ, передавая в виде аргумента функцию для отмены заказа.
err := u.orderCanceler.CancelOrder(ctx, orderID, u.cancelFunc)
if err != nil {...}
return nil
}
func (u *Usecase) cancelFunc(order domain.CancelOrder) (domain.CancelOrder, error) {
// Получаем order, как входной аргумент функции и вызываем метод отмены заказа.
if err := order.Cancel(); err != nil {...}
// Возвращаем order для сохранения результатов отмены заказа.
return order, nil
}
Реализация метода репозитория выглядит следующим образом:
package repository // => ./internals/adapter/repository
type Repository struct {
db *sql.DB
}
// cancelOrder — внутренняя структура данных репозитория для отмены заказа.
type cancelOrder struct {
ID int64 `db:"id"`
Status string `db:"status"`
CanceledAt time.Time `db:"canceled_at"`
}
func (r *Repository) CancelOrder(ctx context.Context, orderID int64, fn usecase.CancelFunc) error {
// 1. Начало ACID-транзакции.
// 2. Выполняется SQL-запрос на получение заказа с блокировкой.
// 3. Анмаршаллинг во внутреннюю структуру данных cancelOrder.
// 4. Конвертация cancelOrder в domain.CancelOrder.
// 5. Вызов fn() с аргументом domain.CancelOrder.
// 6. SQL-запрос на обновление заказа согласно изменениям в domain.CancelOrder.
// 7. Завершение ACID-транзакции.
}
Из примера видно:
Слой usecase сосредоточен на работе с бизнес-логикой и не знает деталей реализации хранилища.
Репозиторий полностью инкапсулирует детали работы с хранилищем, предотвращая «утечку» ACID-транзакции, и не зависит от бизнес-логики.
Осознанное следование описанному подходу и рекомендациям позволяет выстраивать гибкую, масштабируемую и надежную архитектуру, устойчивую к изменениям бизнес-требований и технологий.
В основе этого подхода лежат ключевые принципы:
Четкое разделение слоев в архитектуре приложения – каждый слой имеет свою зону ответственности, что упрощает поддержку и развитие системы.
Соблюдение принципа единственной ответственности – каждый компонент решает строго одну задачу, делая код предсказуемым и удобным для сопровождения.
Обеспечение слабой связанности между компонентами – компоненты взаимодействуют друг с другом через четко определенные интерфейсы, что облегчает замену реализаций и упрощает тестирование.
Минимизация «утечек абстракций» при проектировании интерфейсов – внутренние детали реализации скрыты за интерфейсами, что позволяет изменять реализацию без влияния на вызывающий код.
Автор осознанно не даёт конкретных рекомендаций по внедрению данного подхода в существующие сервисы, поскольку это нетривиальная задача, не имеющая простого и универсального решения и требующая индивидуального подхода.