Типобезопасный домен: Гексагональная архитектура на базе Effect Реализация Aggregate через Effect: иммутабельный подход
Глава

Реализация Aggregate через Effect: иммутабельный подход

Философия Aggregate как чистой функции (State, Command) → Effect<(NewState, Events), Error>. Полная реализация: Value Objects, Domain Errors, Domain Events, Child Entity, Aggregate Root с Guard Methods, Factory, Commands и Queries. Паттерн Event Accumulation. Использование в Application Layer. Тестирование.

Философия: Aggregate как чистая функция

В классическом ООП-подходе Aggregate — это объект с мутабельным состоянием:

// ❌ ООП-подход (мутабельный)
class TodoList {
  private items: TodoItem[] = []
  
  addItem(item: TodoItem): void {
    if (this.items.length >= this.maxItems) throw new Error("Full")
    this.items.push(item)  // мутация!
  }
}

В функциональном подходе с Effect-ts Aggregate — это иммутабельная структура данных, каждая операция над которой возвращает новую копию агрегата:

(CurrentState, Command) → Effect<(NewState, Events), DomainError>

Это даёт:

  1. Предсказуемость — состояние не может измениться «из-под ног»
  2. Тестируемость — чистые функции легко тестировать
  3. Безопасность — нет race conditions при доступе к состоянию
  4. Отмена — если транзакция не завершилась, старое состояние всё ещё валидно
  5. Снепшоты — можно хранить предыдущие версии

Архитектура Aggregate в Effect-ts

┌─────────────────────────────────────────────────────────┐
│                    AGGREGATE MODULE                      │
│                                                          │
│  ┌──────────────────┐   ┌────────────────────────────┐  │
│  │   Value Objects   │   │     Domain Errors          │  │
│  │  ─────────────── │   │  ──────────────────────── │  │
│  │  TodoListId       │   │  TodoListFull              │  │
│  │  TodoItemId       │   │  DuplicateTitle            │  │
│  │  TodoTitle        │   │  TodoNotInList             │  │
│  │  ListName         │   │  ListArchived              │  │
│  │  Priority         │   │  InvalidTransition         │  │
│  └──────────────────┘   └────────────────────────────┘  │
│                                                          │
│  ┌──────────────────┐   ┌────────────────────────────┐  │
│  │  Child Entities   │   │     Domain Events          │  │
│  │  ─────────────── │   │  ──────────────────────── │  │
│  │  TodoItem         │   │  TodoItemAdded             │  │
│  │                   │   │  TodoItemCompleted          │  │
│  └──────────────────┘   │  TodoItemRemoved            │  │
│                          │  TodoListArchived           │  │
│  ┌──────────────────┐   └────────────────────────────┘  │
│  │  Aggregate Root   │                                   │
│  │  ─────────────── │                                   │
│  │  TodoList         │                                   │
│  │  (all behavior)   │                                   │
│  └──────────────────┘                                   │
└─────────────────────────────────────────────────────────┘

Полная реализация

Шаг 1: Value Objects

import { Schema } from "effect"

// ── Идентификаторы ──────────────────────────────

class TodoListId extends Schema.TaggedClass<TodoListId>()("TodoListId", {
  value: Schema.String.pipe(Schema.brand("TodoListId"))
}) {}

class TodoItemId extends Schema.TaggedClass<TodoItemId>()("TodoItemId", {
  value: Schema.String.pipe(Schema.brand("TodoItemId"))
}) {}

// ── Доменные значения ───────────────────────────

class TodoTitle extends Schema.TaggedClass<TodoTitle>()("TodoTitle", {
  value: Schema.String.pipe(
    Schema.trimmed(),
    Schema.minLength(1),
    Schema.maxLength(200),
    Schema.brand("TodoTitle")
  )
}) {}

class ListName extends Schema.TaggedClass<ListName>()("ListName", {
  value: Schema.String.pipe(
    Schema.trimmed(),
    Schema.minLength(1),
    Schema.maxLength(100),
    Schema.brand("ListName")
  )
}) {}

const Priority = Schema.Literal("low", "medium", "high", "critical")
type Priority = typeof Priority.Type

const TodoItemStatus = Schema.Literal("active", "completed")
type TodoItemStatus = typeof TodoItemStatus.Type

const TodoListStatus = Schema.Literal("active", "archived")
type TodoListStatus = typeof TodoListStatus.Type

Шаг 2: Domain Errors

import { Schema } from "effect"

// Каждая ошибка — отдельный тип с payload

class TodoListFull extends Schema.TaggedError<TodoListFull>()(
  "TodoListFull",
  {
    todoListId: Schema.String,
    maxSize: Schema.Number,
    currentSize: Schema.Number
  }
) {}

class DuplicateTitle extends Schema.TaggedError<DuplicateTitle>()(
  "DuplicateTitle",
  {
    todoListId: Schema.String,
    title: Schema.String
  }
) {}

class TodoNotInList extends Schema.TaggedError<TodoNotInList>()(
  "TodoNotInList",
  {
    todoListId: Schema.String,
    todoItemId: Schema.String
  }
) {}

class TodoAlreadyCompleted extends Schema.TaggedError<TodoAlreadyCompleted>()(
  "TodoAlreadyCompleted",
  {
    todoItemId: Schema.String
  }
) {}

class ListArchived extends Schema.TaggedError<ListArchived>()(
  "ListArchived",
  {
    todoListId: Schema.String
  }
) {}

class InvalidPriorityChange extends Schema.TaggedError<InvalidPriorityChange>()(
  "InvalidPriorityChange",
  {
    todoItemId: Schema.String,
    currentPriority: Schema.String,
    newPriority: Schema.String,
    reason: Schema.String
  }
) {}

// Union type для удобства в сигнатурах
type TodoListError =
  | TodoListFull
  | DuplicateTitle
  | TodoNotInList
  | TodoAlreadyCompleted
  | ListArchived
  | InvalidPriorityChange

Шаг 3: Domain Events

import { Schema } from "effect"

// Базовая метаинформация для всех событий
const EventMetadata = Schema.Struct({
  occurredAt: Schema.DateFromSelf,
  aggregateId: Schema.String,
  version: Schema.Number
})

class TodoItemAdded extends Schema.TaggedClass<TodoItemAdded>()(
  "TodoItemAdded",
  {
    ...EventMetadata.fields,
    todoItemId: Schema.String,
    title: Schema.String,
    priority: Priority
  }
) {}

class TodoItemCompleted extends Schema.TaggedClass<TodoItemCompleted>()(
  "TodoItemCompleted",
  {
    ...EventMetadata.fields,
    todoItemId: Schema.String,
    completedAt: Schema.DateFromSelf
  }
) {}

class TodoItemRemoved extends Schema.TaggedClass<TodoItemRemoved>()(
  "TodoItemRemoved",
  {
    ...EventMetadata.fields,
    todoItemId: Schema.String
  }
) {}

class TodoItemRenamed extends Schema.TaggedClass<TodoItemRenamed>()(
  "TodoItemRenamed",
  {
    ...EventMetadata.fields,
    todoItemId: Schema.String,
    oldTitle: Schema.String,
    newTitle: Schema.String
  }
) {}

class TodoItemPriorityChanged extends Schema.TaggedClass<TodoItemPriorityChanged>()(
  "TodoItemPriorityChanged",
  {
    ...EventMetadata.fields,
    todoItemId: Schema.String,
    oldPriority: Priority,
    newPriority: Priority
  }
) {}

class TodoListArchivedEvent extends Schema.TaggedClass<TodoListArchivedEvent>()(
  "TodoListArchived",
  {
    ...EventMetadata.fields,
    itemCount: Schema.Number
  }
) {}

class TodoListRenamed extends Schema.TaggedClass<TodoListRenamed>()(
  "TodoListRenamed",
  {
    ...EventMetadata.fields,
    oldName: Schema.String,
    newName: Schema.String
  }
) {}

// Union всех событий
type TodoListEvent =
  | TodoItemAdded
  | TodoItemCompleted
  | TodoItemRemoved
  | TodoItemRenamed
  | TodoItemPriorityChanged
  | TodoListArchivedEvent
  | TodoListRenamed

Шаг 4: Child Entity — TodoItem

import { Schema, Option } from "effect"

class TodoItem extends Schema.Class<TodoItem>("TodoItem")({
  id: TodoItemId,
  title: TodoTitle,
  priority: Priority,
  status: TodoItemStatus,
  createdAt: Schema.DateFromSelf,
  completedAt: Schema.OptionFromSelf(Schema.DateFromSelf)
}) {
  get isActive(): boolean {
    return this.status === "active"
  }

  get isCompleted(): boolean {
    return this.status === "completed"
  }
}

Шаг 5: Результат мутации — новое состояние + события

// Каждая мутация возвращает обновлённый агрегат + список событий
interface MutationResult {
  readonly list: TodoList
  readonly events: ReadonlyArray<TodoListEvent>
}

Шаг 6: Aggregate Root — TodoList

import { Effect, Schema, Option, ReadonlyArray, pipe } from "effect"

class TodoList extends Schema.Class<TodoList>("TodoList")({
  id: TodoListId,
  name: ListName,
  status: TodoListStatus,
  items: Schema.Array(TodoItem),
  maxItems: Schema.Number.pipe(Schema.int(), Schema.positive()),
  ownerId: Schema.String.pipe(Schema.brand("UserId")),
  createdAt: Schema.DateFromSelf,
  updatedAt: Schema.DateFromSelf,
  version: Schema.Number.pipe(Schema.int(), Schema.nonNegative())
}) {

  // ═══════════════════════════════════════════════
  // Guard Methods (предусловия)
  // ═══════════════════════════════════════════════

  private ensureActive(): Effect.Effect<void, ListArchived> {
    return this.status === "archived"
      ? new ListArchived({ todoListId: this.id.value })
      : Effect.void
  }

  private ensureNotFull(): Effect.Effect<void, TodoListFull> {
    return this.items.length >= this.maxItems
      ? new TodoListFull({
          todoListId: this.id.value,
          maxSize: this.maxItems,
          currentSize: this.items.length
        })
      : Effect.void
  }

  private ensureUniqueTitle(
    title: TodoTitle,
    excludeIndex?: number
  ): Effect.Effect<void, DuplicateTitle> {
    const hasDuplicate = this.items.some(
      (item, i) =>
        i !== excludeIndex && item.title.value === title.value
    )
    return hasDuplicate
      ? new DuplicateTitle({
          todoListId: this.id.value,
          title: title.value
        })
      : Effect.void
  }

  private findItemWithIndex(
    itemId: TodoItemId
  ): Effect.Effect<
    { readonly item: TodoItem; readonly index: number },
    TodoNotInList
  > {
    const index = this.items.findIndex(
      (item) => item.id.value === itemId.value
    )
    return index === -1
      ? new TodoNotInList({
          todoListId: this.id.value,
          todoItemId: itemId.value
        })
      : Effect.succeed({ item: this.items[index]!, index } as const)
  }

  // ═══════════════════════════════════════════════
  // Helper: создание обновлённого агрегата
  // ═══════════════════════════════════════════════

  private updated(
    fields: Partial<{
      readonly name: ListName
      readonly status: TodoListStatus
      readonly items: ReadonlyArray<TodoItem>
    }>,
    now: Date
  ): TodoList {
    return new TodoList({
      ...this,
      ...fields,
      updatedAt: now,
      version: this.version + 1
    })
  }

  private eventMeta(now: Date) {
    return {
      occurredAt: now,
      aggregateId: this.id.value,
      version: this.version + 1
    } as const
  }

  // ═══════════════════════════════════════════════
  // Factory Method
  // ═══════════════════════════════════════════════

  static create(params: {
    readonly id: TodoListId
    readonly name: ListName
    readonly ownerId: string & { readonly UserId: unique symbol }
    readonly maxItems?: number
    readonly now: Date
  }): Effect.Effect<TodoList> {
    return Effect.succeed(
      new TodoList({
        id: params.id,
        name: params.name,
        status: "active",
        items: [],
        maxItems: params.maxItems ?? 50,
        ownerId: params.ownerId,
        createdAt: params.now,
        updatedAt: params.now,
        version: 0
      })
    )
  }

  // ═══════════════════════════════════════════════
  // Commands (мутации)
  // ═══════════════════════════════════════════════

  addItem(params: {
    readonly itemId: TodoItemId
    readonly title: TodoTitle
    readonly priority: Priority
    readonly now: Date
  }): Effect.Effect<
    MutationResult,
    TodoListFull | DuplicateTitle | ListArchived
  > {
    return Effect.gen(this, function* () {
      const { itemId, title, priority, now } = params

      // ── Проверка инвариантов ──
      yield* this.ensureActive()
      yield* this.ensureNotFull()
      yield* this.ensureUniqueTitle(title)

      // ── Создание дочерней Entity ──
      const newItem = new TodoItem({
        id: itemId,
        title,
        priority,
        status: "active",
        createdAt: now,
        completedAt: Option.none()
      })

      // ── Новое состояние + событие ──
      return {
        list: this.updated({ items: [...this.items, newItem] }, now),
        events: [
          new TodoItemAdded({
            ...this.eventMeta(now),
            todoItemId: itemId.value,
            title: title.value,
            priority
          })
        ]
      }
    })
  }

  completeItem(params: {
    readonly itemId: TodoItemId
    readonly now: Date
  }): Effect.Effect<
    MutationResult,
    TodoNotInList | TodoAlreadyCompleted | ListArchived
  > {
    return Effect.gen(this, function* () {
      const { itemId, now } = params

      yield* this.ensureActive()

      const { item, index } = yield* this.findItemWithIndex(itemId)

      if (item.isCompleted) {
        return yield* new TodoAlreadyCompleted({
          todoItemId: itemId.value
        })
      }

      const completedItem = new TodoItem({
        ...item,
        status: "completed",
        completedAt: Option.some(now)
      })

      const updatedItems = ReadonlyArray.modify(
        this.items,
        index,
        () => completedItem
      )

      return {
        list: this.updated({ items: updatedItems }, now),
        events: [
          new TodoItemCompleted({
            ...this.eventMeta(now),
            todoItemId: itemId.value,
            completedAt: now
          })
        ]
      }
    })
  }

  removeItem(params: {
    readonly itemId: TodoItemId
    readonly now: Date
  }): Effect.Effect<
    MutationResult,
    TodoNotInList | ListArchived
  > {
    return Effect.gen(this, function* () {
      const { itemId, now } = params

      yield* this.ensureActive()
      yield* this.findItemWithIndex(itemId)

      const filteredItems = this.items.filter(
        (item) => item.id.value !== itemId.value
      )

      return {
        list: this.updated({ items: filteredItems }, now),
        events: [
          new TodoItemRemoved({
            ...this.eventMeta(now),
            todoItemId: itemId.value
          })
        ]
      }
    })
  }

  renameItem(params: {
    readonly itemId: TodoItemId
    readonly newTitle: TodoTitle
    readonly now: Date
  }): Effect.Effect<
    MutationResult,
    TodoNotInList | DuplicateTitle | ListArchived
  > {
    return Effect.gen(this, function* () {
      const { itemId, newTitle, now } = params

      yield* this.ensureActive()

      const { item, index } = yield* this.findItemWithIndex(itemId)

      // Проверка уникальности (исключая текущий элемент)
      yield* this.ensureUniqueTitle(newTitle, index)

      const oldTitle = item.title.value
      const renamedItem = new TodoItem({ ...item, title: newTitle })
      const updatedItems = ReadonlyArray.modify(
        this.items,
        index,
        () => renamedItem
      )

      return {
        list: this.updated({ items: updatedItems }, now),
        events: [
          new TodoItemRenamed({
            ...this.eventMeta(now),
            todoItemId: itemId.value,
            oldTitle,
            newTitle: newTitle.value
          })
        ]
      }
    })
  }

  changePriority(params: {
    readonly itemId: TodoItemId
    readonly newPriority: Priority
    readonly now: Date
  }): Effect.Effect<
    MutationResult,
    TodoNotInList | ListArchived
  > {
    return Effect.gen(this, function* () {
      const { itemId, newPriority, now } = params

      yield* this.ensureActive()

      const { item, index } = yield* this.findItemWithIndex(itemId)

      const oldPriority = item.priority
      const updatedItem = new TodoItem({ ...item, priority: newPriority })
      const updatedItems = ReadonlyArray.modify(
        this.items,
        index,
        () => updatedItem
      )

      return {
        list: this.updated({ items: updatedItems }, now),
        events: [
          new TodoItemPriorityChanged({
            ...this.eventMeta(now),
            todoItemId: itemId.value,
            oldPriority,
            newPriority
          })
        ]
      }
    })
  }

  rename(params: {
    readonly newName: ListName
    readonly now: Date
  }): Effect.Effect<MutationResult, ListArchived> {
    return Effect.gen(this, function* () {
      const { newName, now } = params

      yield* this.ensureActive()

      const oldName = this.name.value

      return {
        list: this.updated({ name: newName }, now),
        events: [
          new TodoListRenamed({
            ...this.eventMeta(now),
            oldName,
            newName: newName.value
          })
        ]
      }
    })
  }

  archive(now: Date): Effect.Effect<MutationResult, ListArchived> {
    return Effect.gen(this, function* () {
      yield* this.ensureActive()

      return {
        list: this.updated({ status: "archived" }, now),
        events: [
          new TodoListArchivedEvent({
            ...this.eventMeta(now),
            itemCount: this.items.length
          })
        ]
      }
    })
  }

  // ═══════════════════════════════════════════════
  // Queries (чистые функции, без Effect)
  // ═══════════════════════════════════════════════

  get activeItems(): ReadonlyArray<TodoItem> {
    return this.items.filter((item) => item.isActive)
  }

  get completedItems(): ReadonlyArray<TodoItem> {
    return this.items.filter((item) => item.isCompleted)
  }

  get activeItemCount(): number {
    return this.activeItems.length
  }

  get completedItemCount(): number {
    return this.completedItems.length
  }

  get isEmpty(): boolean {
    return this.items.length === 0
  }

  get isFull(): boolean {
    return this.items.length >= this.maxItems
  }

  get isArchived(): boolean {
    return this.status === "archived"
  }

  get completionPercentage(): number {
    return this.items.length === 0
      ? 0
      : Math.round((this.completedItemCount / this.items.length) * 100)
  }

  findItem(itemId: TodoItemId): Option.Option<TodoItem> {
    return Option.fromNullable(
      this.items.find((item) => item.id.value === itemId.value)
    )
  }

  itemsByPriority(priority: Priority): ReadonlyArray<TodoItem> {
    return this.items.filter((item) => item.priority === priority)
  }
}

Использование в Application Layer

Вот как агрегат используется в Application Service:

import { Effect, Context } from "effect"

// ── Порт (Repository) ──
class TodoListRepository extends Context.Tag("TodoListRepository")<
  TodoListRepository,
  {
    readonly findById: (id: TodoListId) => Effect.Effect<TodoList, TodoListNotFound>
    readonly save: (list: TodoList) => Effect.Effect<void>
  }
>() {}

// ── Порт (Event Publisher) ──
class EventPublisher extends Context.Tag("EventPublisher")<
  EventPublisher,
  {
    readonly publishAll: (events: ReadonlyArray<TodoListEvent>) => Effect.Effect<void>
  }
>() {}

// ── Порт (Clock) ──
class Clock extends Context.Tag("Clock")<
  Clock,
  { readonly now: Effect.Effect<Date> }
>() {}

// ── Application Service (Use Case) ──
const addTodoItem = (params: {
  readonly listId: TodoListId
  readonly itemId: TodoItemId
  readonly title: TodoTitle
  readonly priority: Priority
}): Effect.Effect<
  TodoList,
  TodoListNotFound | TodoListFull | DuplicateTitle | ListArchived,
  TodoListRepository | EventPublisher | Clock
> =>
  Effect.gen(function* () {
    const repo = yield* TodoListRepository
    const publisher = yield* EventPublisher
    const clock = yield* Clock

    // 1. Загрузить агрегат
    const list = yield* repo.findById(params.listId)

    // 2. Выполнить операцию (проверка инвариантов внутри)
    const now = yield* clock.now
    const { list: updatedList, events } = yield* list.addItem({
      itemId: params.itemId,
      title: params.title,
      priority: params.priority,
      now
    })

    // 3. Сохранить агрегат
    yield* repo.save(updatedList)

    // 4. Опубликовать события
    yield* publisher.publishAll(events)

    return updatedList
  })

Паттерн: Event Accumulation

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

import { Effect, pipe } from "effect"

// Batch-операция: завершить все активные задачи
const completeAllItems = (
  list: TodoList,
  now: Date
): Effect.Effect<
  MutationResult,
  ListArchived
> =>
  Effect.gen(function* () {
    yield* list.ensureActive()

    const activeItems = list.activeItems
    let currentList = list
    const allEvents: TodoListEvent[] = []

    for (const item of activeItems) {
      const result = yield* currentList.completeItem({
        itemId: item.id,
        now
      })
      currentList = result.list
      allEvents.push(...result.events)
    }

    return { list: currentList, events: allEvents }
  })

Тестирование агрегата

import { describe, it, expect } from "bun:test"
import { Effect, Option } from "effect"

const runTest = <A, E>(effect: Effect.Effect<A, E>) =>
  Effect.runPromise(Effect.either(effect))

describe("TodoList Aggregate", () => {
  const now = new Date("2025-01-01T00:00:00Z")
  const listId = new TodoListId({ value: "list-1" as any })
  const itemId1 = new TodoItemId({ value: "item-1" as any })

  const createList = () =>
    Effect.runSync(
      TodoList.create({
        id: listId,
        name: new ListName({ value: "Shopping" as any }),
        ownerId: "user-1" as any,
        maxItems: 3,
        now
      })
    )

  it("should add item to empty list", async () => {
    const list = createList()
    const title = new TodoTitle({ value: "Buy milk" as any })

    const result = await runTest(
      list.addItem({ itemId: itemId1, title, priority: "medium", now })
    )

    expect(result._tag).toBe("Right")
    if (result._tag === "Right") {
      expect(result.right.list.items.length).toBe(1)
      expect(result.right.list.version).toBe(1)
      expect(result.right.events.length).toBe(1)
      expect(result.right.events[0]!._tag).toBe("TodoItemAdded")
    }
  })

  it("should reject when list is full", async () => {
    // Создаём список с maxItems=3 и добавляем 3 элемента
    let list = createList()
    for (let i = 0; i < 3; i++) {
      const { list: updated } = await Effect.runPromise(
        list.addItem({
          itemId: new TodoItemId({ value: `item-${i}` as any }),
          title: new TodoTitle({ value: `Task ${i}` as any }),
          priority: "low",
          now
        })
      )
      list = updated
    }

    // Четвёртый элемент должен быть отклонён
    const result = await runTest(
      list.addItem({
        itemId: new TodoItemId({ value: "item-extra" as any }),
        title: new TodoTitle({ value: "Extra" as any }),
        priority: "low",
        now
      })
    )

    expect(result._tag).toBe("Left")
    if (result._tag === "Left") {
      expect(result.left._tag).toBe("TodoListFull")
    }
  })

  it("should complete an active item", async () => {
    const list = createList()
    const title = new TodoTitle({ value: "Buy milk" as any })

    const { list: withItem } = await Effect.runPromise(
      list.addItem({ itemId: itemId1, title, priority: "medium", now })
    )

    const completedAt = new Date("2025-01-02T00:00:00Z")
    const result = await runTest(
      withItem.completeItem({ itemId: itemId1, now: completedAt })
    )

    expect(result._tag).toBe("Right")
    if (result._tag === "Right") {
      const item = result.right.list.items[0]!
      expect(item.status).toBe("completed")
      expect(Option.isSome(item.completedAt)).toBe(true)
      expect(result.right.events[0]!._tag).toBe("TodoItemCompleted")
    }
  })

  it("should maintain immutability", async () => {
    const list = createList()
    const title = new TodoTitle({ value: "Buy milk" as any })

    const { list: withItem } = await Effect.runPromise(
      list.addItem({ itemId: itemId1, title, priority: "medium", now })
    )

    // Оригинальный список НЕ изменился!
    expect(list.items.length).toBe(0)
    expect(list.version).toBe(0)

    // Новый список имеет элемент
    expect(withItem.items.length).toBe(1)
    expect(withItem.version).toBe(1)
  })
})

Резюме

Реализация Aggregate в Effect-ts следует функциональным принципам:

  1. Иммутабельность — каждая мутация возвращает новый объект
  2. Typed Errors — каждое нарушение инварианта — типизированная ошибка в E-канале
  3. Domain Events — результат мутации = (NewState, Events)
  4. Guard Methods — переиспользуемые проверки предусловий
  5. Factory Methods — корректная инициализация через статические методы
  6. Pure Queries — чтение состояния через чистые функции без Effect

Агрегат не зависит от инфраструктуры (R = never в методах). Все зависимости (Repository, EventPublisher, Clock) подключаются на уровне Application Service, что полностью соответствует принципам гексагональной архитектуры.