Введение
В статье рассматривается подход к построению архитектуры сервиса с использованием языка 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/
Добавить комментарий