Поиск границ Агрегатов¶
Автор раздела: Ivan Zakrevsky
Бизнес-требования¶
Бизнес-требования к Reference Application описаны в разделе "4.6. Система квалификационной классификации членов Организации" Устава региональной общественной организации "Объединение ИТ-Архитекторов".
Выделим основные из них:
Каждый член Организации может отдать 20 рекомендаций (признаний) в год в пользу других членов.
Одна рекомендация от члена Организации претендуемого (или более высокого) квалификационного класса равноценна двум рекомендациям от членов Организации текущего квалификационного класса (излишки не переносятся).
Рекомендации от членов Организации более низкого квалификационного класса не допускаются.
Допускается одна рекомендация рекомендующего за один конкретный артефакт рекомендуемого.
Требуемые количества рекомендаций по квалификационным классам:
Эксперт - 20 рекомендаций Экспертов или 40 рекомендаций Кандидатов в эксперты;
Кандидат в эксперты - 10 рекомендаций Кандидатов в эксперты или 20 рекомендаций 1-го класса;
1 класс - 7 рекомендаций 1-го класса или 14 рекомендаций 2-го класса;
2 класс - 5 рекомендаций 2-го класса или 10 рекомендаций 3-го класса;
3 класс - 3 рекомендации 3-го класса или 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
подписаны:
Endorser
, у которого вызывается методEndorser.DecreaseAvailableEndorsementCount()
для вычитания использованной рекомендации из счетчика доступных в этом году рекомендаций;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.