Базовая архитектура сервиса на GO
Основная цель моей архитектуры — разделить код на слои, каждый из которых решает свои задачи. Это не просто модный тренд, а необходимость, которая помогает изолировать бизнес-логику от технических деталей, упрощает тестирование и делает код более понятным.
В моем подходе очевидно прослеживаются идеи чистой архитектуры, предложенной дядей Бобом. Однако в угоду практичности, простоты и понятности кода я сознательно иду на некоторые отступления от строгих принципов чистой архитектуры:
- Гибкость в использовании Usecase
- Слои и их названия
- Акцент на микросервисы
- Миграции и работа с БД
- Middleware и инфраструктурные сервисы
- Практическая адаптация
Эти отступления от чистой архитектуры позволяют сохранить ключевые преимущества — изоляцию бизнес-логики, тестируемость и поддерживаемость, — но при этом сделать архитектуру более гибкой и удобной для разработки в реальных условиях.
Все начинается с запроса. Уровень Handlers.
Первое, с чем сталкивается наш сервис, — это внешний запрос. В зависимости от назначения сервиса, это может быть HTTP-запрос от пользователя, RPC-вызов от другого сервиса или даже вызов бинарного файла. В любом случае, запрос необходимо преобразовать в понятный для сервиса формат — внутренние модели, которые затем передаются в слой бизнес-логики. На этом этапе никакая бизнес-логика не выполняется. Путь идеального хендлера выглядит следующим образом:
- Валидация запросаПроверяем, что запрос соответствует ожидаемому формату и содержит все необходимые данные.
- Преобразование запроса в модельКонвертируем данные из внешнего формата (например, JSON) во внутреннюю бизнес-модель.
- Вызов UsecaseПередаем модель в слой бизнес-логики (Usecase). Один хендлер должен вызывать только один Usecase.
- Обработка ошибокЛовим и обрабатываем ошибки, которые могут возникнуть в процессе выполнения. Не всегда удобно или возможно делать это на уровне middleware.
- Формирование и возврат ответаПреобразуем результат работы Usecase в формат, понятный внешнему миру (например, JSON), и возвращаем его.
Задача слоя Handlers — решать транспортные вопросы, то есть взаимодействовать с внешним миром. Он не должен знать ничего о бизнес-логике, данных или их хранении. Его единственная задача — принимать запросы и возвращать ответы. Чтобы гарантировать изоляцию, Usecase следует скрывать за интерфейсами, чтобы хендлеры не знали о их внутренней реализации.
Пример HTTP-хендлера на Go:
Организация хендлеров в пространстве. Роутеры.
Прежде чем углубляться в слои бизнес-логики, важно обсудить, как организовать множество хендлеров в рамках сервиса. Хендлеров может быть очень много, и для каждого из них могут потребоваться свои middleware (MW). Например, для одних нужна авторизация, для других — логирование, а для третьих — проверка прав доступа. Чтобы избежать хаоса и дублирования кода, хендлеры нужно логически группировать и настраивать группы, а не каждый в отдельности.
Для этого мы используем роутеры. Вместо того чтобы настраивать все хендлеры в одном роутере, мы создаем отдельные роутеры для каждой группы хендлеров и настраиваем роутер сразу.
Пример реализации роутера для cущности юзера:
И объединение всех роутеров в один мега-роутер:
Между запросом и роутером. Middleware.
Прежде чем запрос попадет в роутер и будет обработан хендлером, его часто нужно предварительно обработать. Это может быть проверка авторизации пользователя, настройка CORS (Cross-Origin Resource Sharing), логирование запросов, добавление заголовков или даже преобразование данных. Для этих задач используются middleware — промежуточные обработчики, которые выполняются перед тем, как запрос достигнет хендлера.
Middleware — это мощный инструмент, который позволяет централизованно обрабатывать общие задачи для всех или группы запросов. Это избавляет от необходимости дублировать код в каждом хендлере и делает код более чистым и поддерживаемым.
Самый понятный пример использования MW на мой взгляд - проверка авторизации и дальнейшее прокидывание информации об авторизированном юзере в наши хендлеры и бизнес-логику:
Поэтому воткнем на нашу схему MW, не забываем, что для каждого роутера они могут быть свои.
Здесь делается бизнес. Usecase
Слой UseCase — это сердце микросервиса, где реализуется вся бизнес-логика. Здесь определяются правила, какие действия доступны пользователю, какие данные можно изменять, а какие — нет, и как взаимодействовать с другими системами. Основная задача UseCase — описать бизнес-процессы максимально понятно, без привязки к техническим деталям.
Что делает UseCase?
UseCase отвечает за:
- Бизнес-правила: что можно делать, а что нельзя.
- Преобразование данных: фильтрация, обогащение, агрегация.
- Взаимодействие с сервисами: получение данных, отправка изменений.
- Возврат результата: подготовка данных для ответа.
Как организован UseCase?
Обычно UseCase представляет собой класс (или структуру в Go), который группирует методы, связанные с одной бизнес-сущностью или процессом. Например, для сущности "Пользователь" может быть UseCase с методами:
- CreateUser
- UpdateUser
- DeleteUser
- GetUser
Юзкейсов бывает очень много и чтобы не запутаться предлагаю их тоже сгруппировать по Entities(О них ниже), чтобы не запутаться, какой юзкейс к чему относится.
Пример UseCase на Go:
Сущности. Entities
Сущности — это простые структуры данных, которые описывают объекты предметной области. Например, сущность User может содержать поля, такие как ID, Name, Email, CreatedAt и т.д. Эти сущности используются на всех слоях приложения, но их описание обычно находится в одном месте для удобства.
Раньше я упоминал, что интерфейсы можно держать рядом с реализацией UseCase, но мне больше нравится другой подход: держать интерфейсы рядом с описанием сущности. Это делает код более наглядным, так как вы видите поля сущности и методы, которые с ней связаны, в одном месте.
Опишем сущность юзера и его юзкейсы:
И дополним нашу схему:
Дополнение: Services и вспомогательные функции
В дополнение к сущностям (Entities) стоит упомянуть Services. Некоторые разработчики выделяют Services в отдельный слой и используют их как вспомогательные функции для бизнес-логики. Однако я предпочитаю более гибкий подход: если функция нужна только один раз, я пишу её прямо в UseCase или Handler. Если же функция используется часто, я выношу её в файл с сущностями (Entities), чтобы её могли использовать другие части приложения.
На примере пользователя такой функцией может быть например валидация почты:
Базы данных. Repository
Бизнес-логика (UseCase) не существует сама по себе — ей нужно где-то хранить данные. Для этого используется слой Repository. Этот слой отвечает за взаимодействие с базой данных, но при этом он абстрагирован от конкретной реализации базы данных. Это позволяет легко менять базу данных (например, с PostgreSQL на MongoDB) без изменения бизнес-логики.
Что такое Repository?
Repository — это слой, который:
- Абстрагирует доступ к базе данных.
- Предоставляет методы для работы с данными (CRUD: Create, Read, Update, Delete).
- Работает с сущностями (Entities), а не с бизнес-моделями (Models).
Зачем нужен Repository?
- Изоляция бизнес-логикиUseCase не должен знать, как данные хранятся и как они извлекаются. Это задача Repository.
- ТестируемостьRepository можно легко мокировать в unit-тестах, что позволяет тестировать UseCase изолированно.
Как работает Repository?
Repository предоставляет интерфейс, который описывает методы для работы с данными. Например, для сущности User это может быть:
- GetByID — получение пользователя по ID.
- GetByEmail — получение пользователя по email.
- Save — сохранение пользователя.
- Delete — удаление пользователя.
Репозитории так же удобно сгруппировать по сущностям, поэтому не забываем добавить наш интерфейс репозитория в Entities юзера:
На нашей схеме это будет выглядеть так:
Меняем СУБД без боли. Interfaces
Одна из ключевых идей хорошей архитектуры — это изоляция изменений. Если вы решите поменять базу данных, драйвер для работы с ней или даже библиотеку для логирования, это не должно превращаться в кошмар, где приходится переписывать половину проекта. Чтобы избежать этого, мы используем интерфейсы. Интерфейсы позволяют абстрагироваться от конкретной реализации и легко менять её в будущем.
Слой Adapters: Работа с внешними API
Наш сервис уже умеет обрабатывать запросы, работать с базой данных и выполнять бизнес-логику. Однако в реальных приложениях сервисы часто взаимодействуют с другими сервисами или внешними API. Например, нам может понадобиться получить данные из банковского API, отправить уведомление через сторонний сервис или запросить информацию у другого микросервиса.
Если взаимодействие происходит по RPC, то у нас уже есть готовый клиент. Но если это внешний API, нам нужно написать собственный клиент, который будет:
- Шифровать данные.
- Передавать креды.
- Скрывать технические детали (например, заголовки или параметры запросов).
- Обрабатывать ошибки и преобразовывать ответы.
Эту работу нельзя делать в бизнес-логике, так как это нарушает принцип изоляции. Вместо этого мы создадим слой Adapters, который будет отвечать за взаимодействие с внешними системами.
Пример адаптера для банковского API:
Итого
В результате мы получили универсальную и легко поддерживаемую структуру сервиса, которую я разработал на основе личного опыта, учебных проектов и работы в команде.
Эта структура — результат многолетнего опыта, и она отлично подходит для большинства проектов. Однако важно помнить, что архитектура — это не догма, а инструмент. Если вы видите, что какие-то изменения сделают ваш сервис лучше, — смело вносите их. Главное — чтобы код оставался поддерживаемым, тестируемым и понятным.
Буду рад услышать ваши замечания, предложения и идеи по улучшению этой архитектуры. Давайте делиться опытом и делать наши сервисы ещё лучше!
P.S.
Больше разборов Go, личного опыта и полезных материалов — в моём Telegram-канале: [ссылка на канал]. Подписывайтесь.