Skip to content

DDD концепции

1. Aggregate Boundaries

Что такое Aggregate?

Aggregate - кластер доменных объектов (entities + value objects), рассматриваемых как единое целое.

Aggregate Root - единственная точка входа в aggregate, через которую идут все изменения.

Зачем нужны Aggregates?

  1. Инварианты (Invariants) - бизнес-правила, которые ВСЕГДА должны быть истинны
  2. Транзакционная граница - один aggregate = одна транзакция
  3. Консистентность - невозможно нарушить правила агрегата

Пример из MARV: User Aggregate

Текущий код (без aggregates):

go
// Можно нарушить инварианты:
userBackupRepo.Create(ctx, backup)  // Создали backup для несуществующего user
worldRepo.Create(ctx, world)        // Создали world без user
messageRepo.Create(ctx, message)    // Создали message для удалённого user

Правильный подход (с aggregates):

go
// User Aggregate
type UserAggregate struct {
    // Aggregate Root
    root User
    
    // Entities внутри aggregate
    backups  []UserBackup
    worlds   map[string]World  // key = world.Type
    messages []Message
    
    // Invariants:
    // - Все backups принадлежат этому user
    // - Все worlds принадлежат этому user
    // - Нельзя создать backup для несуществующего user
}

// ВСЕ операции идут через Root:
func (u *UserAggregate) CreateBackup(data json.RawMessage) error {
    // Проверка инвариантов
    if !u.root.IsValid() {
        return errors.New("cannot create backup for invalid user")
    }
    
    backup := UserBackup{
        ApiType: u.root.ApiType,
        ApiUID:  u.root.ApiUID,
        Data:    data,
    }
    u.backups = append(u.backups, backup)
    return nil
}

func (u *UserAggregate) CreateWorld(worldType string) (*World, error) {
    // Проверка инвариантов
    if _, exists := u.worlds[worldType]; exists {
        return nil, errors.New("world already exists")
    }
    
    world := World{
        ApiType: u.root.ApiType,
        ApiUID:  u.root.ApiUID,
        Type:    worldType,
    }
    u.worlds[worldType] = world
    return &world, nil
}

// Repository работает с агрегатом целиком:
type UserAggregateRepository interface {
    Get(ctx, apiType, apiUID) (*UserAggregate, error)
    Save(ctx, *UserAggregate) error  // ← Сохраняет User + все изменения
}

2. Behavior в Anemic Models

Проблема: ABTest (анемичная модель)

Текущий код:

go
// domain/models/ab.go
type ABTest struct {
    ID      int      `json:"id"`
    Name    string   `json:"name"`
    Enabled bool     `json:"enabled"`
    Groups  []string `json:"groups"`
}
// НЕТ МЕТОДОВ! Просто data container

Где логика сейчас:

go
// domain/services/abtest.go  
func (s *ABTestService) AssignGroup(ctx, user, testID) (string, error) {
    test := s.repo.FindByID(ctx, testID)
    
    // Вся логика в сервисе, не в модели
    if !test.Enabled {
        return "", errors.New("test disabled")
    }
    
    if len(test.Groups) == 0 {
        return "", errors.New("no groups")
    }
    
    // Детерминированное назначение
    idx := int(user.ID) % len(test.Groups)
    return test.Groups[idx], nil
}

Rich Model подход:

go
// domain/models/ab.go
type ABTest struct {
    ID      int      `json:"id"`
    Name    string   `json:"name"`
    Enabled bool     `json:"enabled"`
    Groups  []string `json:"groups"`
}

// Логика ВНУТРИ модели
func (a *ABTest) IsEnabled() bool {
    return a.Enabled
}

func (a *ABTest) Enable() {
    a.Enabled = true
}

func (a *ABTest) Disable() {
    a.Enabled = false
}

func (a *ABTest) HasGroups() bool {
    return len(a.Groups) > 0
}

func (a *ABTest) AddGroup(groupName string) error {
    // Проверка инвариантов
    if groupName == "" {
        return errors.New("group name cannot be empty")
    }
    
    if a.HasGroup(groupName) {
        return errors.New("group already exists")
    }
    
    a.Groups = append(a.Groups, groupName)
    return nil
}

func (a *ABTest) HasGroup(groupName string) bool {
    for _, g := range a.Groups {
        if g == groupName {
            return true
        }
    }
    return false
}

// Главная бизнес-логика: назначение группы пользователю
func (a *ABTest) AssignGroup(userID UserId) (string, error) {
    if !a.IsEnabled() {
        return "", errors.New("ab test is disabled")
    }
    
    if !a.HasGroups() {
        return "", errors.New("no groups configured")
    }
    
    // Детерминированное назначение по user ID
    idx := int(userID.Int64()) % len(a.Groups)
    return a.Groups[idx], nil
}

func (a *ABTest) SelectRandomGroup() string {
    if !a.HasGroups() {
        return ""
    }
    return a.Groups[rand.Intn(len(a.Groups))]
}

func (a *ABTest) ValidateConfiguration() error {
    if a.Name == "" {
        return errors.New("ab test name is required")
    }
    
    if a.IsEnabled() && !a.HasGroups() {
        return errors.New("enabled ab test must have groups")
    }
    
    // Проверка уникальности групп
    seen := make(map[string]bool)
    for _, g := range a.Groups {
        if seen[g] {
            return fmt.Errorf("duplicate group: %s", g)
        }
        seen[g] = true
    }
    
    return nil
}

Упрощённый Service:

go
// domain/services/abtest.go
func (s *ABTestService) AssignGroupToUser(ctx, user, testID) (string, error) {
    test := s.repo.FindByID(ctx, testID)
    
    // Вся логика делегирована в модель!
    groupName, err := test.AssignGroup(user.ID)
    if err != nil {
        return "", err
    }
    
    // Обновляем user
    user.SetAbTestGroup(testID, groupName)
    return groupName, s.userRepo.Update(ctx, user)
}

Проблема: RemoteConfig (анемичная модель)

Текущий код:

go
type RemoteConfig struct {
    ID      int             `json:"id"`
    ApiType shared.ApiType  `json:"api_type"`
    Key     string          `json:"key"`
    Value   json.RawMessage `json:"value"`
}
// НЕТ МЕТОДОВ

Rich Model подход:

go
type RemoteConfig struct {
    ID      int             `json:"id"`
    ApiType shared.ApiType  `json:"api_type"`
    Key     string          `json:"key"`
    Value   json.RawMessage `json:"value"`
}

// Методы для работы с Value
func (r *RemoteConfig) IsForPlatform(apiType shared.ApiType) bool {
    return r.ApiType == apiType
}

func (r *RemoteConfig) UnmarshalValue(dest any) error {
    if len(r.Value) == 0 {
        return errors.New("empty value")
    }
    return json.Unmarshal(r.Value, dest)
}

func (r *RemoteConfig) SetValue(value any) error {
    data, err := json.Marshal(value)
    if err != nil {
        return err
    }
    r.Value = data
    return nil
}

func (r *RemoteConfig) GetString() (string, error) {
    var s string
    err := r.UnmarshalValue(&s)
    return s, err
}

func (r *RemoteConfig) GetInt() (int, error) {
    var i int
    err := r.UnmarshalValue(&i)
    return i, err
}

func (r *RemoteConfig) GetBool() (bool, error) {
    var b bool
    err := r.UnmarshalValue(&b)
    return b, err
}

func (r *RemoteConfig) GetStringSlice() ([]string, error) {
    var s []string
    err := r.UnmarshalValue(&s)
    return s, err
}

// Валидация
func (r *RemoteConfig) Validate() error {
    if r.Key == "" {
        return errors.New("key is required")
    }
    
    if len(r.Value) == 0 {
        return errors.New("value is required")
    }
    
    // Проверка что Value - валидный JSON
    var temp any
    if err := json.Unmarshal(r.Value, &temp); err != nil {
        return fmt.Errorf("invalid JSON value: %w", err)
    }
    
    return nil
}

// Сравнение конфигов
func (r *RemoteConfig) Equals(other *RemoteConfig) bool {
    return r.ID == other.ID &&
           r.ApiType == other.ApiType &&
           r.Key == other.Key &&
           string(r.Value) == string(other.Value)
}

3. Разница: Value Objects vs Primitives

Зачем использовать Value Objects в сигнатурах?

Плохо (примитивы):

go
func CreateMessage(userID int64, worldID int64, eventID int64) {
    // Легко перепутать параметры!
    repo.Save(worldID, userID, eventID)  // ← Compile OK, но НЕВЕРНО!
}

Хорошо (value objects):

go
func CreateMessage(userID UserId, worldID WorldId, eventID EventId) {
    // Невозможно перепутать!
    repo.Save(worldID, userID, eventID)  // ← Compile ERROR!
    repo.Save(userID, worldID, eventID)  // ← Compile ERROR!
}

Когда использовать .Int64() ?

Правило: Value Object → примитив только на границе слоёв (infrastructure)

go
// Domain layer - value objects
func (s *Service) ProcessUser(userID UserId) {
    user := s.repo.Get(ctx, userID)  // ← UserId
    event := NewUserEvent(user.ID)   // ← UserId
    s.bus.Publish(ctx, event)
}

// Infrastructure layer - конвертация
func (r *postgresRepo) Get(ctx, userID UserId) (*User, error) {
    var record UserRecord
    err := r.db.Where("id = ?", userID.Int64()).First(&record)  // ← int64 для SQL
    return mapper.ToDomain(record), err
}

// Cache keys - примитивы (ключи - строки)
func UserCacheKey(userID UserId) string {
    return fmt.Sprintf("user:%d", userID.Int64())  // ← int64 для ключа
}

4. Что уже реализовано в MARV

Value Objects - полностью внедрены

23 типа типобезопасных ID в domain/models/vo/:

Core Entities:

  • UserId, WorldId, MessageId, TransactionId
  • StreamId, StreamInputId
  • EventId, EventResultId
  • TrafficFlowId, TrafficFlowEntryId
  • RivalId

System Entities:

  • AbTestId, RemoteConfigId, CronTaskId
  • AlertId, AlertDeliveryLogId
  • AcpUserId, AdId

Backup Entities:

  • UserBackupId, WorldBackupId, UserDataUpdateId

Все модели используют value objects:

go
type User struct {
    ID              UserId          `json:"id"`
    ApiType         shared.ApiType  `json:"api_type"`
    ApiUID          string          `json:"api_uid"`
    // ...
}

type World struct {
    ID      WorldId         `json:"id"`
    ApiType shared.ApiType  `json:"api_type"`
    // ...
}

type Transaction struct {
    ID            TransactionId  `json:"id"`
    Type          int            `json:"type"`
    TransactionID string         `json:"transaction_id"`
    // ...
}

Все репозитории используют value objects:

go
// domain/repositories/user.go
type UserRepository interface {
    GetByID(ctx context.Context, id UserId) (*User, error)
    Create(ctx context.Context, user *User) error
}

// domain/repositories/ad.go  
type AdRepository interface {
    FindByID(ctx context.Context, id AdId) (*Ad, error)
    Update(ctx context.Context, ad *Ad) error
}

Все сервисы используют value objects:

go
// domain/services/user.go
func (s *UserService) BanUser(ctx context.Context, userID UserId) error

// domain/services/alert.go
func (s *AlertService) MarkDelivered(ctx context.Context, alertID AlertId, channels []string) error

Структура - реорганизована

Tasks система:

internal/infrastructure/tasks/
├── factory.go
└── implementations/
    ├── data_clean.go              ← Объединённая cleanup задача
    ├── alert_dispatcher.go        ← Dispatch alerts
    └── user_data_batch_update.go  ← Batch updates

Modules система:

modules/
├── factory.go
├── manager.go
└── implementations/
    ├── user.go
    ├── world.go
    ├── transaction.go
    ... (19 модулей)

EventBus - интегрирован

Два типа шин:

go
// Синхронная
c.eventBus = event_bus.NewInMemoryEventBus()

// Асинхронная (10 workers)
c.asyncEventBus = event_bus.NewAsyncEventBus(10)

События:

go
// domain/event_bus/events/iap.go
type IapVerifiedEvent struct {
    TransactionID TransactionId
    UserID        UserId
    ProductID     string
    Receipt       json.RawMessage
}

// domain/event_bus/events/traffic_flow.go  
type TrafficFlowCompletedEvent struct {
    TrafficFlowID TrafficFlowId
    UserID        UserId
    ApiType       shared.ApiType
    ApiUID        string
    Award         json.RawMessage
}

Handlers:

go
// domain/event_bus/handlers/iap.go
type IapVerificationHandler struct {
    transactions interfaces.TransactionService
    users        interfaces.UserService
}

func (h *IapVerificationHandler) Handle(ctx context.Context, event *events.IapVerifiedEvent) error {
    // Обработка IAP verification
}

// Регистрация в container
func (c *Container) setupEventHandlers() {
    iapHandler := handlers.NewIapVerificationHandler(c.Transactions(), c.Users())
    c.eventBus.Subscribe("iap.verified", iapHandler.Handle)
}

Data Cleanup - с мониторингом

Объединённая задача в internal/infrastructure/tasks/implementations/data_clean.go:

go
func (t *dataCleanTask) Process() interfaces.TaskResult {
    startTime := time.Now()
    
    // Duration из конфига
    retentionDuration := t.container.Config().Duration("domain.cleanup.data_retention")
    if retentionDuration == 0 {
        retentionDuration = 180 * 24 * time.Hour // Default
    }
    
    // Cascade delete по api_type + api_uid
    deleted, err := t.container.DataCleanOld(t.ctx, retentionDuration)
    if err != nil {
        _ = t.createFailureAlert(err, retentionDuration)
        return interfaces.TaskResult{Err: err}
    }
    
    duration := time.Since(startTime)
    
    // Мониторинг
    if duration > 5*time.Minute {
        _ = t.createSlowCleanupAlert(deleted, duration, retentionDuration)
    }
    if deleted > 10000 {
        _ = t.createLargeCleanupAlert(deleted, retentionDuration)
    }
    
    return interfaces.TaskResult{Success: true}
}

Cascade deletion:

  • user_backups
  • world_backups
  • worlds
  • messages
  • rivals
  • event_results
  • traffic_flow_entries
  • transactions
  • users

Configuration - duration типы

Было (deprecated):

yaml
domain:
  cleanup:
    user_retention_days: 180
    world_retention_days: 90

Стало:

yaml
domain:
  cleanup:
    data_retention: 4320h  # 180 days

JSON Schema:

json
{
  "data_retention": {
    "type": "string",
    "pattern": "^\\d+(ns|us|µs|ms|s|m|h)$",
    "description": "Duration format: 4320h = 180 days"
  }
}

Database Indexes - оптимизированы

Indexes для cleanup:

sql
CREATE INDEX idx_users_cleanup ON users(api_type, api_uid, updated_at);
CREATE INDEX idx_worlds_cleanup ON worlds(api_type, api_uid, updated_at);
CREATE INDEX idx_transactions_cleanup ON transactions(api_type, api_uid, created_at);
-- ... и т.д.

5. Что уже сделано правильно в MARV

User Model - отличный пример Rich Domain Model:

go
// Есть методы для всех операций
func (u *User) Ban()
func (u *User) Unban()
func (u *User) IsBanned() bool
func (u *User) AttachToAcp(acpUserID, nickname, role)
func (u *User) HasRole() bool
func (u *User) SetAbTestGroup(abID, groupName)
func (u *User) UpdateLastSeen(version, ip, userAgent)

World Model - хороший пример:

go
// Бизнес-логика в модели
func (w *World) IsDefault() bool
func (w *World) IsCustom() bool
func (w *World) BelongsToUser(apiType, apiUID) bool

Message Model - хороший пример:

go
// Методы управления состоянием
func (m *Message) IsCompleted() bool
func (m *Message) MarkAsCompleted()
func (m *Message) IsExpired() bool
func (m *Message) BelongsToUser(apiType, apiUID) bool