Принципы архитектуры
SOLID, DDD, Unit of Work, CQRS, DRY, KISS, YAGNI и другие паттерны проектирования платформы.
Принципы архитектуры
Платформа проектируется по принципам Domain-Driven Design с применением SOLID, чистой архитектуры и проверенных паттернов для NestJS и Go-микросервисов. Этот раздел — обязательное чтение перед началом разработки любого сервиса.
ISP-контекст: Принципы ниже адаптированы под реалии телеком-домена: длительные бизнес-процессы (Saga), взаимодействие с физическим оборудованием (idempotent provisioning), строгие финансовые инварианты (double-entry ledger) и регуляторные требования (audit trail, СОРМ). В отличие от generic CRUD-приложений, каждое решение здесь имеет последствия для реальных абонентов — ошибка в биллинге = потеря денег, ошибка в provisioning = абонент без интернета.
Фундаментальные принципы
DRY (Don't Repeat Yourself)
Каждая единица знания должна иметь единственное, однозначное представление в системе.
В нашей платформе:
- Protobuf как Single Source of Truth: Модели данных описаны один раз в
.protoфайлах. Код генерируется автоматически для Go, TypeScript, Kotlin. Нет ручного дублирования типов. - Общие типы в
common/v1/: Pagination, Money, Metadata, Error — переиспользуются всеми сервисами через buf.build BSR. - Shared Go-библиотеки: Утилиты (логирование, трейсинг, RabbitMQ client, outbox/inbox) — в отдельном модуле
pkg/, не копируются.
Антипаттерн:
// ПЛОХО: дублирование логики валидации в каждом сервисе
func validateUUID(s string) error { ... }
// ХОРОШО: protovalidate в .proto + interceptor (валидация генерируется)
// string id = 1 [(buf.validate.field).string.uuid = true];KISS (Keep It Simple, Stupid)
Простое решение всегда предпочтительнее сложного.
В нашей платформе:
- RabbitMQ, а не Kafka: Для масштаба ISP (~100K абонентов) RabbitMQ проще в эксплуатации и достаточен по производительности.
- Kustomize, а не Helm: Меньше абстракций, декларативный overlay-подход, нативная поддержка kubectl.
- ConnectRPC, а не чистый gRPC: Совместим с gRPC, но проще (HTTP/1.1 + JSON для дебага, нативные браузеры).
- PostgreSQL для всего: Одна СУБД вместо зоопарка (Mongo + MySQL + Redis + ...). Расширения (TimescaleDB, pg_trgm) покрывают специальные кейсы.
YAGNI (You Aren't Gonna Need It)
Не добавляйте функциональность, пока она не понадобилась.
- Не проектируйте "на вырост" для 10M абонентов, если сейчас 10K.
- Не добавляйте Event Sourcing "на всякий случай" — обычного Outbox достаточно.
- Не пишите абстракции над абстракциями: один уровень Repository → PostgreSQL.
SOLID для Go-микросервисов
S — Single Responsibility Principle
Каждый модуль/пакет/сервис имеет одну причину для изменения.
billing-service/
├── internal/
│ ├── domain/ # Бизнес-логика (чистая, без зависимостей)
│ │ ├── account.go # Account aggregate
│ │ ├── transaction.go
│ │ └── ledger.go # Двойная запись
│ ├── app/ # Application-слой (use cases)
│ │ ├── register_payment.go
│ │ ├── charge_account.go
│ │ └── get_balance.go
│ ├── infra/ # Инфраструктура (БД, RabbitMQ, внешние API)
│ │ ├── postgres/
│ │ │ ├── account_repo.go
│ │ │ └── outbox_repo.go
│ │ └── rabbitmq/
│ │ └── publisher.go
│ └── port/ # Входные точки (ConnectRPC handlers, consumers)
│ ├── grpc/
│ │ └── billing_handler.go
│ └── consumer/
│ └── commands_consumer.go
├── cmd/
│ └── billing/
│ └── main.go
└── proto/ # Ссылка на buf.build BSRO — Open/Closed Principle
Открыт для расширения, закрыт для модификации.
Пример: Provisioning для разных вендоров NAS:
// Интерфейс (закрыт для модификации)
type NASProvisioner interface {
ApplyProfile(ctx context.Context, cmd ApplyProfileCommand) error
RemoveProfile(ctx context.Context, cmd RemoveProfileCommand) error
SendCoA(ctx context.Context, cmd CoACommand) error
}
// Реализации (открыты для расширения — добавляем новый вендор)
type MikrotikProvisioner struct { ... }
type HuaweiProvisioner struct { ... }
type JuniperProvisioner struct { ... }
type EltexProvisioner struct { ... } // Новый вендор = новый файл, 0 изменений в coreL — Liskov Substitution Principle
Подтипы должны быть взаимозаменяемы с базовыми типами.
В Go это реализуется через интерфейсы. Любая реализация NASProvisioner должна корректно работать в Provisioning Service без знания конкретного типа.
I — Interface Segregation Principle
Клиент не должен зависеть от интерфейсов, которые не использует.
// ПЛОХО: "God interface"
type Repository interface {
GetAccount(ctx context.Context, id string) (*Account, error)
ListAccounts(ctx context.Context) ([]*Account, error)
CreateAccount(ctx context.Context, a *Account) error
UpdateAccount(ctx context.Context, a *Account) error
DeleteAccount(ctx context.Context, id string) error
GetTransaction(ctx context.Context, id string) (*Transaction, error)
ListTransactions(ctx context.Context, accountID string) ([]*Transaction, error)
// ... ещё 20 методов
}
// ХОРОШО: маленькие, сфокусированные интерфейсы
type AccountReader interface {
GetAccount(ctx context.Context, id string) (*Account, error)
ListAccounts(ctx context.Context, filter AccountFilter) ([]*Account, error)
}
type AccountWriter interface {
SaveAccount(ctx context.Context, a *Account) error
}
type TransactionReader interface {
ListTransactions(ctx context.Context, accountID string, filter TxFilter) ([]*Transaction, error)
}D — Dependency Inversion Principle
Зависимости направлены от внешнего к внутреннему. Domain не зависит ни от чего.
Правило: Domain и Application определяют интерфейсы (ports). Infrastructure реализует их (adapters). Зависимости инжектятся через конструктор (constructor injection).
DDD (Domain-Driven Design)
Bounded Contexts
Каждый микросервис = один Bounded Context. Между контекстами — только Protobuf-контракты и RabbitMQ-события.
| Bounded Context | Сервис | Aggregate Roots |
|---|---|---|
| Customer | Customer Core | Customer, Contract |
| Product | Product & Subscription | ProductOffering, Subscription |
| Billing | Billing & Finance | Account, Invoice |
| Network | Network Inventory | Device, LogicalResource |
| Access | Access Control (AAA) | AccessProfile, Session |
| Provisioning | Provisioning Service | ProvisioningTask |
| Order | OMS | Order |
Aggregate Root
Aggregate Root — единственная точка входа для изменения группы связанных сущностей. Все мутации проходят через методы Aggregate.
// domain/account.go
type Account struct {
id string
balance Money
creditLimit Money
status AccountStatus
transactions []Transaction
events []DomainEvent // Накопленные события
}
// Бизнес-метод на Aggregate Root
func (a *Account) Credit(amount Money, source string, idempotencyKey string) error {
if amount.IsNegative() {
return ErrInvalidAmount
}
tx := Transaction{
ID: uuid.New().String(),
Amount: amount,
Type: TransactionTypeCredit,
Source: source,
CreatedAt: time.Now(),
}
a.balance = a.balance.Add(amount)
a.transactions = append(a.transactions, tx)
// Доменное событие — будет опубликовано через Outbox после коммита
a.events = append(a.events, PaymentReceivedEvent{
AccountID: a.id,
Amount: amount,
PaymentID: tx.ID,
Source: source,
})
return nil
}
// Собрать и очистить накопленные события
func (a *Account) PopEvents() []DomainEvent {
events := a.events
a.events = nil
return events
}Value Objects
Неизменяемые объекты, определяемые значением (не идентификатором):
// domain/money.go
type Money struct {
amount decimal.Decimal
currency string
}
func NewMoney(amount decimal.Decimal, currency string) (Money, error) {
if currency == "" {
return Money{}, ErrEmptyCurrency
}
return Money{amount: amount, currency: currency}, nil
}
func (m Money) Add(other Money) Money {
if m.currency != other.currency {
panic("currency mismatch") // Invariant violation
}
return Money{amount: m.amount.Add(other.amount), currency: m.currency}
}
func (m Money) IsNegative() bool {
return m.amount.IsNegative()
}Domain Events
Доменные события генерируются внутри Aggregate и публикуются через Outbox после коммита:
// domain/events.go
type DomainEvent interface {
EventType() string
AggregateID() string
}
type PaymentReceivedEvent struct {
AccountID string
Amount Money
PaymentID string
Source string
}
func (e PaymentReceivedEvent) EventType() string { return "payment.received" }
func (e PaymentReceivedEvent) AggregateID() string { return e.AccountID }Unit of Work
Паттерн Unit of Work гарантирует, что все изменения в рамках одного бизнес-сценария (use case) коммитятся атомарно: бизнес-данные + outbox-события в одной транзакции.
// app/ports.go
type UnitOfWork interface {
// Begin начинает транзакцию и возвращает контекст с tx.
Begin(ctx context.Context) (context.Context, error)
// Commit фиксирует транзакцию.
Commit(ctx context.Context) error
// Rollback откатывает транзакцию.
Rollback(ctx context.Context) error
}
// app/register_payment.go
type RegisterPaymentUseCase struct {
uow UnitOfWork
accounts AccountRepository // interface
outbox OutboxRepository // interface
}
func (uc *RegisterPaymentUseCase) Execute(ctx context.Context, cmd RegisterPaymentCommand) error {
ctx, err := uc.uow.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer uc.uow.Rollback(ctx) // no-op если уже закоммичено
// 1. Загрузить Aggregate
account, err := uc.accounts.GetByID(ctx, cmd.AccountID)
if err != nil {
return fmt.Errorf("get account: %w", err)
}
// 2. Бизнес-логика (на Aggregate Root)
if err := account.Credit(cmd.Amount, cmd.Source, cmd.IdempotencyKey); err != nil {
return fmt.Errorf("credit: %w", err)
}
// 3. Сохранить Aggregate
if err := uc.accounts.Save(ctx, account); err != nil {
return fmt.Errorf("save account: %w", err)
}
// 4. Сохранить доменные события в Outbox (в той же транзакции!)
for _, event := range account.PopEvents() {
if err := uc.outbox.Save(ctx, event); err != nil {
return fmt.Errorf("save outbox: %w", err)
}
}
// 5. Коммит (атомарно: Account + Outbox)
if err := uc.uow.Commit(ctx); err != nil {
return fmt.Errorf("commit: %w", err)
}
return nil
}Ключевые гарантии:
- Бизнес-данные и outbox-события всегда коммитятся вместе.
- При ошибке на любом шаге — откат всего.
- Outbox Relay Worker публикует события в RabbitMQ после коммита.
CQRS (Command Query Responsibility Segregation)
Разделение моделей чтения и записи. В нашей платформе применяется лёгкий CQRS — без отдельной read-базы, но с разными моделями.
Write side:
- Все мутации проходят через Aggregate Root → Use Case → Repository.
- Сложная бизнес-логика, валидация, invariants.
- Нормализованная модель.
Read side:
- Простые SQL-запросы к денормализованным view/materialized view.
- Оптимизировано для UI (список клиентов, история транзакций, дашборды).
- Materialized views обновляются по событиям или триггерам.
Repository Pattern
Каждый Aggregate Root имеет свой Repository. Repository — это интерфейс в application-слое и реализация в infrastructure.
// app/ports.go (интерфейс)
type AccountRepository interface {
GetByID(ctx context.Context, id string) (*domain.Account, error)
Save(ctx context.Context, account *domain.Account) error
}
// infra/postgres/account_repo.go (реализация)
type PostgresAccountRepository struct {
db *pgxpool.Pool
}
func (r *PostgresAccountRepository) GetByID(ctx context.Context, id string) (*domain.Account, error) {
// Извлекаем tx из контекста (Unit of Work)
tx := extractTx(ctx)
row := tx.QueryRow(ctx,
`SELECT id, balance, credit_limit, status FROM accounts WHERE id = $1 FOR UPDATE`,
id,
)
// ... маппинг в domain.Account
}
func (r *PostgresAccountRepository) Save(ctx context.Context, account *domain.Account) error {
tx := extractTx(ctx)
_, err := tx.Exec(ctx,
`UPDATE accounts SET balance = $1, status = $2, updated_at = now() WHERE id = $3`,
account.Balance(), account.Status(), account.ID(),
)
return err
}Чистая архитектура: слои и зависимости
┌─────────────────────────────────────────┐
│ Ports (вход) │ ConnectRPC handlers, RabbitMQ consumers
├─────────────────────────────────────────┤
│ Application (use cases) │ Оркестрация: UoW + Repo + Domain
├─────────────────────────────────────────┤
│ Domain (ядро) │ Aggregates, Value Objects, Events
├─────────────────────────────────────────┤
│ Infrastructure (адаптеры) │ PostgreSQL, RabbitMQ, External APIs
└─────────────────────────────────────────┘
Зависимости: Ports → Application → Domain ← InfrastructureПравила:
- Domain не импортирует ничего, кроме стандартной библиотеки Go + domain-утилит.
- Application зависит от Domain и определяет интерфейсы (ports) для Infrastructure.
- Infrastructure реализует интерфейсы Application.
- Ports вызывают Application (use cases) и преобразуют Protobuf ↔ Domain.
- Инъекция зависимостей через конструктор в
main.go(без DI-фреймворков).
Практики для Go
Обработка ошибок
// Доменные ошибки (бизнес-логика)
var (
ErrAccountNotFound = errors.New("account not found")
ErrInsufficientFunds = errors.New("insufficient funds")
ErrDuplicatePayment = errors.New("duplicate payment")
)
// Use case оборачивает ошибки с контекстом
func (uc *ChargeAccountUseCase) Execute(ctx context.Context, cmd ChargeCommand) error {
account, err := uc.accounts.GetByID(ctx, cmd.AccountID)
if err != nil {
return fmt.Errorf("charge account %s: %w", cmd.AccountID, err)
}
// ...
}
// ConnectRPC handler маппит доменные ошибки → gRPC коды
func domainErrorToConnect(err error) *connect.Error {
switch {
case errors.Is(err, domain.ErrAccountNotFound):
return connect.NewError(connect.CodeNotFound, err)
case errors.Is(err, domain.ErrInsufficientFunds):
return connect.NewError(connect.CodeFailedPrecondition, err)
case errors.Is(err, domain.ErrDuplicatePayment):
return connect.NewError(connect.CodeAlreadyExists, err)
default:
return connect.NewError(connect.CodeInternal, err)
}
}Конфигурация
// Конфигурация через переменные окружения (12-Factor App)
type Config struct {
Port int `env:"PORT" envDefault:"8080"`
DatabaseURL string `env:"DATABASE_URL,required"`
RabbitMQURL string `env:"RABBITMQ_URL,required"`
LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
}Graceful Shutdown
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// ... инициализация
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error { return grpcServer.Serve(ctx) })
g.Go(func() error { return rabbitConsumer.Run(ctx) })
g.Go(func() error { return outboxRelay.Run(ctx) })
if err := g.Wait(); err != nil {
slog.Error("service stopped", "error", err)
}
}Ссылки по теме
- API-контракты: Protobuf, ConnectRPC, RabbitMQ — API-контракты.
- Доменные модели: ER-диаграммы и события — Доменная логика.
- Технологический стек: Go, PostgreSQL, Kustomize — Технологический стек.
- Бизнес-процессы: Примеры Saga в действии — Workflows.
- Глоссарий: DDD, UoW, CQRS, Aggregate, Bounded Context — Глоссарий.