Aggregates и инварианты: правила, которые нельзя нарушать

Aggregates и инварианты: правила, которые нельзя нарушать

Когда данные становятся невалидными, бизнес теряет деньги, а ты - выходные. Aggregates - про правила, которые код просто не даёт нарушить.

Проблема: данные разъезжаются

Представь: у пользователя BackendStart прогресс курса - 147%. Или он «завершил» урок, которого нет в треке. Или два горутины одновременно обновили прогресс и одно обновление потерялось.

Всё это происходит, когда бизнес-правила разбросаны по handler'ам, сервисам и middleware. Никто не контролирует целостность данных на уровне домена. Aggregate решает эту проблему.

Что такое Aggregate

Aggregate - группа связанных объектов, которые меняются как единое целое. У каждого агрегата есть Aggregate Root - единственная точка входа для всех изменений.

Ключевая идея: внешний код никогда не обращается к внутренним объектам агрегата напрямую. Все изменения проходят через корень.

Aggregate Root: внешний use case вызывает методы только корня CourseProgress; LessonProgress скрыты внутри агрегата

// CourseProgress - Aggregate Root.
// Внешний код работает ТОЛЬКО через методы этой структуры.
type CourseProgress struct {
    id         string
    userID     string
    courseID   string
    lessons    map[string]*LessonProgress // внутренние объекты
    percent    int
    startedAt  time.Time
    completedAt *time.Time
}

// LessonProgress - внутренний объект агрегата.
// Внешний код НЕ должен создавать или менять его напрямую.
type LessonProgress struct {
    lessonID    string
    completed   bool
    completedAt *time.Time
}
<?php
declare(strict_types=1);

namespace App\Learning\Domain;

// CourseProgress - Aggregate Root.
// НЕ readonly: агрегат мутируется (percent, completedAt, lessons).
// Внешний код работает ТОЛЬКО через методы этого класса.
final class CourseProgress
{
    /**
     * @param array<string, LessonProgress> $lessons
     * @param object[]                       $events
     */
    public function __construct(
        private readonly string $id,
        private readonly string $userId,
        private readonly string $courseId,
        private array $lessons,           // внутренние объекты
        private int $percent,
        private readonly \DateTimeImmutable $startedAt,
        private ?\DateTimeImmutable $completedAt = null,
        private array $events = [],
    ) {}
}

// LessonProgress - внутренний объект агрегата.
// Внешний код НЕ должен создавать или менять его напрямую.
final class LessonProgress
{
    public function __construct(
        public readonly string $lessonId,
        private bool $completed = false,
        private ?\DateTimeImmutable $completedAt = null,
    ) {}

    public function isCompleted(): bool { return $this->completed; }

    public function markCompleted(\DateTimeImmutable $at): void
    {
        $this->completed = true;
        $this->completedAt = $at;
    }
}

Инварианты: правила, которые всегда истинны

Инвариант - бизнес-правило, которое агрегат обязан поддерживать в любой момент времени. Если инвариант нарушен, данные невалидны.

Инварианты BackendStart для CourseProgress:

  1. Процент прогресса всегда в диапазоне 0..100
  2. Нельзя завершить урок, которого нет в курсе
  3. Нельзя завершить уже завершённый урок повторно
  4. Процент пересчитывается автоматически при завершении урока
// courseLessons - список допустимых уроков курса (передаётся при создании).
func NewCourseProgress(id, userID, courseID string, courseLessons []string) *CourseProgress {
    lessons := make(map[string]*LessonProgress, len(courseLessons))
    for _, lid := range courseLessons {
        lessons[lid] = &LessonProgress{lessonID: lid}
    }
    return &CourseProgress{
        id:        id,
        userID:    userID,
        courseID:  courseID,
        lessons:   lessons,
        percent:   0,
        startedAt: time.Now(),
    }
}

func (cp *CourseProgress) CompleteLesson(lessonID string) error {
    // Инвариант 2: урок должен существовать в курсе
    lp, exists := cp.lessons[lessonID]
    if !exists {
        return fmt.Errorf("lesson %s does not belong to course %s", lessonID, cp.courseID)
    }

    // Инвариант 3: нельзя завершить повторно
    if lp.completed {
        return fmt.Errorf("lesson %s already completed", lessonID)
    }

    now := time.Now()
    lp.completed = true
    lp.completedAt = &now

    // Инвариант 1 и 4: пересчёт процента (всегда 0..100)
    cp.recalcPercent()

    return nil
}

func (cp *CourseProgress) recalcPercent() {
    total := len(cp.lessons)
    if total == 0 {
        cp.percent = 0
        return
    }

    done := 0
    for _, lp := range cp.lessons {
        if lp.completed {
            done++
        }
    }

    cp.percent = (done * 100) / total

    if cp.percent == 100 {
        now := time.Now()
        cp.completedAt = &now
    }
}
<?php
declare(strict_types=1);

namespace App\Learning\Domain;

final class CourseProgress
{
    // courseLessons - список допустимых уроков курса (передаётся при создании).
    /** @param string[] $courseLessons */
    public static function start(
        string $id,
        string $userId,
        string $courseId,
        array $courseLessons,
    ): self {
        $lessons = [];
        foreach ($courseLessons as $lid) {
            $lessons[$lid] = new LessonProgress($lid);
        }
        return new self(
            $id,
            $userId,
            $courseId,
            $lessons,
            0,
            new \DateTimeImmutable(),
        );
    }

    public function completeLesson(string $lessonId): void
    {
        // Инвариант 2: урок должен существовать в курсе
        if (!isset($this->lessons[$lessonId])) {
            throw new \DomainException(sprintf(
                'lesson %s does not belong to course %s',
                $lessonId,
                $this->courseId,
            ));
        }
        $lp = $this->lessons[$lessonId];

        // Инвариант 3: нельзя завершить повторно
        if ($lp->isCompleted()) {
            throw new \DomainException(sprintf('lesson %s already completed', $lessonId));
        }

        $lp->markCompleted(new \DateTimeImmutable());

        // Инвариант 1 и 4: пересчёт процента (всегда 0..100)
        $this->recalcPercent();
    }

    private function recalcPercent(): void
    {
        $total = count($this->lessons);
        if ($total === 0) {
            $this->percent = 0;
            return;
        }
        $done = 0;
        foreach ($this->lessons as $lp) {
            if ($lp->isCompleted()) {
                $done++;
            }
        }
        $this->percent = intdiv($done * 100, $total);
        if ($this->percent === 100) {
            $this->completedAt = new \DateTimeImmutable();
        }
    }
}

Обрати внимание: recalcPercent - приватный метод. Внешний код не может установить произвольный процент. Инвариант защищён на уровне структуры.

Если handler может напрямую выставить progress.percent = 200 или изменить LessonProgress.completed - инварианты ничего не стоят. Все поля агрегата должны быть неэкспортируемыми (lowercase в Go), а изменения - только через методы Aggregate Root.

Правила проектирования агрегатов

Правило 1: агрегаты должны быть маленькими

Плохо: агрегат User содержит всё - профиль, прогресс всех курсов, все результаты квизов, историю платежей.

// Плохо: гигантский агрегат
type User struct {
    id        string
    profile   Profile
    courses   []CourseProgress  // все курсы
    quizzes   []QuizResult      // все квизы
    payments  []Payment         // все платежи
    sessions  []Session         // все сессии
}
<?php
declare(strict_types=1);

namespace App\Learning\Domain;

// Плохо: гигантский агрегат
final class User
{
    /**
     * @param CourseProgress[] $courses
     * @param QuizResult[]     $quizzes
     * @param Payment[]        $payments
     * @param Session[]        $sessions
     */
    public function __construct(
        private readonly string $id,
        private Profile $profile,
        private array $courses,   // все курсы
        private array $quizzes,   // все квизы
        private array $payments,  // все платежи
        private array $sessions,  // все сессии
    ) {}
}

Проблема: при любом изменении прогресса загружаются платежи и сессии. Конкурентный доступ блокирует весь пользователя.

// Хорошо: маленький фокусированный агрегат
type CourseProgress struct {
    id       string
    userID   string   // ссылка по ID, а не вложенный User
    courseID  string   // ссылка по ID, а не вложенный Course
    lessons  map[string]*LessonProgress
    percent  int
}
<?php
declare(strict_types=1);

namespace App\Learning\Domain;

// Хорошо: маленький фокусированный агрегат
final class CourseProgress
{
    /** @param array<string, LessonProgress> $lessons */
    public function __construct(
        private readonly string $id,
        private readonly string $userId,   // ссылка по ID, а не вложенный User
        private readonly string $courseId, // ссылка по ID, а не вложенный Course
        private array $lessons,
        private int $percent,
    ) {}
}

Правило 2: ссылайся на другие агрегаты по ID

Агрегат CourseProgress не содержит объект User или Course целиком. Он хранит только userID и courseID. Это снижает связанность и упрощает хранение.

Правило 3: один агрегат - одна транзакция

Каждая операция записи должна затрагивать ровно один агрегат в одной транзакции. Если бизнес-процесс требует изменить два агрегата - используй доменные события.

// Хорошо: одна транзакция - один агрегат
func (uc *CompleteLessonUseCase) Execute(userID, courseID, lessonID string) error {
    progress, err := uc.progressRepo.Get(userID, courseID)
    if err != nil {
        return err
    }

    if err := progress.CompleteLesson(lessonID); err != nil {
        return err
    }

    // Одна транзакция сохраняет один агрегат
    if err := uc.progressRepo.Save(progress); err != nil {
        return err
    }

    // Для побочных эффектов (обновить статистику курса) - событие
    uc.eventBus.Publish(LessonCompleted{
        UserID:   userID,
        CourseID: courseID,
        LessonID: lessonID,
    })

    return nil
}
<?php
declare(strict_types=1);

namespace App\Learning\Application;

// Хорошо: одна транзакция - один агрегат
final class CompleteLessonUseCase
{
    public function __construct(
        private readonly ProgressRepository $progressRepo,
        private readonly EventBus $eventBus,
    ) {}

    public function execute(string $userId, string $courseId, string $lessonId): void
    {
        $progress = $this->progressRepo->get($userId, $courseId);

        $progress->completeLesson($lessonId);

        // Одна транзакция сохраняет один агрегат
        $this->progressRepo->save($progress);

        // Для побочных эффектов (обновить статистику курса) - событие
        $this->eventBus->publish(new LessonCompleted(
            userId: $userId,
            courseId: $courseId,
            lessonId: $lessonId,
        ));
    }
}

Два агрегата в одной транзакции означают длинную блокировку, риск deadlock'ов и проблемы с масштабированием. Доменные события позволяют обновлять второй агрегат асинхронно, сохраняя eventual consistency.

ACID и агрегаты

Агрегат - это граница транзакции. Внутри агрегата гарантирован ACID:

  • Atomicity: все изменения агрегата применяются целиком или не применяются
  • Consistency: инварианты проверены до сохранения
  • Isolation: конкурентные запросы к одному агрегату сериализуются
  • Durability: после Save изменения не теряются

Для защиты от конкурентных изменений агрегату добавляют поле version. При сохранении проверяется, что версия не изменилась (оптимистичная блокировка):

type CourseProgress struct {
    // ...
    version int // оптимистичная блокировка
}

func (cp *CourseProgress) Version() int { return cp.version }

// В репозитории при сохранении:
// UPDATE course_progress SET ..., version = version + 1
// WHERE id = ? AND version = ?
// Если 0 строк обновлено - ConflictError
<?php
declare(strict_types=1);

namespace App\Learning\Domain;

final class CourseProgress
{
    // ...
    private int $version = 0; // оптимистичная блокировка

    public function version(): int { return $this->version; }
}

// Doctrine поддерживает оптимистичную блокировку нативно:
// #[ORM\Version]
// #[ORM\Column(type: 'integer')]
// private int $version = 0;
// При flush() Doctrine добавит WHERE version = ? и бросит
// OptimisticLockException, если строка изменилась.

Оптимистичная (version field) - проверяем конфликт при записи. Подходит когда конфликты редки. Пессимистичная (SELECT FOR UPDATE) - блокируем строку при чтении. Подходит когда конфликты часты. Для большинства web-приложений оптимистичная блокировка - лучший выбор.

Мини-задание

  • Спроектируй агрегат QuizAttempt для BackendStart: пользователь отвечает на вопросы квиза, нельзя ответить дважды на один вопрос, результат пересчитывается автоматически
  • Определи 3 инварианта для этого агрегата и реализуй их в методах
  • Убедись, что все поля агрегата неэкспортируемые (lowercase)
  • Добавь поле version для оптимистичной блокировки
  • Проверь: не ссылается ли агрегат на другие агрегаты по значению (должен по ID)

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.