golang

Архитектура продуктового Go-сервиса

  • воскресенье, 30 марта 2025 г. в 00:00:07
https://habr.com/ru/articles/881918/

Введение

В статье рассматривается подход к построению архитектуры сервиса с использованием языка Go для продуктовых команд, ориентированных на решение бизнес-задач. Особое внимание уделяется вопросам выделения слоев в приложении и обеспечению низкой связанности между ними. Приведённые рекомендации могут быть менее актуальны для команд, занимающихся инфраструктурной разработкой.

Цель статьи — систематизировать и поделиться накопленными знаниями. Описанный подход основан на первоисточнике, но адаптирован и переосмыслен с учётом практического опыта автора.

Структура проекта

По историческим причинам в Go не распространена практика работы с фреймворками, как в других языках программирования, где структура проекта часто определяется конкретным фреймворком. В сообществе существует довольно популярный подход к организации структуры проекта, однако он не содержит чётких рекомендаций по построению иерархии пакетов, описывающих внутреннюю бизнес-логику. Ниже приведён пример того, как может выглядеть иерархия пакетов, описывающих внутреннюю бизнес-логику сервиса:

/project
│
├── cmd
│   ├── app1
│   │   └── ... // -> инициализация и запуск app1
│   ├── app2
│   │   └── ... // -> инициализация и запуск app2
│   │
│   └── main.go // -> входная точка приложения
│
├── internals
│   │
│   ├── adapter // -> корневой пакет адаптеров
│   │   └── ...
│   │
│   ├── domain  // -> корневой пакет бизнес-логики
│   │   └── ...
│   │
│   └── usecase // -> корневой пакет юзкейсов
│       └── ...

Основной интерес представляет пакет internals, который включает в себя следующие пакеты: adapter, domain и usecase, каждый из которых будет подробно рассмотрен далее.

Domain

Это центральный слой приложения. На этом уровне объявляются все необходимые сущности и типы данных, которые фокусируются исключительно на бизнес-логике.

Сущности, определённые в этом слое, не должны знать:

  • Кто и где их вызывает.

  • Как и где они хранятся.

  • Как они могут быть преобразованы в различные форматы (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

В иерархии слоев приложения 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

Задача 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-транзакции, и не зависит от бизнес-логики.

Заключение

Осознанное следование описанному подходу и рекомендациям позволяет выстраивать гибкую, масштабируемую и надежную архитектуру, устойчивую к изменениям бизнес-требований и технологий.

В основе этого подхода лежат ключевые принципы:

  • Четкое разделение слоев в архитектуре приложения – каждый слой имеет свою зону ответственности, что упрощает поддержку и развитие системы.

  • Соблюдение принципа единственной ответственности – каждый компонент решает строго одну задачу, делая код предсказуемым и удобным для сопровождения.

  • Обеспечение слабой связанности между компонентами – компоненты взаимодействуют друг с другом через четко определенные интерфейсы, что облегчает замену реализаций и упрощает тестирование.

  • Минимизация «утечек абстракций» при проектировании интерфейсов – внутренние детали реализации скрыты за интерфейсами, что позволяет изменять реализацию без влияния на вызывающий код.

P.S.

Автор осознанно не даёт конкретных рекомендаций по внедрению данного подхода в существующие сервисы, поскольку это нетривиальная задача, не имеющая простого и универсального решения и требующая индивидуального подхода.