DDD концепции
1. Aggregate Boundaries
Что такое Aggregate?
Aggregate - кластер доменных объектов (entities + value objects), рассматриваемых как единое целое.
Aggregate Root - единственная точка входа в aggregate, через которую идут все изменения.
Зачем нужны Aggregates?
- Инварианты (Invariants) - бизнес-правила, которые ВСЕГДА должны быть истинны
- Транзакционная граница - один aggregate = одна транзакция
- Консистентность - невозможно нарушить правила агрегата
Пример из 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,TransactionIdStreamId,StreamInputIdEventId,EventResultIdTrafficFlowId,TrafficFlowEntryIdRivalId
System Entities:
AbTestId,RemoteConfigId,CronTaskIdAlertId,AlertDeliveryLogIdAcpUserId,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 updatesModules система:
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_backupsworld_backupsworldsmessagesrivalsevent_resultstraffic_flow_entriestransactionsusers
Configuration - duration типы
Было (deprecated):
yaml
domain:
cleanup:
user_retention_days: 180
world_retention_days: 90Стало:
yaml
domain:
cleanup:
data_retention: 4320h # 180 daysJSON 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) boolMessage Model - хороший пример:
go
// Методы управления состоянием
func (m *Message) IsCompleted() bool
func (m *Message) MarkAsCompleted()
func (m *Message) IsExpired() bool
func (m *Message) BelongsToUser(apiType, apiUID) bool