Skip to content

Hexagonal Architecture in Go

References:

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

В качестве проекта будет использовать пример из Hexagonal Architecture. Разработает user service (сервис пользователя), который предоставляет функцию регистрации для создания новых аккаунтов.

User Service: Hexagonal Architecture

Структура проекта также построена на основе компонентов Hexagonal Architecture. Архитектура подчеркивает четкое разделение ответственности и изолирует основную логику приложения от внешних зависимостей. Вот как организована структура проекта для нашей архитектуры:

├── Dockerfile
├── cmd
│   └── runner.go
├── conf
│   └── app.yaml
├── internal
│   ├── controller
│   │   └── controller.go
│   ├── core
│   │   ├── common
│   │   │   ├── router
│   │   │   │   └── router.go
│   │   │   └── utils
│   │   │       └── logger.go
│   │   ├── dto
│   │   │   └── user.go
│   │   ├── entity
│   │   ├── model
│   │   │   ├── request
│   │   │   │   └── request.go
│   │   │   └── response
│   │   │       └── response.go
│   │   ├── port
│   │   │   ├── repository
│   │   │   │   ├── db.go
│   │   │   │   └── user.go
│   │   │   └── service
│   │   │       └── user.go
│   │   ├── server
│   │   │   └── http_server.go
│   │   └── service
│   │       └── user.go
│   └── infra
│       ├── config
│       │   └── config.go
│       └── repository
│           ├── db.go
│           └── user.go
├── schema
│   └── schema.sql
└── script
    └── run.sh

Рассмотрим роли директорий.

/cmd

Этот каталог содержит точку входа вашего приложения, в данном случае файл runner.go. Здесь вы обычно определяете основную функцию и настраиваете свое приложение.

/internal

Этот каталог является фундаментальной частью Hexagonal Architecture, поскольку он содержит внутренний код приложения, разделенный на различные пакеты.

/internal/controller

Этот пакет содержит файлы, отвечающие за обработку HTTP-запросов и вызов соответствующей логики ядра. Например, файл controller.go может определять функции для обработки различных HTTP-endpoints. Это primary adapter / driver adapter (первичный адаптер / ведущий адаптер).

/internal/core

Этот пакет содержит основную логику приложения и разделен на подпакеты:

  • common: Содержит общие утилиты, используемые в application (приложении), такие, как router.go (для маршрутизации HTTP) и logger.go (для ведения журнала).
  • dto: Этот пакет определяет data transfer objects (DTO, объекты передачи данных), используемые для передачи данных между различными слоями.
  • entity: Содержит domain entities (доменные сущности), представляющие основные структуры данных, используемые в application.
  • model: Этот пакет содержит структуры model (моделей), представляющие конкретные тела HTTP-запросов и ответов.
  • port: Здесь вы определяете интерфейсы (ports), которые представляют необходимые функции application. Например, интерфейсы repository определяют методы для доступа к данным, а интерфейсы service определяют методы для бизнес-логики.
  • server: Этот пакет содержит настройку HTTP-сервера.
  • service: Этот пакет содержит основные сервисы application, которые обрабатывают бизнес-логику.

/internal/infra

Этот пакет содержит код, связанный с инфраструктурой, они являются secondary adapters / driven adapters (вторичными адаптерами / ведомыми адаптерами):

  • config: Этот файл обрабатывает настройку и разбор конфигурации.
  • repository: Этот пакет содержит реализации интерфейсов репозиториев, таких как db.go и user.go, взаимодействующие с базой данных.

Кроме того, у нас есть несколько других папок:

  • conf: Этот каталог содержит файлы конфигурации вашего приложения, такие как файл app.yaml. Здесь вы храните настройки, связанные с поведением вашего приложения.
  • schema: Обычно содержит файлы схем базы данных, такие как schema.sql, определяющие структуру таблиц базы данных.
  • script: Этот каталог содержит сценарии, такие как run.sh, которые могут использоваться для автоматизации общих задач или выполнения приложения.
  • Dockerfile: Файл, используемый для определения образа Docker для вашего приложения, позволяющий контейнеризировать его.
  • LICENSE: Файл содержит информацию о лицензии
  • README.md: Файл содержит документацию проекта

Организуя код таким образом, Hexagonal Architecture способствует четкому разделению основной логики приложения и внешних зависимостей, что приводит к повышению maintainability (поддерживаемости), testability (тестируемости) и flexibility (гибкости). Это позволяет легко заменять или изменять внешние компоненты без влияния на основную логику приложения.

Благодаря введению в функциональность директорий, мы можем легко визуализировать и понять структуру кода проектов.

Организация кода

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

Модели запросов и ответов

Начнем с создания необходимых request and response models (моделей запросов и ответов) для обработки запросов регистрации.

// ./internal/core/model/request/request.go

package request

type SignUpRequest struct {
    Username string `json:"username"`
    Password string `json:"password"`
}
// ./internal/core/model/response/response.go

package response

import "user-service/internal/core/entity/error_code"

type Response struct {
    Data         interface{}          `json:"data"`
    Status       bool                 `json:"status"`
    ErrorCode    error_code.ErrorCode `json:"errorCode"`
    ErrorMessage string               `json:"errorMessage"`
}

type SignUpDataResponse struct {
    DisplayName string `json:"displayName"`
}

В файле internal/core/model/request/request.go мы определяем структуру SignUpRequest, которая содержит информацию об имени пользователя и пароле.

Определение интерфейса UserService (Primary Port Component)

Мы определяем первичный порт, которым является интерфейс UserService в файле internal/core/port/service/user.go. Этот интерфейс описывает функциональность, необходимую для обработки запросов регистрации.

// ./internal/core/port/service/user.go

package service

import (
    "user-service/internal/core/model/request"
    "user-service/internal/core/model/response"
)

type UserService interface {
    SignUp(request *request.SignUpRequest) *response.Response
}

Реализация UserService (Application/Business Component)

В файле internal/core/service/user.go мы реализуем интерфейс UserService с конкретным сервисом userService. Этот сервис обрабатывает основную бизнес-логику проекта. Функция SignUp в этом сервисе валидирует запрос, генерирует случайное отображаемое имя и сохраняет нового пользователя в базу данных с использованием UserRepository.

// ./internal/core/service/user.go

package service

import (
    "user-service/internal/core/common/utils"
    "user-service/internal/core/dto"
    "user-service/internal/core/entity/error_code"
    "user-service/internal/core/model/request"
    "user-service/internal/core/model/response"
    "user-service/internal/core/port/repository"
    "user-service/internal/core/port/service"
)

const (
    invalidUserNameErrMsg = "invalid username"
    invalidPasswordErrMsg = "invalid password"
)

type userService struct {
    userRepo repository.UserRepository
}

func NewUserService(userRepo repository.UserRepository) service.UserService {
    return &userService{
        userRepo: userRepo,
    }
}

func (u userService) SignUp(request *request.SignUpRequest) *response.Response {
    // validate request
    if len(request.Username) == 0 {
        return u.createFailedResponse(error_code.InvalidRequest, invalidUserNameErrMsg)
    }

    if len(request.Password) == 0 {
        return u.createFailedResponse(error_code.InvalidRequest, invalidPasswordErrMsg)
    }

    currentTime := utils.GetUTCCurrentMillis()
    userDTO := dto.UserDTO{
        UserName:    request.Username,
        Password:    request.Password,
        DisplayName: u.getRandomDisplayName(request.Username),
        CreatedAt:   currentTime,
        UpdatedAt:   currentTime,
    }

    // save a new user
    err := u.userRepo.Insert(userDTO)
    if err != nil {
        if err == repository.DuplicateUser {
            return u.createFailedResponse(error_code.DuplicateUser, err.Error())
        }
        return u.createFailedResponse(error_code.InternalError, error_code.InternalErrMsg)
    }

    // create data response
    signUpData := response.SignUpDataResponse{
        DisplayName: userDTO.DisplayName,
    }
    return u.createSuccessResponse(signUpData)
}

func (u userService) getRandomDisplayName(username string) string {
    return username + utils.GetUUID()
}

func (u userService) createFailedResponse(
    code error_code.ErrorCode, message string,
) *response.Response {
    return &response.Response{
        Status:       false,
        ErrorCode:    code,
        ErrorMessage: message,
    }
}

func (u userService) createSuccessResponse(data response.SignUpDataResponse) *response.Response {
    return &response.Response{
        Data:         data,
        Status:       true,
        ErrorCode:    error_code.Success,
        ErrorMessage: error_code.SuccessErrMsg,
    }
}

В приведенном выше коде объект UserDTO для одного сервиса error_code. Структуры определены следующим образом:

// ./internal/core/dto/user.go

package dto

type UserDTO struct {
    UserName  string
    Password  string
    CreatedAt uint64
    UpdatedAt uint64
}
// ./internal/core/entity/error_code/error_code.go

package error_code

type ErrorCode string

// error code
const (
    Success        ErrorCode = "SUCCESS"
    InvalidRequest ErrorCode = "INVALID_REQUEST"
    DuplicateUser  ErrorCode = "DUPLICATE_USER"
    InternalError  ErrorCode = "INTERNAL_ERROR"
)

// error message
const (
    SuccessErrMsg        = "success"
    InternalErrMsg       = "internal error"
    InvalidRequestErrMsg = "invalid request"
)

Создание интерфейса UserRepository (Secondary Port Component)

Для взаимодействия с базой данных мы создаем вторичный порт, интерфейс UserRepository, в файлеinternal/core/port/repository/user.go. Этот интерфейс определяет контракт для вставки нового объекта UserDTO.

// ./internal/core/port/repository/user.go

package repository

import (
    "errors"
    "user-service/internal/core/dto"
)

var (
    DuplicateUser = errors.New("duplicate user")
)

type UserRepository interface {
    Insert(user dto.UserDTO) error
}

Реализация UserRepository (Secondary Adapter)

Файл internal/infra/repository/user.go содержит реализацию интерфейса UserRepository. Этот репозиторий взаимодействует с базой данных для вставки нового пользователя. Он также обрабатывает любые ошибки, которые могут возникнуть в процессе вставки, такие как дублирование записей пользователя. Это вторичный адаптер.

// ./internal/infra/repository/user.go

package repository

import (
    "errors"
    "strings"
    "user-service/internal/core/dto"
    "user-service/internal/core/port/repository"
)

const (
    duplicateEntryMsg = "Duplicate entry"
    numberRowInserted = 1
)

var (
    insertUserErr = errors.New("failed to insert user")
)

const (
    insertUserStatement = "INSERT INTO User ( " +
        "`username`, " +
        "`password`, " +
        "`display_name`, " +
        "`created_at`," +
        "`updated_at`) " +
        "VALUES (?, ?, ?, ?, ?)"
)

type userRepository struct {
    db repository.Database
}

func NewUserRepository(db repository.Database) repository.UserRepository {
    return &userRepository{
        db: db,
    }
}

func (u userRepository) Insert(user dto.UserDTO) error {
    result, err := u.db.GetDB().Exec(
        insertUserStatement,
        user.UserName,
        user.Password,
        user.DisplayName,
        user.CreatedAt,
        user.UpdatedAt,
    )

    if err != nil {
        if strings.Contains(err.Error(), duplicateEntryMsg) {
            return repository.DuplicateUser
        }
        return err
    }

    numRow, err := result.RowsAffected()
    if err != nil {
        return err
    }

    if numRow != numberRowInserted {
        return insertUserErr
    }

    return nil
}

Для подключения к базе данных сначала определим интерфейс Database (в файле internal/core/port/repository/db.go), чтобы предоставить подключения к базе данных для сервиса. Кроме того, мы реализуем адаптер базы данных в internal/infra/repository/db.go, который настраивает подключение и инициализирует драйвер базы данных.

// ./internal/core/port/repository/db.go

package repository

import (
    "database/sql"
    "io"
)

type Database interface {
    io.Closer
    GetDB() *sql.DB
}

А вот и адаптер базы данных:

// ./internal/infra/repository/db.go

package repository

import (
    "database/sql"
    "time"
    _ "github.com/go-sql-driver/mysql"
    "user-service/internal/core/port/repository"
    "user-service/internal/infra/config"
)

type database struct {
    *sql.DB
}

func NewDB(conf config.DatabaseConfig) (*repository.Database, error) {
    db, err := newDatabase(conf)
    if err != nil {
        return nil, err
    }
    return &database{
        db,
    }, nil
}

func newDatabase(conf config.DatabaseConfig) (*sql.DB, error) {
    db, err := sql.Open(conf.Driver, conf.Url)
    if err != nil {
        return nil, err
    }

    db.SetConnMaxLifetime(time.Minute * time.Duration(conf.ConnMaxLifetimeInMinute))
    db.SetMaxOpenConns(conf.MaxOpenConns)
    db.SetMaxIdleConns(conf.MaxIdleConns)

    if err := db.Ping(); err != nil {
        return nil, err
    }

    return db, err
}

func (da database) Close() error {
    return da.DB.Close()
}

func (da database) GetDB() *sql.DB {
    return da.DB
}

На этом этапе мы почти закончили проект. У нас уже есть компонент, который обрабатывает бизнес-логику (UserService) и компонент, который подключается к базе данных (UserRepository и Database). Давайте посмотрим на архитектуру, чтобы найти, какие компоненты нам не хватает.

User Service: Hexagonal Architecture

Создание UserController (Primary Adapter)

Нам не хватает важного компонента — контроллера (Primary Adapter) сервиса. Теперь нам нужен первичный адаптер, UserController, для обработки входящих HTTP-запросов и вызова основной логики приложения. Файл internal/controller/controller.go содержит реализацию этого контроллера. Он получает HTTP-запросы для функции регистрации и передает обработку запросов в UserService.

// ./internal/controller/controller.go

package controller

import (
    "net/http"
    "github.com/gin-gonic/gin"
    "user-service/internal/core/common/router"
    "user-service/internal/core/entity/error_code"
    "user-service/internal/core/model/request"
    "user-service/internal/core/model/response"
    "user-service/internal/core/port/service"
)

var (
    invalidRequestResponse = &response.Response{
        ErrorCode:    error_code.InvalidRequest,
        ErrorMessage: error_code.InvalidRequestErrMsg,
        Status:       false,
    }
)

type UserController struct {
    gin         *gin.Engine
    userService service.UserService
}

func NewUserController(
    gin *gin.Engine,
    userService service.UserService,
) UserController {
    return UserController{
        gin:         gin,
        userService: userService,
    }
}

func (u UserController) InitRouter() {
    api := u.gin.Group("/api/v1")
    router.Post(api, "/signup", u.signUp)
}

func (u UserController) signUp(c *gin.Context) {
    req, err := u.parseRequest(c)
    if err != nil {
        c.AbortWithStatusJSON(http.StatusOK, &invalidRequestResponse)
        return
    }

    resp := u.userService.SignUp(req)
    c.JSON(http.StatusOK, resp)
}

func (u UserController) parseRequest(ctx *gin.Context) (*request.SignUpRequest, error) {
    var req request.SignUpRequest
    if err := ctx.ShouldBindJSON(&req); err != nil {
        return nil, err
    }

    return &req, nil
}

В конструкторе UserController мы регистрируем соответствующие маршруты для функции регистрации, используя пакет github.com/gin-gonic/gin. Например, мы настраиваем конечную точку /api/v1/signup для обработки запросов на регистрацию. Функция signUp в UserController анализирует входящие запросы и отправляет ответ клиенту. Если запрос недействителен, он возвращает соответствующий ответ об ошибке.

Создание HTTP-сервера

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

// ./internal/core/server/http_server.go

package server

import (
    "context"
    "fmt"
    "log"
    "net/http"
    "time"

    "github.com/gin-gonic/gin"
    "user-service/internal/infra/config"
)

const defaultHost = "0.0.0.0"

type HttpServer interface {
    Start()
    Stop()
}

type httpServer struct {
    Port   uint
    server *http.Server
}

func NewHttpServer(router *gin.Engine, config config.HttpServerConfig) HttpServer {
    return &httpServer{
        Port: config.Port,
        server: &http.Server{
            Addr:    fmt.Sprintf("%s:%d", defaultHost, config.Port),
            Handler: router,
        },
    }
}

func (httpServer httpServer) Start() {
    go func() {
        if err := httpServer.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf(
                "failed to start HttpServer on port %d, err=%s",
                httpServer.Port, err.Error(),
            )
        }
    }()

    log.Printf("Service started on port %d", httpServer.Port)
}

func (httpServer httpServer) Stop() {
    ctx, cancel := context.WithTimeout(
        context.Background(), time.Duration(3)*time.Second,
    )
    defer cancel()

    if err := httpServer.server.Shutdown(ctx); err != nil {
        log.Fatalf("Server forced to shutdown, err=%s", err.Error())
    }
}

В файле internal/core/server/http_server.go мы определяем интерфейс HttpServer с двумя методами:

  • Start() - запускает сервер для прослушивания входящих запросов
  • Stop()- gracefully shuts down сервер при необходимости.

Объединение всего вместе — функция main()

Собрав все части нашей функции регистрации, мы готовы подготовить главную функцию нашей программы. main() объединит все компоненты и запустит HTTP-сервер, что позволит сделать функцию регистрации доступной для пользователей.

// ./cmd/runner.go

package main

import (
    "log"
    "os"
    "os/signal"
    "syscall"

    "github.com/gin-gonic/gin"

    "user-service/internal/controller"
    "user-service/internal/core/server"
    "user-service/internal/core/service"
    "user-service/internal/infra/config"
    "user-service/internal/infra/repository"
)

func main() {
    // Создаем новый экземпляр маршрутизатора Gin
    instance := gin.New()
    instance.Use(gin.Recovery())

    // Инициализируем подключение к базе данных
    db, err := repository.NewDB(
        config.DatabaseConfig{
            Driver:                  "mysql",
            Url:                     "user:password@tcp(127.0.0.1:3306)/your_database_name?charset=utf8mb4&parseTime=true&loc=UTC&tls=false&readTimeout=3s&writeTimeout=3s&timeout=3s&clientFoundRows=true",
            ConnMaxLifetimeInMinute: 3,
            MaxOpenConns:            10,
            MaxIdleConns:            1,
        },
    )
    if err != nil {
        log.Fatalf("failed to connect to database, err=%s\n", err.Error())
    }

    // Создаем UserRepository
    userRepo := repository.NewUserRepository(db)

    // Создаем UserService
    userService := service.NewUserService(userRepo)

    // Создаем UserController
    userController := controller.NewUserController(instance, userService)

    // Инициализируем маршруты для UserController
    userController.InitRouter()

    // Создаем HTTP-сервер
    httpServer := server.NewHttpServer(
        instance,
        config.HttpServerConfig{
            Port: 8000,
        },
    )

    // Запускаем HTTP-сервер
    httpServer.Start()
    defer httpServer.Stop()

    // Ожидание сигналов ОС для выполнения плавного завершения работы
    log.Println("Listening for signals...")
    c := make(chan os.Signal, 1)
    signal.Notify(
        c,
        os.Interrupt,
        syscall.SIGHUP,
        syscall.SIGINT,
        syscall.SIGQUIT,
        syscall.SIGTERM,
    )
    <-c
    log.Println("Graceful shutdown...")
}

В main() мы создаем новый экземпляр маршрутизатора Gin с использованием gin.New(). Мы также добавляем middleware для обработки восстановления после паники с помощью gin.Recovery().

Затем мы инициализируем подключение к базе данных, используя драйвер mysql и соответствующие данные конфигурации. Функция repository.NewDB() создает новый экземпляр подключения к базе данных, который будет использоваться для взаимодействия с базой данных.

Используя подключение к базе данных, мы создаем новый экземпляр UserRepository с помощью repository.NewUserRepository(db). Этот репозиторий будет отвечать за обработку взаимодействий с базой данных, связанных с пользователями.

Затем мы создаем UserService, передавая UserRepository в service.NewUserService(userRepo). Этот сервис будет обрабатывать основную бизнес-логику функции регистрации.

Мы настраиваем HTTP-сервер с использованием server.NewHttpServer(instance, config.HttpServerConfig{Port: 8000}). Сервер будет слушать на порту 8000. Функция httpServer.Start() запускает сервер для начала обработки входящих запросов.

Наконец, мы используем канал для ожидания сигналов ОС (например, SIGINT, SIGTERM) для gracefully shutdown сервера при необходимости. Когда сигнал получен, мы вызываем httpServer.Stop() для остановки сервера и выполнения чистого завершения работы.

Диаграмма кода

Эта диаграмма иллюстрирует поток данных и управление между различными компонентами User Service. UserController получает HTTP-запросы и передает их в UserService, который обрабатывает бизнес-логику и взаимодействует с UserRepository для доступа к базе данных.

                           +------------------------+
                           |     UserController     |
                           +------------------------+
                           |  - UserService         |
                           +------------------------+
                           |  + SignUp(request)     |
                           +------------------------+
                                     |
                                     | HTTP Requests/Responses
                                     |
                                     V
                           +------------------------+
                           |      UserService       |
                           +------------------------+
                           |  - UserRepository      |
                           +------------------------+
                           |  + SignUp(request)     |
                           +------------------------+
                                     |
                                     | Business Logic
                                     |
                                     V
                           +------------------------+
                           |    UserRepository      |
                           +------------------------+
                           |  - Database            |
                           +------------------------+
                           |  + Insert(user)        |
                           +------------------------+

Запуск

Мы можем написать script для запуска User Service следующим образом:

# ./script/run.sh

#! /bin/sh
go run ../cmd/runner.go

Перед запуском сервиса нам нужно подготовить схему базы данных. Создайте файл с именем schema.sql в каталоге schema/ со следующим содержимым:

-- ./schema/schema.sql

create table User
(
    username     varchar(20) not null primary key,
    password     varchar(64) not null,
    display_name varchar(20) not null,
    created_at   bigint      not null,
    updated_at   bigint      not null
)

Чтобы запустить User Service, выполните следующую команду ./run.sh в корневом каталоге проекта. Сервис начнет работу на порту 8000.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)
[GIN-debug] POST   /api/v1/signup            --> user-service/internal/controller.UserController.signUp-fm (2 handlers)
2023/07/31 20:23:41 Start Service with port 8000
2023/07/31 20:23:41 listening signals...

Тестирование API регистрации

Теперь давайте протестируем API /api/v1/signup с использованием curl или любого другого HTTP-клиента. Например, будем использовать команду curl.

# Выполнение команды curl
curl --location 'http://localhost:8000/api/v1/signup' \
--header 'Content-Type: application/json' \
--data '{
    "username": "test_abc",
    "password": "12345"
}'

Результат:

{
  "data": {
    "displayName": "test_abc1690810036744"
  },
  "status": true,
  "errorCode": "SUCCESS",
  "errorMessage": "success"
}

Если выполнить команду curl снова, вы получите следующий результат с errorCode DUPLICATE_USER:

# Выполнение команды curl
curl --location 'http://localhost:8000/api/v1/signup' \
--header 'Content-Type: application/json' \
--data '{
    "username": "test_abc",
    "password": "12345"
}'

Получите следующий результат с errorCode DUPLICATE_USER:

{
  "data": null,
  "status": false,
  "errorCode": "DUPLICATE_USER",
  "errorMessage": "duplicate user"
}

Написание unit test

Давайте добавим файл модульного теста для компонента UserService. Мы создадим файл с именем user_service_test.go в каталоге internal/core/service.

// ./internal/core/service/user_service

package service

import (
    "testing"

    "user-service/internal/core/dto"
    "user-service/internal/core/entity/error_code"
    "user-service/internal/core/model/request"
    "user-service/internal/core/model/response"
    "user-service/internal/core/port/repository"
)

// Определяем мок-репозиторий для тестирования
type mockUserRepository struct{}

func (m *mockUserRepository) Insert(user dto.UserDTO) error {
    // Имитируем случай дублирования пользователя
    if user.UserName == "test_user" {
        return repository.DuplicateUser
    }
    // Имитируем успешную вставку
    return nil
}

func TestUserService_SignUp_Success(t *testing.T) {
    // Создаем мок-репозиторий для тестирования
    userRepo := &mockUserRepository{}

    // Создаем UserService, используя мок-репозиторий
    userService := NewUserService(userRepo)

    // Тестовый случай: успешная регистрация
    req := &request.SignUpRequest{
        Username: "test_abc",
        Password: "12345",
    }

    res := userService.SignUp(req)
    if !res.Status {
        t.Errorf("expected status to be true, got false")
    }

    data := res.Data.(response.SignUpDataResponse)
    if data.DisplayName == "" {
        t.Errorf("expected non-empty display name, got empty")
    }
}

func TestUserService_SignUp_InvalidUsername(t *testing.T) {
    // Создаем мок-репозиторий для тестирования
    userRepo := &mockUserRepository{}

    // Создаем UserService, используя мок-репозиторий
    userService := NewUserService(userRepo)

    // Тестовый случай: недействительный запрос с пустым именем пользователя
    req := &request.SignUpRequest{
        Username: "",
        Password: "12345",
    }

    res := userService.SignUp(req)
    if res.Status {
        t.Errorf("expected status to be false, got true")
    }

    if res.ErrorCode != error_code.InvalidRequest {
        t.Errorf("expected error code to be InvalidRequest, got %s", res.ErrorCode)
    }
}

func TestUserService_SignUp_InvalidPassword(t *testing.T) {
    // Создаем мок-репозиторий для тестирования
    userRepo := &mockUserRepository{}

    // Создаем UserService, используя мок-репозиторий
    userService := NewUserService(userRepo)

    // Тестовый случай: недействительный запрос с пустым паролем
    req := &request.SignUpRequest{
        Username: "test_user",
        Password: "",
    }

    res := userService.SignUp(req)
    if res.Status {
        t.Errorf("expected status to be false, got true")
    }

    if res.ErrorCode != error_code.InvalidRequest {
        t.Errorf("expected error code to be InvalidRequest, got %s", res.ErrorCode)
    }
}

func TestUserService_SignUp_DuplicateUser(t *testing.T) {
    // Создаем мок-репозиторий для тестирования
    userRepo := &mockUserRepository{}

    // Создаем UserService, используя мок-репозиторий
    userService := NewUserService(userRepo)

    // Тестовый случай: дублирование пользователя
    req := &request.SignUpRequest{
        Username: "test_user",
        Password: "12345",
    }

    res := userService.SignUp(req)
    if res.Status {
        t.Errorf("expected status to be false, got true")
    }

    if res.ErrorCode != error_code.DuplicateUser {
        t.Errorf("expected error code to be DuplicateUser, got %s", res.ErrorCode)
    }
}

Каждая тестовая функция создает новый экземпляр UserService с мок-репозиторием и затем вызывает метод SignUp с соответствующим запросом. Затем она проверяет ответ, чтобы убедиться, что сервис ведет себя так, как ожидается.

Чтобы запустить модульные тесты, выполните следующую команду в корневом каталоге проекта:

go test -v ./internal/core/service

Результат:

=== RUN   TestUserService_SignUp_Success
--- PASS: TestUserService_SignUp_Success (0.00s)
=== RUN   TestUserService_SignUp_InvalidUsername
--- PASS: TestUserService_SignUp_InvalidUsername (0.00s)
=== RUN   TestUserService_SignUp_InvalidPassword
--- PASS: TestUserService_SignUp_InvalidPassword (0.00s)
=== RUN   TestUserService_SignUp_DuplicateUser
--- PASS: TestUserService_SignUp_DuplicateUser (0.00s)
PASS
ok      user-service/internal/core/service      0.203s

Заключение

На этой странице успешно разработан User Service с использованием Golang и соблюдены принципы Hexagonal Architecture, что достаточно для демонстрации того, как каждая часть модуля связана друг с другом. Hexagonal Architecture позволила нам создать гибкую и поддерживаемую структуру приложения, заложив основу для масштабируемости.