G-SERVICE Docs
Архитектура ISP (OSS/BSS)

Принципы архитектуры

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 BSR

O — 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 изменений в core

L — 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 не зависит ни от чего.

Loading diagram...

Правило: Domain и Application определяют интерфейсы (ports). Infrastructure реализует их (adapters). Зависимости инжектятся через конструктор (constructor injection).


DDD (Domain-Driven Design)

Bounded Contexts

Каждый микросервис = один Bounded Context. Между контекстами — только Protobuf-контракты и RabbitMQ-события.

Bounded ContextСервисAggregate Roots
CustomerCustomer CoreCustomer, Contract
ProductProduct & SubscriptionProductOffering, Subscription
BillingBilling & FinanceAccount, Invoice
NetworkNetwork InventoryDevice, LogicalResource
AccessAccess Control (AAA)AccessProfile, Session
ProvisioningProvisioning ServiceProvisioningTask
OrderOMSOrder

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-базы, но с разными моделями.

Loading diagram...

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

Правила:

  1. Domain не импортирует ничего, кроме стандартной библиотеки Go + domain-утилит.
  2. Application зависит от Domain и определяет интерфейсы (ports) для Infrastructure.
  3. Infrastructure реализует интерфейсы Application.
  4. Ports вызывают Application (use cases) и преобразуют Protobuf ↔ Domain.
  5. Инъекция зависимостей через конструктор в 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)
    }
}

Ссылки по теме

On this page