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

от автора

Введение

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

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

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

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

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

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

Domain

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

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

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

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

  • Как они могут быть преобразованы в различные форматы (JSON, XML, Protobuf).

  • О любых внешних зависимостях.

Можно выделить следующие типы сущностей:

  • Модель — сущность, которая имеет поведение, ее поля объявляются приватными, а взаимодействие с ней происходит исключительно через ее API.

  • DTO (data transfer object) — сущность, которая не имеет поведения, ее поля объявляются публичными.

Рассмотрим работу с моделью на примере отмены заказа:

package domain // => ./internal/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 // => ./internal/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 // => ./internal/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 // => ./internal/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 // => ./internal/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 // => ./internal/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.

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


ссылка на оригинал статьи https://habr.com/ru/articles/881918/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *