Базовая архитектура сервиса на GO

от автора

Базовая архитектура сервиса на GO

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

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

  1. Гибкость в использовании Usecase

  2. Слои и их названия

  3. Акцент на микросервисы

  4. Миграции и работа с БД

  5. Middleware и инфраструктурные сервисы

  6. Практическая адаптация

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

Все начинается с запроса. Уровень Handlers.

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

  1. Валидация запроса
    Проверяем, что запрос соответствует ожидаемому формату и содержит все необходимые данные.

  2. Преобразование запроса в модель
    Конвертируем данные из внешнего формата (например, JSON) во внутреннюю бизнес-модель.

  3. Вызов Usecase
    Передаем модель в слой бизнес-логики (Usecase). Один хендлер должен вызывать только один Usecase.

  4. Обработка ошибок
    Ловим и обрабатываем ошибки, которые могут возникнуть в процессе выполнения. Не всегда удобно или возможно делать это на уровне middleware.

  5. Формирование и возврат ответа
    Преобразуем результат работы Usecase в формат, понятный внешнему миру (например, JSON), и возвращаем его.

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

Пример HTTP-хендлера на Go:

func UpdateUserHandler(w http.ResponseWriter, r *http.Request) {     var request UserUpdateRequest     if err := json.NewDecoder(r.Body).Decode(&request); err != nil {         http.Error(w, "Invalid request", http.StatusBadRequest)         return     }      user := request.ToModel()     updatedUser, err := userUsecase.Update(user)     if err != nil {         http.Error(w, err.Error(), http.StatusInternalServerError)         return     }      response := updatedUser.ToResponse()     w.Header().Set("Content-Type", "application/json")     json.NewEncoder(w).Encode(response) }
Рисунок 1. Handler

Рисунок 1. Handler

Организация хендлеров в пространстве. Роутеры.

Прежде чем углубляться в слои бизнес-логики, важно обсудить, как организовать множество хендлеров в рамках сервиса. Хендлеров может быть очень много, и для каждого из них могут потребоваться свои middleware (MW). Например, для одних нужна авторизация, для других — логирование, а для третьих — проверка прав доступа. Чтобы избежать хаоса и дублирования кода, хендлеры нужно логически группировать и настраивать группы, а не каждый в отдельности.

Для этого мы используем роутеры. Вместо того чтобы настраивать все хендлеры в одном роутере, мы создаем отдельные роутеры для каждой группы хендлеров и настраиваем роутер сразу.

Пример реализации роутера для cущности юзера:

type UserRouter struct { serverConfig   *config.Config no_auth_router *mux.Router auth_router    *mux.Router logger         logger.Logger User           entities.UserUseCaseInterface Object         entities.ObjectUseCaseInterface }  func NewUserRouter(serverConfig *config.Config, nar *mux.Router, ar *mux.Router, UserUC entities.UserUseCaseInterface, ObjectUC entities.ObjectUseCaseInterface, log logger.Logger) *UserRouter { return &UserRouter{ serverConfig:   serverConfig, no_auth_router: nar, auth_router:    ar, logger:         log, User:           UserUC, Object:         ObjectUC, } }  func ConfigureRouter(ur *UserRouter) { ur.auth_router.HandleFunc("/user/current", ur.GetUserFromAuth()).Methods("GET")  ur.auth_router.Use(middleware.CORS) ur.auth_router.Use(middleware.Logger(ur.logger)) ur.auth_router.Use(middleware.Authorization(ur.serverConfig.TelegramBotToken, ur.User)) ur.auth_router.Use(middleware.Recover(ur.logger))  ur.no_auth_router.HandleFunc("/user/id/{id}", ur.ReadByIdHandler).Methods("GET", "OPTIONS") ur.no_auth_router.HandleFunc("/user/email/{email}", ur.ReadByEmailHandler).Methods("GET", "OPTIONS") ur.no_auth_router.HandleFunc("/register", ur.RegistrationHandler).Methods("POST", "OPTIONS")  ur.no_auth_router.Use(middleware.CORS) ur.no_auth_router.Use(middleware.Logger(ur.logger)) ur.no_auth_router.Use(middleware.Recover(ur.logger)) }

И объединение всех роутеров в один мега-роутер:

rout := mux.NewRouter()  // Routers pingrouter := PingRouter.NewPingRouter(s.config, rout.PathPrefix("/api/v1").Subrouter(), UserUC, log) instructionrouter := InstructionRouter.NewInstructionRouter(s.config, rout.PathPrefix("/api/v1").Subrouter(), InstructionUC, UserUC, log) productrouter := ProductRouter.NewProductRouter(s.config, rout.PathPrefix("/api/v1").Subrouter(), ProductUC, UserUC, log) rentedproductrouter := RentedProductRouter.NewRentedProductRouter(s.config, rout.PathPrefix("/api/v1").Subrouter(), RentedProductUC, UserUC, log) showcaserouter := ShowcaseRouter.NewShowcaseRouter(s.config, rout.PathPrefix("/api/v1").Subrouter(), ShowcaseUC, UserUC, log) userrouter := UserRouter.NewUserRouter(s.config, rout.PathPrefix("/api/v1").Subrouter(), rout.PathPrefix("/api/v1").Subrouter(), UserUC, ObjectUC, log) objectrouter := ObjectRouter.NewObjectRouter(s.config, rout.PathPrefix("/api/v1").Subrouter(), ObjectUC, UserUC, log) cardrouter := CardRouter.NewCardRouter(s.config, rout.PathPrefix("/api/v1").Subrouter(), CardUC, UserUC, log)  http.Handle("/", rout)      // Configure Routers PingRouter.ConfigureRouter(pingrouter) InstructionRouter.ConfigureRouter(instructionrouter) ProductRouter.ConfigureRouter(productrouter) RentedProductRouter.ConfigureRouter(rentedproductrouter) ShowcaseRouter.ConfigureRouter(showcaserouter) UserRouter.ConfigureRouter(userrouter) ObjectRouter.ConfigureRouter(objectrouter) CardRouter.ConfigureRouter(cardrouter)
Рисунок 2. Routers

Рисунок 2. Routers

Между запросом и роутером. Middleware.

Прежде чем запрос попадет в роутер и будет обработан хендлером, его часто нужно предварительно обработать. Это может быть проверка авторизации пользователя, настройка CORS (Cross-Origin Resource Sharing), логирование запросов, добавление заголовков или даже преобразование данных. Для этих задач используются middleware — промежуточные обработчики, которые выполняются перед тем, как запрос достигнет хендлера.

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

Самый понятный пример использования MW на мой взгляд — проверка авторизации и дальнейшее прокидывание информации об авторизированном юзере в наши хендлеры и бизнес-логику:

func AuthMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Пропуск аутентификации для предзапросов CORS if r.Method == http.MethodOptions { next.ServeHTTP(w, r) return }  cookie, err := r.Cookie("session") if err != nil || !checkAuthorization(cookie.Value) { w.WriteHeader(http.StatusUnauthorized) return } ctx := context.WithValue(r.Context(), Key, database.UserHash[cookie.Value]) next.ServeHTTP(w, r.WithContext(ctx)) }) }  func checkAuthorization(hash string) bool { _, exists := database.UserHash[hash] return exists }

Поэтому воткнем на нашу схему MW, не забываем, что для каждого роутера они могут быть свои.

Рисунок 3. Middleware

Рисунок 3. Middleware

Здесь делается бизнес. Usecase

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

Что делает UseCase?

UseCase отвечает за:

  • Бизнес-правила: что можно делать, а что нельзя.

  • Преобразование данных: фильтрация, обогащение, агрегация.

  • Взаимодействие с сервисами: получение данных, отправка изменений.

  • Возврат результата: подготовка данных для ответа.

Как организован UseCase?

Обычно UseCase представляет собой класс (или структуру в Go), который группирует методы, связанные с одной бизнес-сущностью или процессом. Например, для сущности «Пользователь» может быть UseCase с методами:

  • CreateUser

  • UpdateUser

  • DeleteUser

  • GetUser

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

Пример UseCase на Go:

type UserUseCase struct {     userRepo UserRepository     authService AuthService }  func (u *UserUseCase) CreateUser(user User) (*User, error) {     // 1. Проверка бизнес-предусловий     if !u.authService.CanCreateUser(user) {         return nil, errors.New("user creation not allowed")     }      // 2. Получение данных из сервисов     existingUser, err := u.userRepo.GetByEmail(user.Email)     if err != nil && !errors.Is(err, sql.ErrNoRows) {         return nil, err     }     if existingUser != nil {         return nil, errors.New("user already exists")     }      // 3. Преобразование данных     user.ID = uuid.New().String()     user.CreatedAt = time.Now()      // 4. Отправка изменений в сервисы     if err := u.userRepo.Save(user); err != nil {         return nil, err     }      // 5. Возврат результата     return &user, nil }
Рисунок 4. UseCase

Рисунок 4. UseCase

Сущности. Entities

Сущности — это простые структуры данных, которые описывают объекты предметной области. Например, сущность User может содержать поля, такие как ID, Name, Email, CreatedAt и т.д. Эти сущности используются на всех слоях приложения, но их описание обычно находится в одном месте для удобства.

Раньше я упоминал, что интерфейсы можно держать рядом с реализацией UseCase, но мне больше нравится другой подход: держать интерфейсы рядом с описанием сущности. Это делает код более наглядным, так как вы видите поля сущности и методы, которые с ней связаны, в одном месте.

Опишем сущность юзера и его юзкейсы:

type User struct { Name       string `json:"name"` Email      string `json:"email"` Password   string `json:"password"` RePassword string `json:"repassword"` }  type UserUseCase interface { Signup(user *User) (*User, *Session, error) Login(user *User) (*User, *Session, error) Logout(id string) error CheckAuth(sessionID string) (*Session, error) }

И дополним нашу схему:

Рисунок 5. Entities

Рисунок 5. Entities

Дополнение: Services и вспомогательные функции

В дополнение к сущностям (Entities) стоит упомянуть Services. Некоторые разработчики выделяют Services в отдельный слой и используют их как вспомогательные функции для бизнес-логики. Однако я предпочитаю более гибкий подход: если функция нужна только один раз, я пишу её прямо в UseCase или Handler. Если же функция используется часто, я выношу её в файл с сущностями (Entities), чтобы её могли использовать другие части приложения.

На примере пользователя такой функцией может быть например валидация почты:

type User struct { Name       string `json:"name"` Email      string `json:"email"` Password   string `json:"password"` RePassword string `json:"repassword"` }  type UserUseCase interface { Signup(user *User) (*User, *Session, error) Login(user *User) (*User, *Session, error) Logout(id string) error CheckAuth(sessionID string) (*Session, error) }  func EmailIsValid(email string) bool { _, err := mail.ParseAddress(email) return err == nil }
Рисунок 6. Services

Рисунок 6. Services

Базы данных. Repository

Бизнес-логика (UseCase) не существует сама по себе — ей нужно где-то хранить данные. Для этого используется слой Repository. Этот слой отвечает за взаимодействие с базой данных, но при этом он абстрагирован от конкретной реализации базы данных. Это позволяет легко менять базу данных (например, с PostgreSQL на MongoDB) без изменения бизнес-логики.

Что такое Repository?

Repository — это слой, который:

  1. Абстрагирует доступ к базе данных.

  2. Предоставляет методы для работы с данными (CRUD: Create, Read, Update, Delete).

  3. Работает с сущностями (Entities), а не с бизнес-моделями (Models).

Зачем нужен Repository?

  1. Изоляция бизнес-логики
    UseCase не должен знать, как данные хранятся и как они извлекаются. Это задача Repository.

  2. Тестируемость
    Repository можно легко мокировать в unit-тестах, что позволяет тестировать UseCase изолированно.

Как работает Repository?

Repository предоставляет интерфейс, который описывает методы для работы с данными. Например, для сущности User это может быть:

  • GetByID — получение пользователя по ID.

  • GetByEmail — получение пользователя по email.

  • Save — сохранение пользователя.

  • Delete — удаление пользователя.

Репозитории так же удобно сгруппировать по сущностям, поэтому не забываем добавить наш интерфейс репозитория в Entities юзера:

type User struct { Name       string `json:"name"` Email      string `json:"email"` Password   string `json:"password"` RePassword string `json:"repassword"` }  type UserUseCase interface { Signup(user *User) (*User, *Session, error) Login(user *User) (*User, *Session, error) Logout(id string) error CheckAuth(sessionID string) (*Session, error) }  type UserRepository interface { CreateUser(signup *User) (*User, error) CheckUser(login *User) (*User, error) GetByEmail(email string) (bool, error) }  func EmailIsValid(email string) bool { _, err := mail.ParseAddress(email) return err == nil }

На нашей схеме это будет выглядеть так:

Рисунок 7. Repository

Рисунок 7. Repository

Меняем СУБД без боли. Interfaces

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

Слой Adapters: Работа с внешними API

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

Если взаимодействие происходит по RPC, то у нас уже есть готовый клиент. Но если это внешний API, нам нужно написать собственный клиент, который будет:

  • Шифровать данные.

  • Передавать креды.

  • Скрывать технические детали (например, заголовки или параметры запросов).

  • Обрабатывать ошибки и преобразовывать ответы.

Эту работу нельзя делать в бизнес-логике, так как это нарушает принцип изоляции. Вместо этого мы создадим слой Adapters, который будет отвечать за взаимодействие с внешними системами.

Пример адаптера для банковского API:

type BankAPIAdapter struct {     baseURL    string     apiKey     string     httpClient *http.Client }  // NewBankAPIAdapter — конструктор для BankAPIAdapter func NewBankAPIAdapter(baseURL, apiKey string) *BankAPIAdapter {     return &BankAPIAdapter{         baseURL:    baseURL,         apiKey:     apiKey,         httpClient: &http.Client{},     } }  // GetCustomerByINN — получение клиента по ИНН func (b *BankAPIAdapter) GetCustomerByINN(inn string) (*Customer, error) {     url := b.baseURL + "/customer?inn=" + inn     req, err := http.NewRequest("GET", url, nil)     if err != nil {         return nil, err     }      // Добавляем заголовок с API-ключом     req.Header.Set("Authorization", "Bearer "+b.apiKey)      // Выполняем запрос     resp, err := b.httpClient.Do(req)     if err != nil {         return nil, err     }     defer resp.Body.Close()      // Обрабатываем ответ     if resp.StatusCode != http.StatusOK {         return nil, errors.New("bank API returned non-200 status code")     }      var customer Customer     if err := json.NewDecoder(resp.Body).Decode(&customer); err != nil {         return nil, err     }      return &customer, nil }
Рисунок 8. Adapters

Рисунок 8. Adapters

Итого

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

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

Буду рад услышать ваши замечания, предложения и идеи по улучшению этой архитектуры. Давайте делиться опытом и делать наши сервисы ещё лучше! 


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


Комментарии

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

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