Доменная логика и События
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 (подписывается).
Типы связей между контекстами
| Upstream (U) → Downstream (D) | Тип связи | Описание |
|---|---|---|
| Customer → Billing | Customer/Supplier | Billing зависит от Customer-событий для создания аккаунтов |
| Product → Provisioning | Published Language | Подписка определяет Protobuf-контракт, Provisioning реализует |
| Billing → Provisioning | Conformist | Provisioning принимает формат Billing-команд как есть |
| Mediation → Billing | Anti-Corruption Layer | Billing преобразует сырые UDR в свою доменную модель |
| OMS → Provisioning | Customer/Supplier | OMS отправляет команды, Provisioning исполняет |
| Legacy CRM → Customer Core | Anti-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 Core | Billing (account_id → customer_id) |
| Продукт/Тариф | Product & Subscription | Billing (subscription_id), Provisioning |
| Баланс, транзакции, инвойсы | Billing & Finance | — |
| Подписка (статус, параметры) | Product & Subscription | OMS, Provisioning (subscription_id) |
| Порт, IP, VLAN, устройство | Network Inventory | Provisioning (resource_id) |
| RADIUS-профиль | Access Control (AAA) | Provisioning |
| Заказ (статус, шаги) | OMS | — |
| CDR/UDR (сырые) | Mediation | Billing (агрегированные) |
Запрещено:
- Прямой доступ к БД другого сервиса (даже read-only реплике).
- Хранение "копии" данных другого контекста, которая может устареть без механизма синхронизации.
- JOIN между таблицами разных контекстов.
Разрешено:
- Хранить
foreign_id(UUID ссылка на сущность другого контекста). - Строить локальные read-модели (проекции) из событий другого контекста.
- Запрашивать актуальные данные по ConnectRPC при необходимости.
1. Customer Context (Клиенты)
Сервис: Customer Core Service
Ответственность: Юридические данные, контракты, контакты, адреса подключения.
Модель данных
Инварианты (бизнес-правила)
- У клиента всегда есть хотя бы один подтверждённый контакт.
Contractне может перейти вactiveбез подписи (signed_at).Addressсis_service_address=true— адрес подключения (один на подписку).passport_dataшифруется в БД (pgcrypto), маскируется в логах.
События (Exchange: isp.events.customer, Type: Topic)
| Routing Key | Описание | Consumers | Protobuf Message |
|---|---|---|---|
customer.created | Новый клиент | Billing, Notification | CustomerCreatedEvent |
customer.updated | Данные изменены | Notification | CustomerUpdatedEvent |
contract.signed | Подписан договор | Billing (создать Account), OMS | ContractSignedEvent |
contract.terminated | Расторжение | Billing, Provisioning | ContractTerminatedEvent |
contact.verified | Контакт подтверждён | Notification | ContactVerifiedEvent |
2. Product Context (Продукты и Подписки)
Сервис: Product & Subscription Service
Ответственность: Каталог продуктов (тарифов), ценовые планы, жизненный цикл подписки (Subscription).
Модель данных
Инварианты
ProductOfferingв статусеarchivedне может использоваться для новых подписок.Subscriptionможет бытьsuspendedтолько изactive. Переходsuspended → activeтребует проверки баланса.ServiceInstance.parametersопределяют, что нужно настроить на оборудовании (передаются Provisioning).- Смена тарифа = создание новой подписки + терминация старой (или effective_date на следующий период).
События (Exchange: isp.events.product, Type: Topic)
| Routing Key | Описание | Consumers | Protobuf Message |
|---|---|---|---|
subscription.activated | Услуга включена | Provisioning, Billing | SubscriptionActivatedEvent |
subscription.suspended | Пауза услуги | Provisioning, Notification | SubscriptionSuspendedEvent |
subscription.terminated | Услуга отключена | Provisioning, Billing | SubscriptionTerminatedEvent |
subscription.tariff_changed | Смена тарифа | Provisioning, Billing | TariffChangedEvent |
product.created | Новый продукт в каталоге | — | ProductCreatedEvent |
3. Billing Context (Деньги)
Сервис: Billing & Finance Service
Ответственность: Балансы (Ledger), транзакции, инвойсы, платежи, Dunning (работа с задолженностью).
Модель данных
Инварианты
- Двойная запись (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 | Описание | Consumers | Protobuf Message |
|---|---|---|---|
payment.received | Поступил платёж | Notification, OMS | PaymentReceivedEvent |
balance.negative | Баланс ушёл в минус | Notification | BalanceNegativeEvent |
access.suspend | Команда блокировки | Provisioning | AccessSuspendCommand |
access.resume | Команда разблокировки | Provisioning | AccessResumeCommand |
invoice.issued | Счёт выставлен | Notification | InvoiceIssuedEvent |
dunning.stage_changed | Смена стадии Dunning | Notification, OMS | DunningStageChangedEvent |
Примечание:
access.suspendиaccess.resume— это команды, а не события. Они публикуются в отдельный exchangeisp.commands.provisioningс direct routing. Подробнее — API-контракты.
4. OSS / Resource Context (Ресурсы и Сеть)
Сервис: Network Inventory & Provisioning & Access Control (AAA)
Ответственность: Учёт оборудования, портов, IP-адресов, управление конфигурацией, RADIUS-профили.
Модель данных
Инварианты
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 (на оборудовании):
События (Exchange: isp.events.provisioning, Type: Topic)
| Routing Key | Описание | Consumers | Protobuf Message |
|---|---|---|---|
provisioning.success | Настройки применены | OMS, Analytics | ProvisioningSuccessEvent |
provisioning.failed | Ошибка настройки | OMS, Support (Ticket) | ProvisioningFailedEvent |
provisioning.rollback | Откат конфигурации | OMS | ProvisioningRollbackEvent |
session.started | PPPoE/IPoE сессия начата | Analytics | SessionStartedEvent |
session.disconnected | Сессия сброшена (CoA) | Analytics | SessionDisconnectedEvent |
resource.exhausted | Пул IP/портов исчерпан | Notification, Planning | ResourceExhaustedEvent |
Примечание: OMS слушает события от Provisioning для управления состоянием заказов и переходов в workflow'ах.
5. Версионирование событий
События эволюционируют. Чтобы не ломать consumers при изменении структуры:
Правила
- Только additive changes: Новые поля добавляются как optional. Старые поля никогда не удаляются и не переименовываются.
- Routing key содержит версию (при major-ломающих изменениях):
customer.v2.created. - Protobuf backward compatibility:
buf breakingпроверяет на CI, что изменения обратно-совместимы. - 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)
Полная карта потоков событий между контекстами:
Ссылки по теме
- Архитектурные принципы: DDD, Aggregate Root, Value Objects, UoW — Принципы архитектуры.
- Бизнес-процессы: Примеры использования событий в workflow'ах — Бизнес-сценарии (Workflows).
- API: Protobuf EventEnvelope, топология exchanges RabbitMQ, Outbox/Inbox паттерны — API-контракты.
- BSS-сервисы: Customer Core, Product, Billing — BSS Layer.
- OSS-сервисы: Inventory, Provisioning, AAA — OSS Layer.
- RADIUS: Профили, radcheck/radreply — Интеграция с RADIUS.
- Термины: DDD, Bounded Context, Aggregate, Value Object, ACL — Глоссарий.