Розробка серверних Go-застосунків. Частина 2: проєктування предметної області

Привіт, спільното! Мене звати Дмитро, я співпрацюю з Wizer Inc. у ролі Backend Lead. Пропоную вашій увазі другу статтю з серії про розробку серверних застосунків на Go. Цього разу я детальніше зупинюсь на проєктуванні предметної області з застосуванням підходів, притаманних предметно-орієнтованому дизайну (Domain-Driven Design, DDD).

У статті розглянемо такі питання:

  • опис агрегатів, пов’язаних із ними сутностей та об’єктів-значень;
  • зміна стану агрегату через його методи;
  • збереження стану агрегату у сховищі на основі подій;
  • реалізація менеджера транзакцій і передача транзакцій через контекст;
  • диспетчер подій для оповіщення різних частин системи.

Перш як перейти до практичних рекомендацій і прикладів, визначимо ключові поняття:

Сутність (Entity) — об’єкт предметної області з власною унікальною ідентичністю, яка зберігається навіть при зміні його стану та визначається ідентифікатором. Декілька об’єктів з однаковими атрибутами, але різними ідентифікаторами, є різними сутностями. Навпаки, об’єкти з різними атрибутами, але однаковим ідентифікатором, розглядаються як різні стани однієї сутності.

Об’єкт-значення (Value Object) — об’єкт без власної ідентичності, визначається лише набором своїх атрибутів. Два об’єкти-значення з однаковими атрибутами вважаються еквівалентними. Такий об’єкт є іммутабельним, тобто після створення його стан не змінюється, а всі зміни вносяться через створення нового екземпляра.

Агрегат (Aggregate) — група сутностей і об’єктів-значень, що логічно об’єднані і змінюються як єдине ціле. Має кореневу сутність (Aggregate Root), через методи якої здійснюється доступ і модифікація даних.

Подія предметної області (Domain Event) — факт, що відбувся в системі і має значення для бізнесу. Подія є незмінною і часто використовується для синхронізації або побудови реактивної логіки.

У Go для опису цих об’єктів зазвичай використовуються структури, а їхні поля виступають у ролі атрибутів.

Структура та створення сутностей

Уявімо, що ми будуємо систему для управління навчальними матеріалами. Адміністратори системи можуть створювати курси, які складаються з декількох розділів, а також визначати складність матеріалу та практичні завдання.

Для реалізації цієї задачі визначимо ключові об’єкти предметної області:

  • Курс (Course) — агрегат, який забезпечує виконання інваріантів при роботі з його розділами та складністю.
  • Розділ (Chapter) — сутність, що існує в межах курсу.

Опишемо ці об’єкти у коді за допомогою структур:

internal/course/model/course.go

package model

import (
    "github.com/google/uuid"

    "examples/internal/course/vo"
)

// Course — агрегат, що об'єднує сутність "Курс" і пов'язані розділи.
type Course struct {
    id uuid.UUID
    name string
    complexity vo.Complexity
    chapters []Chapter
}

func (c *Course) ID() uuid.UUID {
    return c.id
}

func (c *Course) Name() string {
    return c.name
}

func (c *Course) Complexity() vo.Complexity {  
    return c.complexity  
}   

func (c *Course) Chapters() []Chapter {  
    return c.chapters  
}

// Chapter — сутність, яка існує лише в контексті Course.
type Chapter struct {
    id uuid.UUID
    name string
}

func (c *Chapter) ID() uuid.UUID {
    return c.id
}

func (c *Chapter) Name() string {
    return c.name
}

Оскільки «складність» (Complexity) визначається саме сукупністю значень усіх її полів, а не окремим ідентифікатором, доцільно віднести її до об’єктів-значень (Value Object).

internal/course/vo/complexity.go

package vo

import (
    "errors"
)

// Complexity — value object (іммутабельний).
type Complexity struct {
    content Level
    tasks   Level
}

type Level byte

const (
    UndefinedLevel Level = iota
    EntryLevel
    IntermediateLevel
    AdvancedLevel
)

var ErrUndefinedComplexityLevels = errors.New("complexity: levels not defined")

func NewComplexity(content, tasks Level) (Complexity, error) {
    if content == UndefinedLevel || tasks == UndefinedLevel {
        return Complexity{}, ErrUndefinedComplexityLevels
    }

    return Complexity{content: content, tasks: tasks}, nil
}

func (c Complexity) Content() Level {
    return c.content
}

func (c Complexity) Tasks() Level {
    return c.tasks
}

func (c Complexity) IsDefined() bool {
    return c.content != UndefinedLevel && c.tasks != UndefinedLevel
}

Для ініціалізації агрегату можна використовувати як простий конструктор, так і патерни «Будівельник» (Builder) або «Функціональні опції» (Functional Options), особливо коли структура містить багато полів.

internal/course/model/new_course.go

package model

import (
    "fmt"

    "github.com/google/uuid"

    "examples/internal/course/vo"
)

type CourseOption func(*Course) error

func NewCourse(id uuid.UUID, opts ...CourseOption) (Course, error) {
    var course Course
    course.SetID(id) // метод описаний у наступних прикладах

    for _, opt := range opts {
        if err := opt(&course); err != nil {
            return Course{}, fmt.Errorf("new course: %w", err)
        }
    }

    return course, nil
}

func WithName(name string) CourseOption {
    return func(c *Course) error {
        // можна додати перевірку вхідних даних

        c.name = name
        return nil
    }
}

func WithComplexity(complexity vo.Complexity) CourseOption {
    return func(c *Course) error {
        // можна додати перевірку вхідних даних

        if !complexity.IsDefined() {
            return vo.ErrUndefinedComplexityLevels
        }

        c.complexity = complexity
        return nil
    }
}

func WithChapters(chapters []Chapter) CourseOption {
    return func(c *Course) error {
        // можна додати перевірку вхідних даних

        c.chapters = chapters
        return nil
    }
}

func NewChapter(id uuid.UUID, opts ...ChapterOption) (Chapter, error) {
    var chapter Chapter
    chapter.SetID(id)// метод описаний у наступних прикладах

    for _, opt := range opts {
        if err := opt(&chapter); err != nil {
            return Chapter{}, fmt.Errorf("new chapter: %w", err)
        }
    }

    return chapter, nil
}

type ChapterOption func(*Chapter) error

func WithChapterName(name string) ChapterOption {
    return func(c *Chapter) error {
        // можна додати перевірку вхідних даних

        if name == "" {
            return ErrEmptyName
        }

        c.name = name
        return nil
    }
}

Для створення агрегату при отриманні його даних зі сховища також можна використовувати породжуючі функції або методи об’єктів передачі даних (DTO).

internal/course/outbound/postgres/course/dto.go

package course  

import (  
    "examples/internal/course/model"  
    "examples/internal/course/vo"

    "github.com/google/uuid"
)  

type Course struct {  
    ID                uuid.UUID
    Name              string
    ContentComplexity vo.Level
    TasksComplexity   vo.Level
    Chapters          []Chapter
}  

func (c Course) toAggregate() (model.Course, error) {
    complexity, err := vo.NewComplexity(c.ContentComplexity, c.TasksComplexity)
    if err != nil {
        return model.Course{}, fmt.Errorf("Course.toAggregate: %w", err)
    }

    chapters, err := toChapters(c.Chapters)
    if err != nil {
        return model.Course{}, fmt.Errorf("Course.toAggregate: %w", err)
    }

    course, err := model.NewCourse(
        c.ID,
        model.WithName(c.Name),
        model.WithChapters(chapters),
        model.WithComplexity(complexity),
    )
    if err != nil {
        return model.Course{}, fmt.Errorf("Course.toAggregate: %w", err)
    }

    return course, nil
} 

func toChapters(chapters []*Chapter) ([]model.Chapter, error) {
    result := make([]model.Chapter, len(chapters))
    var err error
    for i := range chapters {
        if chapters[i] == nil {
            return nil, fmt.Errorf("toChapters: chapter is nil: %w", ErrBrokenData)
        }

        result[i], err = model.NewChapter(chapters[i].ID, model.WithChapterName(chapters[i].Name))
        if err != nil {
            return nil, fmt.Errorf("toChapters: invalid data: %w", err)
        }
    }

    return result, nil
}

type Chapter struct {  
    ID       uuid.UUID
    Name     string
}  

Такий підхід передбачає написання більшої кількості коду, порівняно з варіантом отримання агрегату напряму зі сховища з використанням ORM. За бажання пришвидшити цей процес можна за допомогою кодогенерації, яку підтримують більшіть сучасних IDE, або ж AI-агентів.

Враховуючи, що сутності у застосунку найвірогідніше матимуть спільні поля, а агрегати побудовані на основі сутностей, можна створити для них окремі базові типи для зменшення дублікації коду.

internal/domain/entity.go

type Entity struct {
    id uuid.UUID
}

func (e *Entity) SetID(id uuid.UUID) {
    if e.id != uuid.Nil {
        panic("development mistake: dirty ID set")
    }

    if id == uuid.Nil {
        panic("development mistake: invalid ID")
    }

    e.id = id
}

func (e *Entity) ID() uuid.UUID {
    return e.id
}

internal/domain/aggregate.go

type Aggregate struct {
    Entity
}

Встановлення пустого ідентифікатора або його повторний запис слід розглядати не як операційні, а як помилки розробки. З огляду на це, щоб уникнути зайвих перевірок на наявність помилок на вищому рівні абстракції, у методі SetID доцільно викликати паніку. Важливо подбати, щоб у разі її виникнення вона була коректно оброблена за допомогою recover() і не призвела до зупинки застосунку.

Альтернативним підходом є повернення помилки.

Зміна стану сутностей

Зміна стану сутностей та агрегатів відбувається через їхні методи, а не через сеттери. Це дозволяє чітко відділити поведінкову логіку від логіки ініціалізації самих сутностей.

Для прикладу розглянемо такі операції:

  1. перейменування курсу;
  2. перейменування його розділу.

Для наочності додамо такі умови:

  1. курс не може отримати ту саму назву, що й поточна;
  2. розділ не може отримати ту саму назву, що й поточна;
  3. розділи в межах одного курсу не можуть мати однакову назву.

Враховуючи попередні пропозиції з цієї статті, курс та його розділ матимуть таке відображення в коді:

internal/course/model/course.go

package model

import (
    "errors"
    "time"

    "github.com/google/uuid"

    "examples/internal/course/vo"
    "examples/internal/domain"
)

var (
    ErrSameName  = errors.New("the same name provided")
    ErrNameTaken = errors.New("the name is already in use in this course")
)

type Course struct {
    domain.Aggregate
    name       string
    complexity vo.Complexity
    chapters   []Chapter
}

func (c *Course) Name() string {
    return c.name
}

func (c *Course) Complexity() vo.Complexity {
    return c.complexity
}

func (c *Course) Chapters() []Chapter {
    return c.chapters
}

func (c *Course) Rename(newName string, now time.Time) error {
    if c.name == newName {
        return ErrSameName
    }

    c.name = newName
    return nil
}

func (c *Course) RenameChapter(chapterID uuid.UUID, newName string, now time.Time) error {
    if c.isChapterNameUsed(newName) {
        return ErrNameTaken
    }

    for i := range c.chapters {
        if c.chapters[i].ID() == chapterID {
            return c.renameChapterByIdx(i, newName, now)
        }
    }

    return nil
}

func (c *Course) isChapterNameUsed(name string) bool {
    for i := range c.chapters {
        if c.chapters[i].Name() == name {
            return true
        }
    }

    return false
}

func (c *Course) renameChapterByIdx(idx int, newName string, now time.Time) error {
    previousName := c.chapters[idx].Name()
    if err := c.chapters[idx].rename(newName); err != nil {
        return err
    }

    return nil
}

type Chapter struct {
    domain.Entity
    name string
}

func (c *Chapter) Name() string {
    return c.name
}

func (c *Chapter) rename(newName string) error {
    if c.name == newName {
        return ErrSameName
    }

    c.name = newName
    return nil
}

Створення подій предметної області

Подія — це певний історичний факт, про який доцільно сповістити інші частини системи. Подія є незмінною після свого створення та містить конкретний час, коли вона відбулася.

Розширимо наш базовий агрегат так, щоб він умів працювати з подіями.

internal/domain/aggregate.go

package domain  

import (
    "time"
)

type Event interface {  
    OccurredAt() time.Time  
}

type Aggregate struct {  
    Entity  
    events []Event  
}  

func (a *Aggregate) AddEvent(event Event) {  
    a.events = append(a.events, event)  
}

func (a *Aggregate) Events() []Event {  
    return a.events  
}

Тепер ми можемо додавати події до агрегату кожного разу, коли вони виникають у його методах. Для цього опишемо типи подій.

internal/course/model/events.go

package model  

import (  
    "time"

    "github.com/google/uuid"
)  

type CourseRenamedEvent struct {  
    courseID     uuid.UUID  
    previousName string  
    newName      string  
    occurredAt   time.Time  
}  

func (e CourseRenamedEvent) CourseID() uuid.UUID {  
    return e.courseID  
}  

func (e CourseRenamedEvent) PreviousName() string {  
    return e.previousName  
}  

func (e CourseRenamedEvent) NewName() string {  
    return e.newName  
}  

func (e CourseRenamedEvent) OccurredAt() time.Time {  
    return e.occurredAt  
}  

type ChapterRenamedEvent struct {  
    courseID     uuid.UUID  
    chapterID    uuid.UUID  
    previousName string  
    newName      string  
    occurredAt   time.Time  
}  

func (e ChapterRenamedEvent) CourseID() uuid.UUID {  
    return e.courseID  
}  

func (e ChapterRenamedEvent) ChapterID() uuid.UUID {  
    return e.chapterID  
}  

func (e ChapterRenamedEvent) PreviousName() string {  
    return e.previousName  
}  

func (e ChapterRenamedEvent) NewName() string {  
    return e.newName  
}  

func (e ChapterRenamedEvent) OccurredAt() time.Time {  
    return e.occurredAt  
}

internal/course/model/course.go

// ...
func (c *Course) Rename(newName string, now time.Time) error {  
    if c.name == newName {  
       return ErrSameName  
    }  

    c.AddEvent(CourseRenamedEvent{  
       courseID:     c.ID(),  
       previousName: c.name,  
       newName:      newName,  
       occurredAt:   now,  
    })  

    c.name = newName  
    return nil  
}

func (c *Course) RenameChapter(chapterID uuid.UUID, newName string, now time.Time) error {  
    if c.isChapterNameUsed(newName) {  
       return ErrNameTaken  
    }  

    for i := range c.chapters {  
       if c.chapters[i].ID() == chapterID {  
          return c.renameChapterByIdx(i, newName, now)  
       }  
    }  

    return nil  
}

func (c *Course) renameChapterByIdx(idx int, newName string, now time.Time) error {  
    previousName := c.chapters[idx].Name()  
    if err := c.chapters[idx].rename(newName); err != nil {  
       return err  
    }  

    c.AddEvent(ChapterRenamedEvent{  
       courseID:     c.ID(),  
       chapterID:    c.chapters[idx].ID(),  
       previousName: previousName,  
       newName:      newName,  
       occurredAt:   now,  
    })  

    return nil  
}

// ...

Фіксацію часу, коли відбулася подія, можна робити за допомогою time.Now() безпосередньо в тілі методу або ж передавати час як аргумент під час виклику. Другий варіант суттєво спрощує як юніт-, так і інтеграційне тестування.

У контексті цього прикладу CourseRenamedEvent та ChapterRenamedEvent у майбутньому можуть використовуватися для:

  1. збереження стану сутності у сховищі;
  2. передачі даних чи ініціації певних дій у межах застосунку — через патерн «Спостерігач» (Observer);
  3. передачі даних або запуску дій в інших застосунках — через шину повідомлень;
  4. тощо.

Збереження стану сутностей

Збереження стану доцільно розглянути на прикладі реляційних баз даних. Тут можна піти кількома шляхами:

  1. використовувати об’єктно-реляційне відображення (Object-Relational Mapping, ORM);
  2. вносити зміни на підставі подій агрегата;
  3. зберігати весь агрегат цілком через самописні чи згенеровані SQL-запити тощо.

Оскільки реалізація збереження сутностей у сховищі та їх отримання звідти в першому й третьому випадках значною мірою залежить від конкретної бібліотеки для доступу до БД, пропоную зосередитися на другому підході. Він менш поширений порівняно з іншими, але, на мою думку, заслуговує не меншої уваги. Цей підхід дозволяє точково вносити необхідні зміни до сховища даних і чудово поєднується з шаблоном проєктування «Вихідна скринька» / «Вихідні повідомлення» (Outbox Pattern).

Розглянемо саме цей варіант у контексті використання PostgreSQL та пакета github.com/uptrace/bun для роботи з БД.

Щоб забезпечити внесення змін на підставі подій у межах однієї транзакції, реалізуємо одну з варіацій патерна «Одиниця роботи» (Unit of Work). Це можна зробити за допомогою таких кроків:

  1. Створити менеджер транзакцій — тип, що працюватиме з підключенням до БД та гарантуватиме транзакційність операцій.
  2. Додати до нього метод InTransaction, який виконуватиме передану йому як аргумент функцію в межах однієї транзакції.
  3. У InTransaction перевіряти наявність транзакції в контексті. Якщо вона вже існує — використовувати її для наступних операцій. Якщо ні — створювати нову і надалі працювати з нею.
  4. Додати метод DB, який залежно від наявності транзакції повертатиме або її, або звичайне підключення до БД. Такий підхід дозволяє іншим частинам застосунку (за умови доступу до контексту) читати дані, змінені в межах тієї ж транзакції. Досягти цього можна через патерн «Спостерігач» (Observer), про який піде мова в наступному розділі статті.

Приклад реалізації менеджера транзакцій:

internal/course/outbound/postgres/tx.go

package postgres  

import (  
    "context"  
    "database/sql"
    "errors"
    "fmt"
    "github.com/uptrace/bun"
)  

type txKey struct{}  

func injectTx(ctx context.Context, tx bun.Tx) context.Context {  
    return context.WithValue(ctx, txKey{}, tx)  
}  

func extractTx(ctx context.Context) (bun.Tx, bool) {  
    tx, ok := ctx.Value(txKey{}).(bun.Tx)  

    return tx, ok  
}  

type TransactionManager struct {  
    conn bun.IDB  
}  

func NewTransactionManager(db bun.IDB) *TransactionManager {  
    return &TransactionManager{  
       conn: db,  
    }  
}  

type unitFunc func(ctx context.Context, tx bun.Tx) error  

func (tm *TransactionManager) InTransaction(ctx context.Context, fn unitFunc, opts *sql.TxOptions) error {  
    tx, exists := extractTx(ctx)  
    if exists {  
       return fn(ctx, tx)  
    }  

    return tm.withinTransaction(ctx, opts, fn)  
}  

func (tm *TransactionManager) withinTransaction(ctx context.Context, opts *sql.TxOptions, fn unitFunc) (err error) {
    tx, err := tm.conn.BeginTx(ctx, opts)
    if err != nil {
        return fmt.Errorf("begin transaction: %w", err)
    }

    defer func() {
        if err != nil && !errors.Is(err, sql.ErrTxDone) {
            err = errors.Join(err, tx.Rollback())
        }
    }()

    if err = fn(injectTx(ctx, tx), tx); err != nil {
        return fmt.Errorf("transaction: %w", err)
    }

    if err = tx.Commit(); err != nil && !errors.Is(err, sql.ErrTxDone) {
        return fmt.Errorf("commit transaction: %w", err)
    }

    return nil
}

func (tm *TransactionManager) DB(ctx context.Context) bun.IDB {  
    tx, exists := extractTx(ctx)  
    if exists {  
       return tx  
    }  

    return tm.conn  
}

Тепер опишемо для агрегата «курс» репозиторій — тип, що реалізує однойменний патерн у DDD, — та додамо до нього метод Persist, який відповідатиме за транзакційне збереження змін агрегата.

internal/course/outbound/postgres/course/write.go

package course

import (
    "context"
    "fmt"
    "time"

    "github.com/google/uuid"
    "github.com/uptrace/bun"

    "examples/internal/course/model"
    "examples/internal/course/outbound/postgres"
    "examples/internal/domain"
)

type Repository struct {
    manager    *postgres.TransactionManager
}

func NewRepository(manager *postgres.TransactionManager) Repository {
    return Repository{
        manager:    manager,
    }
}

func (repo *Repository) Persist(ctx context.Context, agg model.Course) error {
    events := agg.Events()
    if len(events) == 0 {
        return nil
    }

    return repo.manager.InTransaction(ctx, func(ctx context.Context, tx bun.Tx) error {
        var updatedAt time.Time
        for i := range events {
            switch event := events[i].(type) {
            case model.CourseCreatedEvent:
                if err := insertCourse(ctx, tx, event); err != nil {
                    return fmt.Errorf("persist CourseCreatedEvent: %w", err)
                }
            case model.CourseRenamedEvent:
                if err := updateCourseName(ctx, tx, event); err != nil {
                    return fmt.Errorf("persist CourseRenamedEvent: %w", err)
                }
            case model.ChapterRenamedEvent:
                if err := updateChapterName(ctx, tx, event); err != nil {
                    return fmt.Errorf("persist ChapterRenamedEvent: %w", err)
                }
                // інші події за потреби
            }

            updatedAt = events[i].OccurredAt()
        }

        if err := incrementVersion(ctx, tx, agg.ID(), agg.Version(), updatedAt); err != nil {
            return fmt.Errorf("increment course version: %w", err)
        }

        agg.Reset()
        return nil
    }, nil)
}

func incrementVersion(ctx context.Context, tx bun.Tx, id uuid.UUID, version int64, updatedAt time.Time) error {
    res, err := tx.NewUpdate().
        Table("courses").
        Set("version = version + 1").
        Set("updated_at = ?", updatedAt).
        Where("id = ?", id).
        Where("version = ?", version).
        Exec(ctx)
    if err != nil {
        return fmt.Errorf("query: %w", err)
    }

    num, err := res.RowsAffected()
    if err != nil {
        return fmt.Errorf("rows affected: %w", err)
    }

    if num == 0 {
        return fmt.Errorf("result: %w", domain.ErrInvalidAggregateVersion)
    }

    return nil
}

func insertCourse(ctx context.Context, tx bun.Tx, event model.CourseCreatedEvent) error {
    panic("implement me")
}

func updateCourseName(ctx context.Context, tx bun.Tx, event model.CourseRenamedEvent) error {
    panic("implement me")
}

func updateChapterName(ctx context.Context, tx bun.Tx, event model.ChapterRenamedEvent) error {
    panic("implement me")
}

Щоб уникнути неконсистентності даних у разі виникнення конкуруючих бізнес-транзакцій, реалізуємо механізм оптимістичного блокування. Таким чином, якщо спробувати записати агрегат зі станом, що конфліктує з уже збереженими даними, виникне помилка, і поточна транзакція буде відхилена.

internal/domain/aggregate.go

package domain

import (
    "errors"
    "time"
)

var ErrInvalidAggregateVersion = errors.New("invalid aggregate version")

type Event interface {
    OccurredAt() time.Time
}

type Aggregate struct {
    Entity
    events  []Event
    version int64
}

func (a *Aggregate) AddEvent(event Event) {
    a.events = append(a.events, event)
}

func (a *Aggregate) Events() []Event {
    return a.events
}

func (a *Aggregate) Reset() {
    a.events = nil
}

func (a *Aggregate) SetVersion(version int64) {
    a.version = version
}

func (a *Aggregate) Version() int64 {
    return a.version
}

Змінимо об’єкт передачі даних Course та його метод toAggregate так, щоб під час зчитування даних з БД агрегат отримував відповідну версію.

internal/course/outbound/postgres/course/dto.go

func (c Course) toAggregate() (model.Course, error) {
    // ...

    course.SetVersion(c.Version)

    return course, nil
}

Взаємодія з іншими предметними областями застосунку

Бувають випадки, коли певні події в одній предметній області можуть ініціювати дії в іншій.

Для прикладу, уявімо, що користувач системи може безкоштовно створити лише обмежену кількість курсів, після чого поява кожного наступного призводитиме до виставлення рахунку. Очевидно, що робота з оплатою виходить за рамки даної предметної області. Для наочності ми розмістимо її у цьому ж застосунку в пакеті internal/billing.

Щоб ініціювати відповідну логіку в billing, створимо відповідний тип події для додавання курсу:

internal/course/model/events.go

type CourseCreatedEvent struct {  
    courseID   uuid.UUID  
    name       string  
    occurredAt time.Time  
}  

func (e CourseCreatedEvent) CourseID() uuid.UUID {  
    return e.courseID  
}  

func (e CourseCreatedEvent) Name() string {  
    return e.name  
}  

func (e CourseCreatedEvent) OccurredAt() time.Time {  
    return e.occurredAt  
}

Тепер необхідно налаштувати механізм підписки, який дозволить стежити за вказаною подією та реагувати на неї.

internal/domain/dispatcher.go

package domain  

import (  
    "context"  
    "errors"
    "fmt"
    "reflect"
)  

var ErrInvalidEventType = errors.New("invalid event type")  

type EventListener interface {  
    Listen(ctx context.Context, event Event) error  
}  

type EventDispatcher struct {  
    listeners map[string][]EventListener  
}  

func NewEventDispatcher() *EventDispatcher {  
    return &EventDispatcher{  
       listeners: make(map[string][]EventListener),  
    }  
}  

func (d *EventDispatcher) MustSubscribe(instance Event, listeners ...EventListener) {  
    for i := range listeners {  
       if listeners[i] == nil {  
          panic("development mistake: nil listener provided")
       }  
    }  

    t := mustDetectType(instance)  
    d.listeners[t] = append(d.listeners[t], listeners...)  
}  

func mustDetectType(e Event) string {  
    if t := detectEventType(e); t != "" {  
       return t  
    }  

    panic("development mistake: unsupported event type")  
}  

func detectEventType(e Event) string {  
    t := reflect.TypeOf(e)  
    if t.Kind() != reflect.Struct {  
       return ""  
    }  

    return t.Name()  
}  

func (d *EventDispatcher) Dispatch(ctx context.Context, events ...Event) error {  
    for i := range events {  
       if err := d.handle(ctx, events[i]); err != nil {  
          return err  
       }  
    }  

    return nil  
}  

func (d *EventDispatcher) handle(ctx context.Context, event Event) error {  
    t := detectEventType(event)  
    for _, h := range d.listeners[t] {  
       if err := h.Listen(ctx, event); err != nil {  
          return fmt.Errorf("handle event %s: %w", t, err)  
       }  
    }  

    return nil  
}

У цьому прикладі для визначення типу подій використано рефлексію. Хоча вона трохи повільніша з точки зору продуктивності, такий підхід значно спрощує підтримку коду.

Альтернативний варіант — додати до інтерфейсу Event окремий метод, який повертатиме конкретний тип події у вигляді рядка:

type EventType string

type Event interface {  
    OccurredAt() time.Time
    Type() EventType
}

Тоді кожен тип події реалізує цей метод, наприклад:

func (e CourseCreatedEvent) Type() EventType {  
    return "CourseCreatedEvent"  
}

Наступним кроком є реалізація підписника, який виконуватиме необхідні дії — зокрема, виставлення рахунку при отриманні повідомлення про створення курсу. Для цього визначимо новий тип CreateBillOnCreateCourse, який реалізовуватиме інтерфейс EventListener. Саме він виступатиме зв’язуючою ланкою між двома предметними областями.

internal/service/listener/create_course.go

package listener  

import (  
    "context"  
    "examples/internal/course/model"
    "examples/internal/domain"
    "fmt"
)  

type BillCreator interface {  
    CreateBill(ctx context.Context, courseName string) error  
}  

type CreateBillOnCreateCourse struct {  
    billCreator BillCreator  
}  

func NewCreateBillOnCreateCourse(billCreator BillCreator) *CreateBillOnCreateCourse {  
    return &CreateBillOnCreateCourse{  
       billCreator: billCreator,  
    }  
}  

func (l *CreateBillOnCreateCourse) Listen(ctx context.Context, input domain.Event) error {  
    event, isValid := input.(model.CourseCreatedEvent)  
    if !isValid {  
       return fmt.Errorf("CreateBillOnCreateCourse: invalid event type: %w", domain.ErrInvalidEventType)  
    }  

    if err := l.billCreator.CreateBill(ctx, event.Name()); err != nil {  
       return fmt.Errorf("CreateBillOnCreateCourse: create bill: %w", err)  
    }  

    return nil  
}

Тепер, коли в нас є всі необхідні елементи (вважатимемо, що у internal/billing/usecase реалізовано інтерфейс BillCreator), їх слід підключити один до одного.

internal/app/events/events.go

package events  

import (  
    "examples/internal/billing/usecase"  
    "examples/internal/course/model"
    "examples/internal/domain"
    "examples/internal/service/listener"
)  

type dummyDependencies struct {  
    billCreator usecase.CreateBill  
}  

func setupEventListeners(dispatcher *domain.EventDispatcher, dd dummyDependencies) {
    dispatcher.MustSubscribe(
        model.CourseCreatedEvent{},
        listener.NewCreateBillOnCreateCourse(dd.billCreator),
    )
}

Залишається лише вчасно повідомляти про цю подію. Це можна зробити, розширивши репозиторій агрегата додаванням диспетчера подій. Якщо відправляти повідомлення в методі Persist в рамках відкритої транзакції, усі підписники цієї події отримають доступ до тієї ж транзакції через контекст. Іншими словами, збереження новоствореного курсу та виставлення рахунку в БД відбувається атомарно.

internal/course/outbound/postgres/course/write.go

package course

import (
    "context"
    "fmt"
    "time"

    "github.com/google/uuid"
    "github.com/uptrace/bun"

    "examples/internal/course/model"
    "examples/internal/course/outbound/postgres"
    "examples/internal/domain"
)

type Repository struct {
    manager    *postgres.TransactionManager
    dispatcher *domain.EventDispatcher
}

func NewRepository(manager *postgres.TransactionManager, dispatcher *domain.EventDispatcher) Repository {
    return Repository{
        manager:    manager,
        dispatcher: dispatcher,
    }
}

func (repo *Repository) Persist(ctx context.Context, agg model.Course) error {
    events := agg.Events()
    if len(events) == 0 {
        return nil
    }

    return repo.manager.InTransaction(ctx, func(ctx context.Context, tx bun.Tx) error {
        // ...

        if err := incrementVersion(ctx, tx, agg.ID(), agg.Version(), updatedAt); err != nil {
            return fmt.Errorf("increment course version: %w", err)
        }

        if err := repo.dispatcher.Dispatch(ctx, events...); err != nil {
            return fmt.Errorf("dispatch course events: %w", err)
        }

        agg.Reset()
        return nil
    }, nil)
}

// ...

Варто зауважити, що описані підходи найдоцільніше застосовувати для великих проєктів зі складною бізнес-логікою. У таких випадках запуск проєкту, ймовірно, потребуватиме більше часу порівняно з використанням анемічних моделей предметної області, але зі зростанням функціональності та розширенням кодової бази їхні переваги стають очевидними — як у контексті спрощення внесення змін до коду та масштубуванні, так і під час написання автоматизованих тестів.

Разом із цим навіть окремо взяті патерни з наведених прикладів можуть стати в нагоді для вирішення технічних та бізнесових задач при розробці застосунків незалежно від масштабів проєкту.

Весь приклад реалізації доступний у репозиторії на GitHub.

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

👍ПодобаєтьсяСподобалось12
До обраногоВ обраному11
LinkedIn
Ctrl + Enter
Ctrl + Enter

Перше враження: це не є «Go way». Стандартна схема, яка реалізується будь-якою мовою.
Для мене девіз Go звучить так — «Stay simple. Be obvious. Don’t be cute, don’t over-design and over-engineer — those are signs you’re doing it wrong.»
Проте чекаю продовження, цікаво що буде далі.

Дякую за відгук!

Стандартна схема, яка реалізується будь-якою мовою.

Дійсно, багатьма сучасними мовами високого рівня.

На мою думку, говорити про «over-engineering» чи «простоту» без урахування конкретних бізнес-проблем — не зовсім коректно. Go чудово підходить для невеликих застосунків, але в деяких випадках навіть великий моноліт на Go може бути більш виправданим рішенням, ніж, наприклад, переучування інженерів чи найм нової команди під іншу технологію.

Просто DDD підхід сам по собі це ще та трешачина. І коли це пробують натягувати на мову, яка дизайнилась для маніпуляції текстовими файлами, то получаємо те, що в статті.

Занадто категоричні твердження, як на мене.

Просто DDD підхід сам по собі це ще та трешачина.

Виглядає так, що йому тоді взагалі немає місця в індустрії. Чи все ж таки в нього є позитивні сторони?

мову, яка дизайнилась для маніпуляції текстовими файлами

Що наштовхнуло на такі думки?

Виглядає так, що йому тоді взагалі немає місця в індустрії. Чи все ж таки в нього є позитивні сторони?

DDD + Event Sourcing/CQRS — дуже добре виглядають разом. В більшості інших випадків DDD пробують натягнути на звичайні CRUD-и та SOA-архітектуру і виходить франкенштейн.

Що наштовхнуло на такі думки?

Роба Пайка буквально запросили для цього із-за його попередніх заслуг. Плюс про це неодноразово говорили сам Пайк, Рас Кокс та інші. Але це одна із багатьох причин.

Підписатись на коментарі