Поиск границ Агрегатов

Автор раздела: Ivan Zakrevsky

Бизнес-требования

Бизнес-требования к Reference Application описаны в разделе "4.6. Система квалификационной классификации членов Организации" Устава региональной общественной организации "Объединение ИТ-Архитекторов".

Выделим основные из них:

  1. Каждый член Организации может отдать 20 рекомендаций (признаний) в год в пользу других членов.

  2. Одна рекомендация от члена Организации претендуемого (или более высокого) квалификационного класса равноценна двум рекомендациям от членов Организации текущего квалификационного класса (излишки не переносятся).

  3. Рекомендации от членов Организации более низкого квалификационного класса не допускаются.

  4. Допускается одна рекомендация рекомендующего за один конкретный артефакт рекомендуемого.

  5. Требуемые количества рекомендаций по квалификационным классам:

    1. Эксперт - 20 рекомендаций Экспертов или 40 рекомендаций Кандидатов в эксперты;

    2. Кандидат в эксперты - 10 рекомендаций Кандидатов в эксперты или 20 рекомендаций 1-го класса;

    3. 1 класс - 7 рекомендаций 1-го класса или 14 рекомендаций 2-го класса;

    4. 2 класс - 5 рекомендаций 2-го класса или 10 рекомендаций 3-го класса;

    5. 3 класс - 3 рекомендации 3-го класса или 6 рекомендаций без класса;

    6. без класса - по умолчанию.

Остальные требования в настоящий момент рассмотрения пока не сильно релевантны.

Strong Consistency (RDBMS)

Этот вариант реализации хорошо был расcмотрен в статье "Modeling Relationships in a DDD Way" by Vladimir Khorikov, поэтому подробно рассматривать мы его не будем. Приведу только заключительный фрагмент его статьи:

public class Student : Entity
{
    public string Name { get; }
    public string Email { get; }

    private readonly IList<StudentInstructor> _studentInstructors;
    public IReadOnlyList<Instructor> Instructors => _studentInstructors
        .Select(x => x.Instructor)
        .OrderBy(x => x.DateAdded)
        .ToList();

    internal void AddInstructor(StudentInstructor instructor)
    {
        _studentInstructors.Add(instructor);
    }
}

public class Instructor : Entity
{
    public string Name { get; }

    private readonly IList<StudentInstructor> _studentInstructors;
    public IReadOnlyList<Student> Students => _studentInstructors
        .Select(x => x.Student)
        .OrderBy(x => x.DateAdded)
        .ToList();

    public void AddStudent(Student student)
    {
        var studentInstructor = new StudentInstructor(student, this, DateTime.Now);
        _studentInstructors.Add(studentInstructor);
        student.AddInstructor(studentInstructor);
    }
}

public class StudentInstructor : Entity
{
    public Student Student { get; }
    public Instructor Instructor { get; }
    public DateTime DateAdded { get; }

    public StudentInstructor(Student student, Instructor instructor, DateTime dateAdded)
    {
        Student = student;
        Instructor = instructor;
        DateAdded = dateAdded;
    }
}

Eventual Consistency

Первоначальная модель

Хотя предполагается использование RDBMS, но была предпринята попытка найти такие контуры Агрегатов, которые без существенной переработки могли бы функционировать и в условиях отсутствия транзакционной согласованности.

Самый первый вариант модели практически полностью воспроизводил структуру (online) excel-таблиц, использовавшихся на тот момент. Упрощенная реализация модели выглядела примерно так:

Упрощенная реализация первоначальной модели

package grade_1

import (
    "errors"
    "time"
)

type MemberId uint64
type Grade uint
type AvailableEndorsementCount uint
type ReceivedEndorsementCount uint
type EndorsementId uint64
type ArtifactDescription string

type Weight uint8

const (
    PeerWeight   = 1
    HigherWeight = 2
)

const (
    Expert       = Grade(5)
    Candidate    = Grade(4)
    Grade1       = Grade(3)
    Grade2       = Grade(2)
    Grade3       = Grade(1)
    WithoutGrade = Grade(0)
)

type Specialist struct {
    id                       MemberId
    grade                    Grade
    receivedEndorsementCount ReceivedEndorsementCount
    assignments              []Assignment
    version                  uint
    createdAt                time.Time
}

func (s Specialist) GetId() MemberId {
    return s.id
}

func (s Specialist) GetGrade() Grade {
    return s.grade
}

func (s Specialist) GetVersion() uint {
    return s.version
}

func (s *Specialist) IncreaseReceivedEndorsementCount(w Weight, t time.Time) {
    s.receivedEndorsementCount += ReceivedEndorsementCount(w)
    if s.grade == WithoutGrade && s.receivedEndorsementCount >= 6 {
        s.setGrade(Grade3, t)
        s.receivedEndorsementCount = 0
    } else if s.grade == Grade3 && s.receivedEndorsementCount >= 10 {
        s.setGrade(Grade2, t)
        s.receivedEndorsementCount = 0
    } else if s.grade == Grade2 && s.receivedEndorsementCount >= 14 {
        s.setGrade(Grade1, t)
        s.receivedEndorsementCount = 0
    } else if s.grade == Grade1 && s.receivedEndorsementCount >= 20 {
        s.setGrade(Candidate, t)
        s.receivedEndorsementCount = 0
    } else if s.grade == Candidate && s.receivedEndorsementCount >= 40 {
        s.setGrade(Expert, t)
        s.receivedEndorsementCount = 0
    }
}

func (s *Specialist) setGrade(g Grade, t time.Time) {
    s.assignments = append(s.assignments, Assignment{
        s.id, s.version, g, t,
    })
    s.grade = g
}

func (s *Specialist) IncreaseVersion() {
    s.version += 1
}

type Assignment struct {
    specialistId        MemberId
    specialistVersion   uint
    assignedGrade       Grade
    createdAt           time.Time
}

type Endorser struct {
    id                        MemberId
    grade                     Grade
    availableEndorsementCount AvailableEndorsementCount
    version                   uint
    createdAt                 time.Time
}

func (e Endorser) Endorse(s Specialist, aDesc ArtifactDescription, t time.Time) (Endorsement, error) {
    if e.grade < s.grade {
        return Endorsement{}, errors.New(
            "it is allowed to endorse only members with equal or lower grade",
        )
    }
    if e.availableEndorsementCount == 0 {
        return Endorsement{}, errors.New(
            "you have reached the limit of available recommendations this year",
        )
    }
    if uint64(e.id) == uint64(s.GetId()) {
        return Endorsement{}, errors.New(
            "endorser can't endorse himself",
        )
    }
    return Endorsement{
        e.id, e.grade, e.version,
        s.id, s.grade, s.GetVersion(),
        aDesc, t,
    }, nil
}

func (e *Endorser) DecreaseAvailableEndorsementCount() error {
    if e.availableEndorsementCount == 0 {
        return errors.New("no endorsement is available")
    }
    e.availableEndorsementCount -= 1
    return nil
}

func (e *Endorser) IncreaseVersion() {
    e.version += 1
}

type Endorsement struct {
    endorserId        MemberId
    endorserGrade     Grade
    endorserVersion   uint
    specialistId        MemberId
    specialistGrade     Grade
    specialistVersion   uint
    artifactDescription ArtifactDescription
    createdAt           time.Time
}

Метод Endorser.Endorse(Specialist, ArtifactDescription, time.Time) является фабричным методом Агрегата Endorsement. При сохранении Агрегата Endorsement в Хранилище, из него извлекаются Доменные События, и отправляются подписчикам через некий механизм доставки. Мы предполагаем, что они могут быть обработаны как синхронно в той же транзакции (Mediator/Observer Design Pattern), так и асинхронно в другой транзакции (Message Broker).

На Доменное Событие EndorsementCreated подписаны:

  1. Endorser, у которого вызывается метод Endorser.DecreaseAvailableEndorsementCount() для вычитания использованной рекомендации из счетчика доступных в этом году рекомендаций;

  2. Endorse, у которого вызывается метод Specialist.IncreaseReceivedEndorsementCount(Weight) с указанием веса рекомендации, зависящего от отношения квалификационного класса рекомендующего к квалификационному классу рекомендуемого.

Обратите внимание, Assignment, как и Endorsement, имеет значение для бизнес-правил, и он не может быть усечен снэпшотом event sourced log. Например, он может устанавливать правила минимального, либо максимального периода времени между присваиваниями классности, например, не чаще одного раза в полгода. Или, например, требовать подтверждения текущей классности в случае отсутствия присваиваний в течении года. Поэтому, он выполнен в виде самостоятельного Объекта-Значения. В ином случае он мог бы быть перемещен в ReadModel.

Endorsement, в свою очередь, отвечает за то, чтобы Endorser не смог порекомендовать один и тот же Artifact одного и того же Specialist дважды.

Реализация требований

Пройдемся по требованиям:

Каждый член Организации может отдать 20 рекомендаций (признаний) в год в пользу других членов.

Это требование реализуется счетчиком Endorser.availableEndorsementCount и инвариантом Объекта-Значения AvailableEndorsementCount, который не может превышать установленное ограничение.

Одна рекомендация от члена Организации претендуемого (или более высокого) квалификационного класса равноценна двум рекомендациям от членов Организации текущего квалификационного класса (излишки не переносятся).

Это требование реализуется обработчиком Доменного События EndorsementCreated перед вызовом метода Specialist.IncreaseReceivedEndorsementCount(Weight).

Рекомендации от членов Организации более низкого квалификационного класса не допускаются.

Реализуется фабричным методом Endorser.Endorse(...).

Допускается одна рекомендация рекомендующего за один конкретный артефакт рекомендуемого.

Это требование обсудим отдельно.

Требуемые количества рекомендаций по квалификационным классам...

Реализуется фабричным методом Endorser.Endorse(...).

Проблемы данной модели

В существующей модели прослеживается ряд проблем. Рассмотрим их по порядку.

Вероятность утраты согласованности

Давайте представим, что endorserA 2-го класса дает рекомендацию в пользу specialistA 2-го класса, у которого уже существует 13 рекомендаций, т.е. для присвоения нового квалификационного класса не хватает всего одной рекомендации. В период времени с момента проверки инварианта методом Endorser.Endorse(...) и до декрементирования счетчика доступных рекомендаций рекомендующего методом Endorser.DecreaseAvailableEndorsementCount(), а также до вызова метода Specialist.IncreaseReceivedEndorsementCount(Weight), другой участник endorserB 2-го класса может также успеть дать рекомендацию в пользу specialistA.

В результате рекомендация endorserB будет зачтена в пользу specialistA уже фактически 1-го класса, что нарушает требование о запрете на рекомендацию участников более высокого квалификационного класса.

Для упреждения такой ситуации достаточно наложить покрывающий (композитный) уникальный индекс на поля Endorsement.specialistId и Endorsement.specialistVersion.

Или рассмотрим другую ситуацию. Участник endorserA, у которого оставалась всего одна доступная рекомендация в текущем году, дает рекомендацию в пользу specialistA, но произошла техническая задержка доставки сообщения EndorsementCreated рекомендующему по техническим причинам, например, очередь "встала" (или подписчик затупил, чек-поинт в БД запустился, сеть упала...), и тогда рекомендующий может успеть раздать рекомендаций больше, чем располагает. Упреждается такая ситуация таким же образом - покрывающим уникальным индексом на поля Endorsement.endorserId и Endorsement.endorserGrade.

Но это выдвигает новый вопрос - каким образом партиционировать таблицу Endorsement, чтобы реализовать оба уникальных индекса? Кто хоть раз занимался партиционированием, тот знает, что уникальный индекс возможен только в пределах партиции. Можно, конечно, партиционировать Endorsement по автоинкрементальному первичному ключу (или по дате создания), но тут самое время перейти к следующему требованию, которое гласит: "Допускается одна рекомендация рекомендующего за один конкретный артефакт рекомендуемого".

Уникальность артефакта

Суть в том, что дубликаты описаний артефактов могут быть нечеткими. Требуется вводить пре-модерацию рекомендаций. Но это значит, что оптимистическая блокировка с помощью уникального индекса может длиться часами и днями. Вряд ли пользователи системы будут в восторге от этого.

А что если выделить Endorsement.artifactDescription в отдельный Агрегат и сделать пре-модерацию для него? Кажется, это предотвратит длительные блокировки по уже одобренным артефактам, а рекомендация неодобренных артефактов в принципе невозможна. Более того, артефакты можно категоризировать, и тогда система сможет информировать не только о квалификационной классности члена Организации, но и об областях знаний его экспертности.

Вносим правки:

type Endorsement struct {
    endorserId        MemberId
    endorserGrade     Grade
    endorserVersion   uint
    specialistId        MemberId
    specialistGrade     Grade
    specialistVersion   uint
    artifactId          ArtifactId
    createdAt           time.Time
}

type Artifact struct {
    id            ArtifactId
    status        ArtifactStatus
    description   ArtifactDescription
    competenceIds []CompetenceId
    createdAt     time.Time
}

type Competence struct {
    id        CompetenceId
    name      CompetenceName
    createdAt time.Time
}

Задача упрощается. В момент создания Агрегата Endorsement мы можем удостовериться, что такой артефакт такого рекомендуемого еще пока не был рекомендован таким рекомендующим, с помощью стратегии-валидатора, передаваемой аргументом в фабричный метод, ответственный за создание этого Агрегата.

Но вот в чем дело. После выделения артефакта в отдельный Агрегат, нам корневой доступ к Агрегату Endorsement особо-то и не нужен. Это значит, что его можно преобразовать в Сущность.

Преобразование Endorsement в Сущность

С точки зрения DDD Trilemma, учитывая относительно небольшое количество возможных рекомендаций в процессе жизни Агрегата Specialist, имеет смысл отдать предпочтение в пользу "Domain model purity" и "Domain model completeness".

Вопрос в том, в каком именно Агрегате разместить Сущность Endorsement? Ответ на этот вопрос подскажет нам, по какому ключу лучше партиционировать таблицу Endorsement. Сейчас становится уже очевидно, что партиционирование по автоинкрементальному первичному ключу (или по дате создания) будет приводить к просмотру всех партиций, что нас не устраивает.

У кого хранится в реальном мире наградной лист, почетная грамота, сертификат и т.д. - у награждаемого или у награждающего? Для кого он имеет ценность?

Это наводит на мысль о том, что Сущность Endorsement должна принадлежать Агрегату Specialist. Что подтвержается также ответом на вопрос о том, должен ли рекомендующий, т.е. Агрегат Endorser, хранить рекомендации удаленных из системы рекомендуемых? Вроде бы рекомендации должны удаляться вместе с рекомендуемым (это отвечает и на вопрос о том, по какому ключу партиционировать таблицу Endorsement). А вот если из системы удаляется рекомендующий, то его рекомендации продолжают иметь значение как способ подтверждения достоверности квалификационной классности рекомендуемого. Иными словами, квалификационная классность рекомендуемого является сверткой (left fold) этих рекомендаций, по-другому говоря - их проекцией.

Таким образом, между рекомендуемым и рекомендациями образуется строгая согласованность. Все, что теперь требуется Агрегату Specialist для того, чтобы установить возможность создания рекомендации - это квалификационный класс рекомендующего и достоверность того, что он пока еще не исчерпал доступные ему рекомендации.

Но это так же значит, что мы не можем создать покрывающий уникальный индекс на поля Endorsement.endorserId и Endorsement.endorserGrade.

Иными словами, существует незначительная вероятность того, что Endorser успеет раздать рекомендаций больше, чем ему доступно. Существует несколько способов решить эту проблему.

Достижение согласованности
Data, context, and interaction (DCI)

Первый из них - это "Data, context, and interaction (DCI)". Подробно он описан в главе "Chapter 9. Coding it Up: The DCI Architecture" книги "Lean Architecture: for Agile Software Development" 1st edition by James O. Coplien, Gertrud Bjørnvig. Можно посмотреть на примере реализации перевода денежных средств с одного счета на другой счет (который, в определенной мере, похож на перенос рекомендации от одного члена Организации к другому члену Организации).

Process Manager Pattern

Второй способ описывает Vaughn Vernon в интервью "Modeling Uncertainty with Reactive DDD" by Vaughn Vernon reviewed by Thomas Betts - путем применения Process Manager Pattern.

Сюда же можно отнести SAGA pattern и workflow engines.

Pessimistic Offline Lock

Еще одним решением может быть пессимистическая блокировка Агрегата Endorser. Сценарий будет состоять из следующих этапов:

  • блокировка Агрегата Endorser;

  • проверка возможности осуществления рекомендации;

    • в случае неудачи - блокировка отпускается;

    • в случае успеха - порождается Доменное Событие;

      • обработчик Доменного События вызывает Specialist.ReceiveEndorsement(Endorser, ArtifactId, time.Time) error;

        • в случае успеха порождается Доменное Событие об успехе;

          • обработчик Доменного События осуществит декрементирование счетчика доступных рекомендаций рекомендующего методом Endorser.DecreaseAvailableEndorsementCount() и отпустит блокировку;

        • в случае неудачи порождается Доменное Событие о неудаче;

          • обработчик Доменного События отпустит блокировку.

Блокировка не позволит рекомендующему осуществить другую рекомендацию, пока не завершится первая.

Резервирование

Повысить параллелизм можно, если заменить блокировку на резервирование рекомендации, используя счетчик Endorser.pendingEndorsementCount, значение которого не должно превышать значение Endorser.availableEndorsementCount. Сценарий будет состоять из следующих этапов:

  • резервирование рекомендации инкрементированием счетчика Endorser.pendingEndorsementCount;

  • проверка возможности осуществления рекомендации;

    • в случае неудачи - декрементирование счетчика Endorser.pendingEndorsementCount;

    • в случае успеха - порождается Доменное Событие;

      • обработчик Доменного События вызывает Specialist.ReceiveEndorsement(Endorser, ArtifactId, time.Time) error;

        • в случае успеха порождается Доменное Событие об успехе;

          • обработчик Доменного События осуществит декрементирование счетчика доступных рекомендаций рекомендующего Endorser.availableEndorsementCount и отпустит резервирование декрементированием счетчика Endorser.pendingEndorsementCount;

        • в случае неудачи порождается Доменное Событие о неудаче;

          • обработчик Доменного События отпустит резервирование декрементированием счетчика Endorser.pendingEndorsementCount.

Этот вариант выглядит наиболее простым, поэтому, на нем и остановимся. Не исключено, что в будущем появятся альтернативные реализации с использованием описанных подходов.

Упрощенная реализация итоговой модели

package grade_2

import (
    "errors"
    "time"
)

type MemberId uint64
type Grade uint
type EndorsementCount uint
type ArtifactId uint64
type ArtifactDescription string
type ArtifactStatus uint8
type CompetenceId uint64
type CompetenceName string

type Weight uint8

const (
    LowerWeight  = 0
    PeerWeight   = 1
    HigherWeight = 2

    Expert       = Grade(5)
    Candidate    = Grade(4)
    Grade1       = Grade(3)
    Grade2       = Grade(2)
    Grade3       = Grade(1)
    WithoutGrade = Grade(0)

    Proposed     = ArtifactStatus(0)
    Accepted     = ArtifactStatus(1)
)

type Specialist struct {
    id                       MemberId
    grade                    Grade
    receivedEndorsements     []Endorsement
    assignments              []Assignment
    version                  uint
    createdAt                time.Time
}

func (s *Specialist) ReceiveEndorsement(e Endorser, aId ArtifactId, t time.Time) error {
    if e.GetGrade() < s.grade {
        return errors.New(
            "it is allowed to receive endorsements only from members with equal or higher grade",
        )
    }
    if !e.CanCompleteEndorsement() {
        return errors.New(
            "endorser is not able to complete endorsement",
        )
    }
    if uint64(e.GetId()) == uint64(s.id) {
        return errors.New(
            "endorser can't endorse himself",
        )
    }
    for _, v := range s.receivedEndorsements {
        if v.IsEndorsedBy(e.GetId(), aId) {
            return errors.New("this artifact has already been endorsed by the recogniser")
        }
    }
    s.receivedEndorsements = append(s.receivedEndorsements, Endorsement{
        e.GetId(), e.GetGrade(), e.GetVersion(),
        s.id, s.grade, s.version,
        aId, t,
    })
    s.actualizeGrade(t)
    return nil
}

func (s *Specialist) actualizeGrade(t time.Time) {
    if s.grade == WithoutGrade && s.getReceivedEndorsementCount() >= 6 {
        s.setGrade(Grade3, t)
    } else if s.grade == Grade3 && s.getReceivedEndorsementCount() >= 10 {
        s.setGrade(Grade2, t)
    } else if s.grade == Grade2 && s.getReceivedEndorsementCount() >= 14 {
        s.setGrade(Grade1, t)
    } else if s.grade == Grade1 && s.getReceivedEndorsementCount() >= 20 {
        s.setGrade(Candidate, t)
    } else if s.grade == Candidate && s.getReceivedEndorsementCount() >= 40 {
        s.setGrade(Expert, t)
    }
}
func (s Specialist) getReceivedEndorsementCount() uint {
    var counter uint
    for _, v := range s.receivedEndorsements {
        if v.GetSpecialistGrade() == s.grade {
            counter += uint(v.GetWeight())
        }
    }
    return counter
}

func (s *Specialist) setGrade(g Grade, t time.Time) {
    s.assignments = append(s.assignments, Assignment{
        s.id, s.version, g, t,
    })
    s.grade = g
}

func (s *Specialist) IncreaseVersion() {
    s.version += 1
}

type Endorsement struct {
    endorserId        MemberId
    endorserGrade     Grade
    endorserVersion   uint
    specialistId        MemberId
    specialistGrade     Grade
    specialistVersion   uint
    artifactId          ArtifactId
    createdAt           time.Time
}

func (e Endorsement) IsEndorsedBy(rId MemberId, aId ArtifactId) bool {
    return e.endorserId == rId && e.artifactId == aId
}

func (e Endorsement) GetSpecialistGrade() Grade {
    return e.specialistGrade
}

func (e Endorsement) GetWeight() Weight {
    if e.endorserGrade == e.specialistGrade {
        return PeerWeight
    } else if e.endorserGrade > e.specialistGrade {
        return HigherWeight
    }
    return LowerWeight
}

type Assignment struct {
    specialistId       MemberId
    specialistVersion  uint
    assignedGrade      Grade
    createdAt          time.Time
}

type Endorser struct {
    id                        MemberId
    grade                     Grade
    availableEndorsementCount EndorsementCount
    pendingEndorsementCount   EndorsementCount
    version                   uint
    createdAt                 time.Time
}

func (e Endorser) GetId() MemberId {
    return e.id
}

func (e Endorser) GetGrade() Grade {
    return e.grade
}

func (e Endorser) GetVersion() uint {
    return e.version
}

func (e Endorser) canReserveEndorsement() bool {
    return e.availableEndorsementCount > e.pendingEndorsementCount
}

func (e Endorser) CanCompleteEndorsement() bool {
    return e.pendingEndorsementCount > 0 && e.availableEndorsementCount >= e.pendingEndorsementCount
}

func (e *Endorser) ReserveEndorsement() error {
    if !e.canReserveEndorsement() {
        return errors.New("no endorsement can be reserved")
    }
    e.pendingEndorsementCount += 1
    return nil
}

func (e *Endorser) ReleaseEndorsementReservation() {
    e.pendingEndorsementCount -= 1
}

func (e *Endorser) CompleteEndorsement() error {
    if e.availableEndorsementCount == 0 {
        return errors.New("no endorsement is available")
    }
    if e.pendingEndorsementCount == 0 {
        return errors.New("there is no endorsement reservation")
    }
    e.availableEndorsementCount -= 1
    e.pendingEndorsementCount -= 1
    return nil
}

func (e *Endorser) IncreaseVersion() {
    e.version += 1
}

type Artifact struct {
    id            ArtifactId
    status        ArtifactStatus
    description   ArtifactDescription
    competenceIds []CompetenceId
    createdAt     time.Time
}

type Competence struct {
    id        CompetenceId
    name      CompetenceName
    createdAt time.Time
}

Ссылка на полную модель:

Missing chapter

Проектом предусматривается поддержка Multitenancy. В свете этого, возникает потребность в гибком конфигурировании количества уровней классности для каждого Tenant, а также количества требуемых рекомендаций для достижения каждого уровня. По этой причине, конструктор экземпляра Объекта-значения Grade должен создаваться Агрегатом Tenant. Соответственно, фабричные методы Агрегатов Endorser и Endorsement должны переехать в Агрегат Tenant, чтобы иметь возможность принимать сконфигурированный экземпляр Объекта-значения Grade.

По мере роста гибкости бизнес-правил можно рассмотреть вариант применения "Rules Engine" (aka "Production Rule System"), например, в виде "Grule-Rule-Engine" - Rule engine implementation in Golang.