Aggregates и инварианты: правила, которые нельзя нарушать
Aggregates и инварианты: правила, которые нельзя нарушать
Когда данные становятся невалидными, бизнес теряет деньги, а ты - выходные. Aggregates - про правила, которые код просто не даёт нарушить.
Проблема: данные разъезжаются
Представь: у пользователя BackendStart прогресс курса - 147%. Или он «завершил» урок, которого нет в треке. Или два горутины одновременно обновили прогресс и одно обновление потерялось.
Всё это происходит, когда бизнес-правила разбросаны по handler'ам, сервисам и middleware. Никто не контролирует целостность данных на уровне домена. Aggregate решает эту проблему.
Что такое Aggregate
Aggregate - группа связанных объектов, которые меняются как единое целое. У каждого агрегата есть Aggregate Root - единственная точка входа для всех изменений.
Ключевая идея: внешний код никогда не обращается к внутренним объектам агрегата напрямую. Все изменения проходят через корень.
// 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:
- Процент прогресса всегда в диапазоне 0..100
- Нельзя завершить урок, которого нет в курсе
- Нельзя завершить уже завершённый урок повторно
- Процент пересчитывается автоматически при завершении урока
// 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)