Архитектура «монолита» на Go
Введение
В последние годы микросервисная архитектура стала популярным решением для крупных систем. Она позволяет независимо масштабировать сервисы, разделять ответственность между разными командами и развивать отдельные части системы независимо друг от друга. Однако микросервисы не всегда являются лучшим выбором. Для небольших и средних проектов они могут создавать различные сложности: сетевое взаимодействие, мониторинг, отдельные deployment-процессы, сложность локальной разработки и проблемы согласованности данных.
Хорошо спроектированный монолит во многих случаях позволяет разрабатывать приложение быстрее и проще. При этом монолит не обязательно должен представлять собой один большой пакет. Главная идея заключается не в том, чтобы заранее подготовить приложение к микросервисам, а в том, чтобы контролировать зависимости и границы ответственности внутри монолита.
Про DDD и «Чистую архитектуру»
DDD (Domain-Driven Design)
DDD отвечает прежде всего за моделирование предметной области: подход к разработке программного обеспечения,
при котором в центре внимания находится не техническая часть (базы данных, фреймворки или API), а реальная предметная область бизнеса (домен) и его правила
«Чистая архитектура»
Это подход к проектированию программного обеспечения, который делит код на изолированные слои. Главная цель — сделать бизнес-логику независимой от баз данных, фреймворков и интерфейса, чтобы программу было легко тестировать, изменять и поддерживать со временем.
Вместе эти подходы можно использовать следующим образом:
Важно: это не означает, что каждый Go-проект обязан иметь слои как на схеме. Слои и абстракции должны появляться там, где они действительно помогают управлять сложностью проекта.
Общая архитектура приложения
Для примера создадим небольшое приложение с одним модулем — модулем авторизации. Он будет поддерживать только две операции:
- Регистрация пользователя.
- Авторизация пользователя по email и паролю.
В будущем приложение может содержать другие модули: user, order, и т.д. Каждый модуль должен отвечать за определённую часть предметной области.
Структура проекта
Создадим базовую структуру проекта:
. ├── cmd/ │ └── app/ │ └── main.go ├── internal/ │ └── auth/ │ ├── domain/ │ ├── application/ │ ├── port/ │ ├── infrastructure/ │ └── transport/ ├── migration/ ├── config/ ├── go.mod └── README.md
cmd/
Содержит точку входа приложения. main.go отвечает за:
- загрузку конфигурации;
- создание инфраструктуры;
- dependency injection;
- запуск HTTP/gRPC сервера;
Бизнес-логика здесь находиться не должна.
internal/
Содержит внутренний код приложения. Go запрещает импортировать internal из пакетов, находящихся за пределами соответствующего «родительского дерева». Это удобно для ограничения публичного API приложения.
config/
Содержит конфигурацию приложения.
migration/
Содержит миграции базы данных.
Архитектура сервиса
Каждый сервис имеет собственные слои:
internal/auth/ ├── domain/ ├── application/ ├── infrastructure/ ├── port/ └── transport/
Domain
Domain содержит бизнес-модель и правила предметной области. Он не должен зависеть от сторонних пакетов.
Создадим в данном пакете структуру пользователя и базовые структуры токенов,
а также функцию NewUser для корректной инициализации структуры пользователя:
user.go
package domain
import "github.com/google/uuid"
type User struct {
ID uuid.UUID
Email string
PasswordHash string
}
func NewUser(email, passwordHash string) User {
return User{
ID: uuid.New(),
Email: email,
PasswordHash: passwordHash,
}
}
claims.go
package domain
import (
"time"
"github.com/google/uuid"
)
type AccessToken struct {
UserID uuid.UUID
Email string
ExpiresAt time.Time
}
type RefreshToken struct {
UserID uuid.UUID
ExpiresAt time.Time
}
Port
Application должен иметь возможность работать с адаптерами БД, сторонних API, JWT, и т.д., не завися от их реализации. Для этого определим порты:
port.go
package port
import (
"project/internal/auth/domain"
"context"
"github.com/google/uuid"
)
type UserRepository interface {
Get(context.Context, uuid.UUID) (domain.User, error)
GetByEmail(context.Context, string) (domain.User, error)
Create(context.Context, domain.User) error
}
type TokenIssuer interface {
IssueAccess(context.Context, domain.AccessToken) (string, error)
IssueRefresh(context.Context, domain.RefreshToken) (string, error)
}
type PasswordHasher interface {
Hash(context.Context, string) (string, error)
Compare(context.Context, string, string) error
}
"Порты" позволяют нам прописать чёткие контракты для всех сторонних пакетов так, чтобы изменения, например, базы данных не затрагивали слой бизнес-логики.
Application
Application layer содержит use cases. Для нашего примера это:
- SignUp
- SignIn
Application может использовать domain и порты, но не должен напрямую зависеть от PostgreSQL, HTTP или конкретной JWT-библиотеки.
Опишем структуру сервиса:
service.go
package application
import (
"project/internal/auth/port"
)
type AuthService struct {
repository port.UserRepository
tokenIssuer port.TokenIssuer
passwordHasher port.PasswordHasher
}
func NewAuthService(
repository port.UserRepository,
tokenIssuer port.TokenIssuer,
passwordHasher port.PasswordHasher,
) *AuthService {
return &AuthService{
repository: repository,
tokenIssuer: tokenIssuer,
passwordHasher: passwordHasher,
}
}
Зависимости передаются через constructor. Это называется constructor injection.
Далее создадим usecase`s для SignIn и SignUp
Стоит упомянуть, что если вы определяете DTO(не вижу смысла в данном случае), то ошибка должна быть отдельной частью ответа метода и встраивать ее в структуру ответа будет некорректно.
sign_in.go
package application
import (
"project/internal/auth/domain"
"context"
"errors"
"time"
)
func (service *AuthService) SignIn(ctx context.Context, email, password string) (string, string, error) {
user, err := service.repository.GetByEmail(ctx, email)
if err != nil {
return "", "", errors.New("неверный email или пароль")
}
err = service.passwordHasher.Compare(ctx, user.PasswordHash, password)
if err != nil {
return "", "", errors.New("неверный email или пароль")
}
accessToken, err := service.tokenIssuer.IssueAccess(
ctx,
domain.AccessToken{
UserID: user.ID,
Email: user.Email,
ExpiresAt: time.Now().Add(5 * time.Minute),
},
)
if err != nil {
return "", "", errors.New("ошибка при авторизации пользователя")
}
refreshToken, err := service.tokenIssuer.IssueRefresh(
ctx,
domain.RefreshToken{
UserID: user.ID,
ExpiresAt: time.Now().Add(10 * time.Minute),
},
)
if err != nil {
return "", "", errors.New("ошибка при авторизации пользователя")
}
return accessToken, refreshToken, nil
}
sign_up.go
package application
import (
"project/internal/auth/domain"
"context"
"errors"
)
func (service *AuthService) SignUp(ctx context.Context, email, password string) error {
_, err := service.repository.GetByEmail(ctx, email)
if err == nil {
return errors.New("пользователь с данным email уже существует")
}
hashedPassword, err := service.passwordHasher.Hash(ctx, password)
if err != nil {
return errors.New("ошибка при регистрации пользователя")
}
user := domain.NewUser(
email,
hashedPassword,
)
err = service.repository.Create(ctx, user)
if err != nil {
return errors.New("ошибка при регистрации пользователя")
}
return nil
}
Infrastructure
Infrastructure содержит конкретные реализации портов. Я не буду подробно расписывать содержимое их методов, лишь продемонстрирую общую структуру:
internal/auth/infrastructure/
├── postgres/
│ └── repository.go
├── bcrypt/
│ └── password_hasher.go
└── jwt/
└── token_issuer.go
repository.go
package postgres
import (
"project/internal/auth/port"
"project/internal/auth/domain"
"context"
"github.com/google/uuid"
"database/sql"
_ "github.com/lib/pq"
)
type repository struct {
db *sql.DB
}
func NewRepository(
db *sql.DB,
) port.UserRepository {
return &repository{
db: db,
}
}
func (repository *repository) Get(ctx context.Context, id uuid.UUID) (domain.User, error) {
return domain.User{}, nil
}
func (repository *repository) GetByEmail(ctx context.Context, email string) (domain.User, error) {
return domain.User{}, nil
}
func (repository *repository) Create(ctx context.Context, user domain.User) error {
return nil
}
password_hasher.go
package bcrypt
import (
"project/internal/auth/port"
"context"
)
type passwordHasher struct{}
func NewPasswordHasher() port.PasswordHasher {
return &passwordHasher{}
}
func (hasher *passwordHasher) Hash(ctx context.Context, password string) (string, error) {
return "", nil
}
func (hasher *passwordHasher) Compare(ctx context.Context, hashed, input string) error {
return nil
}
token_issuer.go
package jwt
import (
"context"
"project/internal/auth/domain"
"project/internal/auth/port"
)
type Issuer struct {
accessSecret []byte
refreshSecret []byte
}
func NewIssuer(
accessSecret string,
refreshSecret string,
) port.TokenIssuer {
return &Issuer{
accessSecret: []byte(accessSecret),
refreshSecret: []byte(refreshSecret),
}
}
func (issuer *Issuer) IssueAccess(
ctx context.Context, token domain.AccessToken,
) (string, error) {
return "", nil
}
func (issuer *Issuer) IssueRefresh(ctx context.Context, token domain.RefreshToken) (string, error) {
return "", nil
}
Transport
Transport в нашем примере будет в виде стандартного JSON API. Он должен:
- получить HTTP запрос;
- декодировать JSON;
- выполнить базовую валидацию;
- преобразовать request в application input;
- вызвать use case;
- преобразовать результат в HTTP ответ.
api.go
package api
import "project/internal/auth/application"
type API struct {
service *application.AuthService
}
func NewAPI(service *application.AuthService) *API {
return &API{
service: service,
}
}
sign_in.go
package api
import (
"net/http"
"encoding/json"
)
func (api *API) SignIn(w http.ResponseWriter, r *http.Request) {
var data map[string]string
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "ошибка декодирования запроса",
})
return
}
access, refresh, err := api.service.SignIn(
r.Context(),
data["email"],
data["password"],
)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": err.Error(),
})
return
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{
"access_token": access,
"refresh_token": refresh,
})
}
sign_up.go
package api
import (
"net/http"
"encoding/json"
)
func (api *API) SignUp(w http.ResponseWriter, r *http.Request) {
var data map[string]string
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "ошибка декодирования запроса",
})
return
}
err := api.service.SignUp(
r.Context(),
data["email"],
data["password"],
)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": err.Error(),
})
return
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{
"message": "user successfully registered",
})
}
Важно: не стоит использовать domain entity как HTTP ответ. Это позволяет независимо менять внешний API и внутреннюю модель.
Об обработке ошибок
Ошибки должны иметь смысл для конкретного слоя. Например:
Domain/Application
↓
error
↓
Transport
↓
HTTP код + error
Внутренние ошибки PostgreSQL не должны напрямую возвращаться клиенту.
Взаимодействие модулей
Предположим, кроме auth появился модуль orders. Не следует делать так: orders → auth/infrastructure/postgres и не стоит давать одному модулю доступ ко всем внутренним деталям другого.
Лучше определить минимальный контракт. Например, если orders нужно получить пользователя:
type UserReader interface {
GetByID(ctx context.Context, id uuid.UUID) (User, error)
}
Конкретный способ получения пользователя остаётся ответственностью auth. Это позволяет уменьшить связанность между модулями.
Composition Root
Все зависимости собираются в main.go.
В данном случае лучшим решением будет фреймворк для создания DI-контейнеров go.uber.org/fx
Важно: при взаимодействии между сервисами, для корректной сборки контейнера необходимо использовать fx.As
Итоговая структура
В результате архитектура нашего приложения будет выглядеть так:
. ├── cmd/ │ └── app/ │ └── main.go ├── internal/ │ └── auth/ │ ├── domain/ │ │ ├── user.go │ │ └── claims.go │ ├── application/ │ │ ├── service.go │ │ ├── sign_in.go │ │ └── sign_up.go │ ├── port/ │ │ └── port.go │ ├── infrastructure/ │ │ ├── postgres/ │ │ │ └── repository.go │ │ ├── bcrypt/ │ │ │ └── password_hasher.go │ │ └── jwt/ │ │ └── token_issuer.go │ └── transport/ │ └── http/ │ ├── api.go │ ├── sign_in.go │ └── sign_up.go ├── migration/ ├── config/ └── go.mod
Направление зависимостей
Главное архитектурное правило:
Тестирование
Такая структура позволяет тестировать разные части приложения независимо.
Domain
Проверяем бизнес-правила:
- User
- Value Objects
- Aggregates
Application
Тестируем сервис с mock implementations:
- UserRepository
- PasswordHasher
- TokenIssuer
Без вызова реализаций в infrastructure.
Infrastructure
Для этого слоя подойдут только интеграционные тесты.
Заключение
Монолитная архитектура не устарела. Для многих проектов хорошо спроектированный модульный монолит остаётся более простым и надёжным решением, чем преждевременный переход к микросервисам.
«Simple is better than complex.» — Rob Pike