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

Доменная логика и События

DDD-модели, Context Map, карта событий RabbitMQ, Data Ownership и Anti-Corruption Layer.

Доменная логика и Модели данных (DDD)

Система построена на принципах Domain Driven Design. Данные изолированы в контекстах (Bounded Contexts). Каждый контекст владеет своими данными (Data Ownership), определяет свой Ubiquitous Language и взаимодействует с другими только через Protobuf-контракты (синхронно) и RabbitMQ-события (асинхронно).

Почему DDD для ISP? Телеком-домен исторически страдает от "GOD-объектов" (в legacy-системах типичная таблица book_orders содержит 50+ полей от клиента до MAC-адреса). DDD позволяет чётко разделить бизнес-домены (кто клиент, что продано, сколько стоит, что включено в сети) и эволюционировать их независимо. Каждый Bounded Context = отдельный микросервис с собственной БД, что устраняет проблему "один UPDATE ломает всё".

Context Map

Карта взаимодействий между Bounded Contexts. Стрелки показывают направление зависимости: U = Upstream (публикует), D = Downstream (подписывается).

Loading diagram...

Типы связей между контекстами

Upstream (U) → Downstream (D)Тип связиОписание
Customer → BillingCustomer/SupplierBilling зависит от Customer-событий для создания аккаунтов
Product → ProvisioningPublished LanguageПодписка определяет Protobuf-контракт, Provisioning реализует
Billing → ProvisioningConformistProvisioning принимает формат Billing-команд как есть
Mediation → BillingAnti-Corruption LayerBilling преобразует сырые UDR в свою доменную модель
OMS → ProvisioningCustomer/SupplierOMS отправляет команды, Provisioning исполняет
Legacy CRM → Customer CoreAnti-Corruption LayerАдаптер конвертирует legacy-формат в доменные модели

Anti-Corruption Layer (ACL)

Паттерн защиты домена от "протечки" чужих моделей. Реализуется на границе контекста:

// internal/billing/acl/mediation_adapter.go
// ACL: преобразование сырого UDR из Mediation в доменную модель Billing.
type MediationACL struct{}

func (a *MediationACL) ToUsageRecord(raw *mediationpb.UsageEvent) (*domain.UsageRecord, error) {
    sessionType, err := mapSessionType(raw.SessionType)
    if err != nil {
        return nil, fmt.Errorf("unknown session type %q: %w", raw.SessionType, err)
    }
    return &domain.UsageRecord{
        AccountID:   domain.AccountID(raw.AccountId),
        SessionType: sessionType,
        BytesIn:     raw.OctetsIn,
        BytesOut:    raw.OctetsOut,
        Duration:    time.Duration(raw.DurationSec) * time.Second,
        RecordedAt:  raw.Timestamp.AsTime(),
    }, nil
}

func mapSessionType(s string) (domain.SessionType, error) {
    switch s {
    case "pppoe":
        return domain.SessionPPPoE, nil
    case "ipoe":
        return domain.SessionIPoE, nil
    default:
        return "", domain.ErrUnknownSessionType
    }
}

Правило: Никогда не используйте Protobuf-типы (*pb.SomeMessage) внутри домена. ACL конвертирует внешние DTO в Value Objects и Entity домена.


Data Ownership

Каждый Bounded Context — единственный владелец своих данных. Другие контексты хранят только ссылки (ID) и проекции (read-модели для UI).

ДанныеВладелец (Source of Truth)Кто хранит копию/ссылку
Клиент (ФИО, паспорт, контакты)Customer CoreBilling (account_id → customer_id)
Продукт/ТарифProduct & SubscriptionBilling (subscription_id), Provisioning
Баланс, транзакции, инвойсыBilling & Finance
Подписка (статус, параметры)Product & SubscriptionOMS, Provisioning (subscription_id)
Порт, IP, VLAN, устройствоNetwork InventoryProvisioning (resource_id)
RADIUS-профильAccess Control (AAA)Provisioning
Заказ (статус, шаги)OMS
CDR/UDR (сырые)MediationBilling (агрегированные)

Запрещено:

  • Прямой доступ к БД другого сервиса (даже read-only реплике).
  • Хранение "копии" данных другого контекста, которая может устареть без механизма синхронизации.
  • JOIN между таблицами разных контекстов.

Разрешено:

  • Хранить foreign_id (UUID ссылка на сущность другого контекста).
  • Строить локальные read-модели (проекции) из событий другого контекста.
  • Запрашивать актуальные данные по ConnectRPC при необходимости.

1. Customer Context (Клиенты)

Сервис: Customer Core Service Ответственность: Юридические данные, контракты, контакты, адреса подключения.

Модель данных

Loading diagram...

Инварианты (бизнес-правила)

  • У клиента всегда есть хотя бы один подтверждённый контакт.
  • Contract не может перейти в active без подписи (signed_at).
  • Address с is_service_address=true — адрес подключения (один на подписку).
  • passport_data шифруется в БД (pgcrypto), маскируется в логах.

События (Exchange: isp.events.customer, Type: Topic)

Routing KeyОписаниеConsumersProtobuf Message
customer.createdНовый клиентBilling, NotificationCustomerCreatedEvent
customer.updatedДанные измененыNotificationCustomerUpdatedEvent
contract.signedПодписан договорBilling (создать Account), OMSContractSignedEvent
contract.terminatedРасторжениеBilling, ProvisioningContractTerminatedEvent
contact.verifiedКонтакт подтверждёнNotificationContactVerifiedEvent

2. Product Context (Продукты и Подписки)

Сервис: Product & Subscription Service Ответственность: Каталог продуктов (тарифов), ценовые планы, жизненный цикл подписки (Subscription).

Модель данных

Loading diagram...

Инварианты

  • ProductOffering в статусе archived не может использоваться для новых подписок.
  • Subscription может быть suspended только из active. Переход suspended → active требует проверки баланса.
  • ServiceInstance.parameters определяют, что нужно настроить на оборудовании (передаются Provisioning).
  • Смена тарифа = создание новой подписки + терминация старой (или effective_date на следующий период).

События (Exchange: isp.events.product, Type: Topic)

Routing KeyОписаниеConsumersProtobuf Message
subscription.activatedУслуга включенаProvisioning, BillingSubscriptionActivatedEvent
subscription.suspendedПауза услугиProvisioning, NotificationSubscriptionSuspendedEvent
subscription.terminatedУслуга отключенаProvisioning, BillingSubscriptionTerminatedEvent
subscription.tariff_changedСмена тарифаProvisioning, BillingTariffChangedEvent
product.createdНовый продукт в каталогеProductCreatedEvent

3. Billing Context (Деньги)

Сервис: Billing & Finance Service Ответственность: Балансы (Ledger), транзакции, инвойсы, платежи, Dunning (работа с задолженностью).

Модель данных

Loading diagram...

Инварианты

  • Двойная запись (Double-entry): Каждая Transaction — проводка. Сумма всех транзакций = balance.
  • idempotency_key обязателен для Payment — защита от двойного зачисления.
  • dunning_stage меняется автоматически: none → soft (D+0) → hard (D+3) → terminated (D+N).
  • Invoice в статусе void — отменён (корректировка), не влияет на баланс.
  • Все суммы хранятся как decimal (не float!) для точности финансовых расчётов.

События (Exchange: isp.events.billing, Type: Topic)

Routing KeyОписаниеConsumersProtobuf Message
payment.receivedПоступил платёжNotification, OMSPaymentReceivedEvent
balance.negativeБаланс ушёл в минусNotificationBalanceNegativeEvent
access.suspendКоманда блокировкиProvisioningAccessSuspendCommand
access.resumeКоманда разблокировкиProvisioningAccessResumeCommand
invoice.issuedСчёт выставленNotificationInvoiceIssuedEvent
dunning.stage_changedСмена стадии DunningNotification, OMSDunningStageChangedEvent

Примечание: access.suspend и access.resume — это команды, а не события. Они публикуются в отдельный exchange isp.commands.provisioning с direct routing. Подробнее — API-контракты.


4. OSS / Resource Context (Ресурсы и Сеть)

Сервис: Network Inventory & Provisioning & Access Control (AAA) Ответственность: Учёт оборудования, портов, IP-адресов, управление конфигурацией, RADIUS-профили.

Модель данных

Loading diagram...

Инварианты

  • PhysicalPort с status=reserved имеет TTL (reserved_until). Если заказ не завершён — порт освобождается автоматически.
  • ServiceInstance.applied_config — фактическое состояние на оборудовании. Сравнивается с desired state из Product Context для reconciliation.
  • IPAddress освобождается только после полного deprovision (не при suspend!).
  • RadiusProfile — проекция ServiceInstance в формате FreeRADIUS. Подробнее — RADIUS.

Reconciliation Loop

Provisioning периодически (cron / event-driven) сверяет desired state (из Product Context) с actual state (на оборудовании):

Loading diagram...

События (Exchange: isp.events.provisioning, Type: Topic)

Routing KeyОписаниеConsumersProtobuf Message
provisioning.successНастройки примененыOMS, AnalyticsProvisioningSuccessEvent
provisioning.failedОшибка настройкиOMS, Support (Ticket)ProvisioningFailedEvent
provisioning.rollbackОткат конфигурацииOMSProvisioningRollbackEvent
session.startedPPPoE/IPoE сессия начатаAnalyticsSessionStartedEvent
session.disconnectedСессия сброшена (CoA)AnalyticsSessionDisconnectedEvent
resource.exhaustedПул IP/портов исчерпанNotification, PlanningResourceExhaustedEvent

Примечание: OMS слушает события от Provisioning для управления состоянием заказов и переходов в workflow'ах.


5. Версионирование событий

События эволюционируют. Чтобы не ломать consumers при изменении структуры:

Правила

  1. Только additive changes: Новые поля добавляются как optional. Старые поля никогда не удаляются и не переименовываются.
  2. Routing key содержит версию (при major-ломающих изменениях): customer.v2.created.
  3. Protobuf backward compatibility: buf breaking проверяет на CI, что изменения обратно-совместимы.
  4. Consumer-driven: Consumer игнорирует неизвестные поля (стандартное поведение Protobuf).

Пример миграции события

// v1: Первоначальная версия
message CustomerCreatedEvent {
  string customer_id = 1;
  string type = 2;           // "individual" | "legal_entity"
  string full_name = 3;
}

// v2: Добавили поле — обратно-совместимо
message CustomerCreatedEvent {
  string customer_id = 1;
  string type = 2;
  string full_name = 3;
  string inn = 4;             // Новое optional поле
  Address service_address = 5; // Новое optional поле
}

Anti-pattern: Никогда не меняйте номер поля и не удаляйте поля — используйте reserved для устаревших.


6. Карта событий (Event Flow)

Полная карта потоков событий между контекстами:

Loading diagram...

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

On this page