Архитектура «монолита» на Go

Введение

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

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

Про DDD и «Чистую архитектуру»

DDD (Domain-Driven Design)

DDD отвечает прежде всего за моделирование предметной области: подход к разработке программного обеспечения,
при котором в центре внимания находится не техническая часть (базы данных, фреймворки или API), а реальная предметная область бизнеса (домен) и его правила

«Чистая архитектура»

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

Вместе эти подходы можно использовать следующим образом:

┌───────────────────────────────┐ │ Transport │ │ HTTP / gRPC / CLI │ └───────────────────────────────┘ ↓ ┌───────────────────────────────┐ │ Application │ │ Use Cases │ └───────────────────────────────┘ ↓ ┌───────────────────────────────┐ │ Domain │ │ Entities / Value Objects / │ │ Business Rules / Aggregates │ └───────────────────────────────┘ ↑ ┌───────────────────────────────┐ │ Infrastructure │ │ DB / JWT / 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

Направление зависимостей

Главное архитектурное правило:

┌──────────────┐ │ Transport │ └──────┬───────┘ ↓ ┌──────────────┐ │ Application │ └──────┬───────┘ ↓ ┌──────────────┐ │ Domain │ └──────────────┘ ↑ ┌──────────────┐ │Infrastructure│ └──────────────┘

Тестирование

Такая структура позволяет тестировать разные части приложения независимо.

Domain

Проверяем бизнес-правила:

  • User
  • Value Objects
  • Aggregates

Application

Тестируем сервис с mock implementations:

  • UserRepository
  • PasswordHasher
  • TokenIssuer

Без вызова реализаций в infrastructure.

Infrastructure

Для этого слоя подойдут только интеграционные тесты.

Заключение

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

«Simple is better than complex.» — Rob Pike