Типобезопасный домен: Гексагональная архитектура на базе Effect Почему Ports & Adapters — лучший выбор для Effect
Глава

Почему Ports & Adapters — лучший выбор для Effect

Кульминация модуля: почему именно Hexagonal Architecture естественно ложится на Effect-ts. Структурное соответствие Context.Tag = Port, Layer = Adapter, R-канал = Dependency Rule — это не метафора, а прямое отображение. Мы покажем, что Effect-проекты уже являются гексагональными, даже если авторы об этом не знают.

Центральный тезис

Когда мы говорим «Effect-ts хорошо подходит для Hexagonal Architecture», мы не имеем в виду, что Effect — удобный инструмент, который можно использовать для реализации гексагона. Мы имеем в виду нечто более глубокое:

Effect-ts содержит встроенную реализацию Ports & Adapters паттерна на уровне системы типов. Context.Tag — это Port. Layer — это Adapter. R-канал в Effect<A, E, R> — это Dependency Rule, проверяемый компилятором.

Это прямое структурное соответствие, а не метафора. Каждый раз, когда вы определяете Context.Tag — вы определяете порт. Каждый раз, когда вы создаёте Layer — вы реализуете адаптер. Каждый раз, когда компилятор требует Effect.provide — он проверяет Dependency Rule.


Соответствие 1: Context.Tag = Port

Что такое Port в Hexagonal Architecture?

Порт — это типизированный контракт между ядром приложения и внешним миром. Он определяет:

  • Какие операции доступны
  • Какие данные принимаются и возвращаются
  • Какие ошибки возможны

Порт не содержит реализации. Он говорит что нужно, но не как это сделать.

Что такое Context.Tag в Effect?

Context.Tag — это типизированный идентификатор сервиса, который определяет:

  • Какие методы доступны (через Shape)
  • Какие типы принимаются и возвращаются
  • Какие ошибки могут произойти (через E в Effect)

Context.Tag не содержит реализации. Он определяет что сервис предоставляет, но не как.

Прямое соответствие

import { Effect, Context } from "effect"

// ═══════════════════════════════════════════════════
// Hexagonal: "Порт TodoRepository определяет контракт
//             для персистентности задач"
//
// Effect:    "Tag TodoRepository определяет сервис
//             для персистентности задач"
//
// ЭТО ОДНО И ТО ЖЕ.
// ═══════════════════════════════════════════════════

/** 
 * Это ПОРТ в терминах Hexagonal Architecture.
 * Это TAG в терминах Effect-ts.
 * Один объект — две роли, потому что роли совпадают.
 */
class TodoRepository extends Context.Tag("@app/TodoRepository")<
  TodoRepository,
  {
    readonly findById: (id: string) => Effect.Effect<Todo | null, RepositoryError>
    readonly findAll: () => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
    readonly save: (todo: Todo) => Effect.Effect<void, RepositoryError>
    readonly delete: (id: string) => Effect.Effect<void, RepositoryError>
  }
>() {}

/**
 * Ещё один порт / Tag.
 * Определяет контракт уведомлений — без привязки к email, push, SMS.
 */
class NotificationService extends Context.Tag("@app/NotificationService")<
  NotificationService,
  {
    readonly send: (userId: string, message: string) => Effect.Effect<void, NotificationError>
  }
>() {}

/**
 * Порт для детерминированного времени.
 * В продакшене — системные часы. В тестах — фиксированная дата.
 */
class Clock extends Context.Tag("@app/Clock")<
  Clock,
  {
    readonly now: () => Effect.Effect<Date>
  }
>() {}

Обратите внимание: ни один из этих тегов не содержит ни строчки реализации. Они описывают контракт — именно то, что делает Port в Hexagonal Architecture.


Соответствие 2: Layer = Adapter

Что такое Adapter в Hexagonal Architecture?

Адаптер — это конкретная реализация порта для определённой технологии. Для одного порта может быть несколько адаптеров:

  • TodoRepositoryPortSqliteTodoAdapter, InMemoryTodoAdapter, PostgresTodoAdapter
  • NotificationPortEmailAdapter, SlackAdapter, ConsoleAdapter

Что такое Layer в Effect?

Layer — это конкретная реализация Tag (сервиса). Для одного Tag может быть несколько Layer:

  • TodoRepository Tag → SqliteTodoLayer, InMemoryTodoLayer, PostgresTodoLayer
  • NotificationService Tag → EmailLayer, SlackLayer, ConsoleLayer

Прямое соответствие

import { Effect, Layer } from "effect"

// ═══════════════════════════════════════════════════
// Hexagonal: "Адаптер InMemoryTodoRepository реализует
//             порт TodoRepository для хранения в памяти"
//
// Effect:    "Layer InMemoryTodoRepo реализует
//             Tag TodoRepository для хранения в памяти"
//
// ЭТО ОДНО И ТО ЖЕ.
// ═══════════════════════════════════════════════════

// Адаптер 1: InMemory (для тестов и прототипирования)
const InMemoryTodoRepo = Layer.sync(TodoRepository, () => {
  const store = new Map<string, Todo>()

  return {
    findById: (id) => Effect.sync(() => store.get(id) ?? null),
    findAll: () => Effect.sync(() => [...store.values()]),
    save: (todo) => Effect.sync(() => { store.set(todo.id, todo) }),
    delete: (id) => Effect.sync(() => { store.delete(id) }),
  }
})

// Адаптер 2: SQLite (для продакшена)
const SqliteTodoRepo = Layer.scoped(
  TodoRepository,
  Effect.gen(function* () {
    const db = yield* SqliteClient  // зависимость адаптера от другого сервиса
    
    return {
      findById: (id) =>
        Effect.try({
          try: () => {
            const row = db.query("SELECT * FROM todos WHERE id = ?").get(id)
            return row ? mapRowToTodo(row as TodoRow) : null
          },
          catch: (error) => new RepositoryError({ cause: error }),
        }),
      
      findAll: () =>
        Effect.try({
          try: () => {
            const rows = db.query("SELECT * FROM todos ORDER BY created_at DESC").all()
            return (rows as ReadonlyArray<TodoRow>).map(mapRowToTodo)
          },
          catch: (error) => new RepositoryError({ cause: error }),
        }),
      
      save: (todo) =>
        Effect.try({
          try: () => {
            db.query(
              `INSERT OR REPLACE INTO todos (id, title, status, priority, created_at, completed_at)
               VALUES ($id, $title, $status, $priority, $created, $completed)`
            ).run({
              $id: todo.id,
              $title: todo.title,
              $status: todo.status,
              $priority: todo.priority,
              $created: todo.createdAt.toISOString(),
              $completed: todo.completedAt?.toISOString() ?? null,
            })
          },
          catch: (error) => new RepositoryError({ cause: error }),
        }),
      
      delete: (id) =>
        Effect.try({
          try: () => { db.query("DELETE FROM todos WHERE id = ?").run(id) },
          catch: (error) => new RepositoryError({ cause: error }),
        }),
    }
  })
)

// Адаптер 3: Console Logger (для отладки — логирует все операции)
const LoggingTodoRepo = Layer.effect(
  TodoRepository,
  Effect.gen(function* () {
    const inner = yield* TodoRepository  // декорирует другой адаптер
    
    return {
      findById: (id) =>
        Effect.tap(
          inner.findById(id),
          (result) => Effect.log(`findById(${id}) => ${result ? "found" : "null"}`)
        ),
      findAll: () =>
        Effect.tap(
          inner.findAll(),
          (todos) => Effect.log(`findAll() => ${todos.length} todos`)
        ),
      save: (todo) =>
        Effect.tap(
          inner.save(todo),
          () => Effect.log(`save(${todo.id}: "${todo.title}")`)
        ),
      delete: (id) =>
        Effect.tap(
          inner.delete(id),
          () => Effect.log(`delete(${id})`)
        ),
    }
  })
)

Три адаптера (Layer) для одного порта (Tag). Замена адаптера — одна строка в точке сборки:

// Для тестов:
const testProgram = createTodo("Test").pipe(Effect.provide(InMemoryTodoRepo))

// Для продакшена:
const prodProgram = createTodo("Prod").pipe(Effect.provide(SqliteTodoRepo))

// Бизнес-логика (createTodo) НЕ ИЗМЕНИЛАСЬ.

Соответствие 3: R-канал = Dependency Rule

Что такое Dependency Rule?

Dependency Rule в Hexagonal Architecture гласит: бизнес-логика определяет свои зависимости через порты (интерфейсы), а конкретные реализации предоставляются снаружи. Бизнес-логика не знает, какой адаптер будет использоваться.

Что такое R-канал в Effect?

Effect<A, E, R> — тип с тремя параметрами:

  • A — тип результата (что возвращает)
  • E — тип ошибки (что может пойти не так)
  • R — тип требований (от чего зависит)

R-канал — это множество сервисов (портов), от которых зависит вычисление. Компилятор отслеживает эти зависимости и не позволяет запустить программу, пока все зависимости не будут предоставлены.

Прямое соответствие

// Use Case: создание задачи
// R-канал ЯВНО показывает зависимости (порты)
const createTodo = (
  title: string,
  priority: Priority
): Effect.Effect<
  Todo,                                          // A: результат
  ValidationError | RepositoryError,             // E: возможные ошибки
  TodoRepository | Clock                         // R: ЗАВИСИМОСТИ = ПОРТЫ
> =>
  Effect.gen(function* () {
    // Получение сервисов через порты
    const repo = yield* TodoRepository
    const clock = yield* Clock
    
    // Доменная валидация
    if (title.trim().length === 0) {
      return yield* Effect.fail(new ValidationError({ message: "Empty title" }))
    }
    
    // Создание доменного объекта
    const now = yield* clock.now()
    const todo: Todo = {
      id: crypto.randomUUID(),
      title: title.trim(),
      status: "pending",
      priority,
      createdAt: now,
      completedAt: null,
    }
    
    // Сохранение через порт
    yield* repo.save(todo)
    
    return todo
  })

// Тип createTodo: Effect<Todo, ValidationError | RepositoryError, TodoRepository | Clock>
//                                                                  ^^^^^^^^^^^^^^^^^^^^^^^^^
//                                                                  R-канал = ПОРТЫ
//                                                                  = Dependency Rule

Компилятор как enforcement-механизм Dependency Rule:

// ❌ НЕ СКОМПИЛИРУЕТСЯ: R-канал не пуст
// Type 'TodoRepository | Clock' is not assignable to type 'never'
Effect.runPromise(createTodo("Buy milk", "medium"))

// ❌ НЕ СКОМПИЛИРУЕТСЯ: предоставлен только один из двух портов
// Type 'Clock' is not assignable to type 'never'
const partial = createTodo("Buy milk", "medium").pipe(
  Effect.provide(InMemoryTodoRepo)
)
Effect.runPromise(partial)

// ✅ СКОМПИЛИРУЕТСЯ: все порты предоставлены, R = never
const complete = createTodo("Buy milk", "medium").pipe(
  Effect.provide(Layer.merge(InMemoryTodoRepo, TestClock))
)
Effect.runPromise(complete)  // R = never → можно запускать

Это уникальное свойство Effect-ts: Dependency Rule проверяется компилятором. Ни один из традиционных подходов (Clean, Onion, Hexagonal без Effect) не даёт такой гарантии. Там зависимости проверяются в runtime (DI-контейнер бросит исключение, если что-то не зарегистрировано).


Соответствие 4: Effect.provide = Adapter Wiring

Что такое Wiring в Hexagonal Architecture?

Wiring (сборка) — это процесс подключения адаптеров к портам при запуске приложения. Обычно это происходит в main.ts или Composition Root:

Port: TodoRepository  →  Adapter: SqliteTodoRepository
Port: NotificationService  →  Adapter: EmailNotificationAdapter
Port: Clock  →  Adapter: SystemClock

Что такое Effect.provide в Effect?

Effect.provide — это операция, которая подключает Layer (адаптер) к Effect-программе, удовлетворяя зависимости из R-канала:

import { Effect, Layer } from "effect"

// ═══════════════════════════════════════════════════
// Composition Root = Layer сборка
// ═══════════════════════════════════════════════════

// Отдельные адаптеры
const SqliteClientLive = Layer.scoped(SqliteClient, /* ... */)
const SqliteTodoRepoLive = Layer.effect(TodoRepository, /* ... */)
const SystemClockLive = Layer.succeed(Clock, { now: () => Effect.sync(() => new Date()) })
const EmailNotificationLive = Layer.effect(NotificationService, /* ... */)

// Граф зависимостей — автоматическое разрешение
const AppLayer = Layer.mergeAll(
  SqliteTodoRepoLive,
  SystemClockLive,
  EmailNotificationLive
).pipe(
  Layer.provide(SqliteClientLive)  // SqliteTodoRepo зависит от SqliteClient
)

// Запуск: все порты → все адаптеры → R = never
const main = createTodo("Buy milk", "medium").pipe(
  Effect.provide(AppLayer)
)

Effect.runPromise(main)  // ✅ R = never, все зависимости удовлетворены


// ═══════════════════════════════════════════════════
// Для тестов — ДРУГАЯ сборка, ТОТ ЖЕ Use Case
// ═══════════════════════════════════════════════════

const TestLayer = Layer.mergeAll(
  InMemoryTodoRepo,
  Layer.succeed(Clock, { now: () => Effect.succeed(new Date("2024-01-01")) }),
  Layer.succeed(NotificationService, { send: () => Effect.void })
)

const testMain = createTodo("Buy milk", "medium").pipe(
  Effect.provide(TestLayer)
)

Effect.runPromise(testMain)  // ✅ Тот же Use Case, другие адаптеры

Соответствие 5: E-канал = Error Contract

Ошибки как часть контракта порта

В Hexagonal Architecture порт определяет не только успешные операции, но и возможные ошибки. Если порт TodoRepository может выдать RepositoryError, это часть контракта.

В Effect ошибки — часть типа Effect<A, E, R>, где E — типизированный канал ошибок:

// Доменные ошибки
class ValidationError extends Data.TaggedError("ValidationError")<{
  readonly message: string
}> {}

class TodoNotFound extends Data.TaggedError("TodoNotFound")<{
  readonly id: string
}> {}

class InvalidTransition extends Data.TaggedError("InvalidTransition")<{
  readonly from: TodoStatus
  readonly to: TodoStatus
}> {}

// Инфраструктурные ошибки
class RepositoryError extends Data.TaggedError("RepositoryError")<{
  readonly cause: unknown
}> {}

// Use Case с полным контрактом ошибок
const completeTodo = (
  id: string
): Effect.Effect<
  Todo,                                                    // A
  TodoNotFound | InvalidTransition | RepositoryError,      // E — контракт ошибок
  TodoRepository                                           // R — контракт зависимостей
> =>
  Effect.gen(function* () {
    const repo = yield* TodoRepository
    const existing = yield* repo.findById(id)

    if (existing === null) {
      return yield* Effect.fail(new TodoNotFound({ id }))
    }

    const updated = transitionStatus(existing, "completed")
    if (updated === null) {
      return yield* Effect.fail(
        new InvalidTransition({ from: existing.status, to: "completed" })
      )
    }

    yield* repo.save(updated)
    return updated
  })

Адаптер (HTTP контроллер) может маппить доменные ошибки в HTTP-коды:

// Driving Adapter: HTTP → доменные ошибки → HTTP статус-коды
const handleCompleteTodo = (id: string) =>
  completeTodo(id).pipe(
    Effect.catchTags({
      TodoNotFound: (e) =>
        Effect.succeed(new Response(JSON.stringify({ error: `Todo ${e.id} not found` }), { status: 404 })),
      InvalidTransition: (e) =>
        Effect.succeed(new Response(JSON.stringify({ error: `Cannot transition from ${e.from} to ${e.to}` }), { status: 409 })),
      RepositoryError: (_e) =>
        Effect.succeed(new Response(JSON.stringify({ error: "Internal error" }), { status: 500 })),
    }),
    Effect.map((todo) =>
      new Response(JSON.stringify(todo), { status: 200, headers: { "Content-Type": "application/json" } })
    )
  )

Почему НЕ Clean Architecture для Effect?

Clean Architecture вводит концепции, которые не нужны при использовании Effect:

Input/Output Boundary → не нужен

Effect-программа уже является контрактом с чётко определёнными входами (параметры), выходами (A-канал) и ошибками (E-канал):

// Clean Architecture: нужны два интерфейса
interface CreateTodoInputBoundary {
  execute(input: CreateTodoInput): Promise<void>
}
interface CreateTodoOutputBoundary {
  presentSuccess(output: CreateTodoOutput): void
}

// Effect: один тип содержит ВСЕ
type CreateTodo = (
  input: CreateTodoInput
) => Effect.Effect<CreateTodoOutput, CreateTodoError, TodoRepository>
//                 ^^^^^^^^^^^^^^^^  ^^^^^^^^^^^^^^^  ^^^^^^^^^^^^^^
//                 Output (A)        Errors (E)       Dependencies (R)

Presenter → не нужен

Effect позволяет трансформировать результат с помощью Effect.map на любом уровне:

// Clean: Presenter — отдельный объект
class HttpPresenter implements CreateTodoOutputBoundary {
  presentSuccess(output: CreateTodoOutput) { /* форматирование */ }
}

// Effect: map/tap на уровне адаптера
const httpHandler = createTodo(input).pipe(
  Effect.map(todoToHttpResponse),       // «presenter» — одна строка
  Effect.catchAll(errorToHttpResponse)   // обработка ошибок — тоже
)

Interactor class → не нужен

В Clean Architecture Use Case — это класс Interactor. В Effect Use Case — это функция, возвращающая Effect:

// Clean: класс с конструктором для DI
class CreateTodoInteractor implements CreateTodoInputBoundary {
  constructor(
    private readonly repo: TodoRepository,
    private readonly presenter: CreateTodoOutputBoundary
  ) {}
  async execute(input: CreateTodoInput): Promise<void> { /* ... */ }
}

// Effect: функция, зависимости в R-канале
const createTodo = (input: CreateTodoInput) =>
  Effect.gen(function* () {
    const repo = yield* TodoRepository  // DI через R-канал
    // ... логика
  })
// Нет классов, нет конструкторов, нет ручного DI

Почему НЕ Onion Architecture для Effect?

Onion Architecture ближе к Effect, но имеет одно ключевое отличие:

Explicit Domain Services layer → не нужен как отдельный слой

В Onion Architecture Domain Services — отдельный слой между Domain Model и Application Services. В Effect Domain Services — это обычные чистые функции, которые не требуют отдельного архитектурного слоя:

// Onion: Domain Services — отдельный слой с DI
class TodoPrioritizer {
  constructor(private readonly config: PrioritizerConfig) {}
  prioritize(todos: Todo[]): Todo[] { /* ... */ }
}

// Effect: Domain Services — просто функции
// Если нужна конфигурация — она в R-канале
const prioritize = (
  todos: ReadonlyArray<Todo>
): ReadonlyArray<Todo> =>
  [...todos].sort(/* чистая сортировка */)

// Если нужна зависимость — она явна в типе
const prioritizeWithConfig = (
  todos: ReadonlyArray<Todo>
): Effect.Effect<ReadonlyArray<Todo>, never, PrioritizerConfig> =>
  Effect.gen(function* () {
    const config = yield* PrioritizerConfig
    return [...todos].sort(/* сортировка с учётом config */)
  })

Сводная таблица: Effect ↔ Hexagonal

Hexagonal ArchitectureEffect-tsРоль
PortContext.TagТипизированный контракт
AdapterLayerКонкретная реализация контракта
Driving PortTag для входных операций (Use Cases)API приложения
Driven PortTag для инфраструктуры (Repository, etc.)Зависимости приложения
Driving AdapterHTTP Router, CLI handlerВнешний клиент
Driven AdapterLayer для SQLite, FS, APIИнфраструктура
Application CoreEffect-программы (Effect.gen)Бизнес-логика
Dependency RuleR-канал + Effect.provideПроверка зависимостей
Adapter WiringLayer.mergeAll + Effect.provideСборка приложения
Error ContractE-канал в Effect<A, E, R>Контракт ошибок порта
Adapter SwapЗамена Layer в provideПодмена технологии
Compilation checkR extends neverDependency Rule enforced

Гарантии, которые не даёт ни одна другая архитектура

1. Compile-time Dependency Rule

Если вы забыли подключить адаптер — код не скомпилируется:

const program = createTodo("Buy milk", "medium")
// Тип: Effect<Todo, Error, TodoRepository | Clock>

Effect.runPromise(program)
// ❌ Compilation Error:
// Argument of type 'Effect<Todo, Error, TodoRepository | Clock>'
// is not assignable to parameter of type 'Effect<Todo, Error, never>'
//   Type 'TodoRepository | Clock' is not assignable to type 'never'

В Spring, NestJS и других DI-фреймворках вы узнаете о забытой зависимости в runtime. В Effect — в IDE, до запуска.

2. Type-safe Error Propagation

Ошибки не теряются и не становятся unknown:

// Тип ПОЛНОСТЬЮ описывает, что может пойти не так
const result: Effect.Effect<
  Todo,
  TodoNotFound | InvalidTransition | RepositoryError,
  TodoRepository
> = completeTodo("123")

// Компилятор заставит обработать ВСЕ возможные ошибки
result.pipe(
  Effect.catchTags({
    TodoNotFound: (e) => /* обязательно */,
    InvalidTransition: (e) => /* обязательно */,
    RepositoryError: (e) => /* обязательно */,
  })
)

3. Automatic Dependency Graph Resolution

Effect автоматически разрешает граф зависимостей между Layer:

// SqliteTodoRepo зависит от SqliteClient
// SqliteClient зависит от Config
// Effect разрешает это автоматически

const AppLayer = SqliteTodoRepo.pipe(
  Layer.provide(SqliteClientLive),
  Layer.provide(ConfigLive)
)
// Порядок не важен — Effect разберётся

4. Resource Safety

Адаптеры, которые управляют ресурсами (соединения с БД, файловые дескрипторы), гарантированно закрываются:

const SqliteClientLive = Layer.scoped(
  SqliteClient,
  Effect.acquireRelease(
    // Acquire: открыть соединение
    Effect.sync(() => new Database("todos.sqlite")),
    // Release: ГАРАНТИРОВАННО закрыть
    (db) => Effect.sync(() => db.close())
  )
)
// close() вызовется ВСЕГДА, даже при ошибке или прерывании

Финальная картина: Hexagonal Architecture на Effect

                    DRIVING SIDE                           DRIVEN SIDE
              (Context.Tag = Driving Port)           (Context.Tag = Driven Port)

         ┌─────────────────────────┐           ┌─────────────────────────┐
         │    HTTP Adapter         │           │   SQLite Adapter        │
         │    (Bun.serve router)   │           │   (Layer.scoped)        │
         │                         │           │                         │
         │  Layer: HttpServerLive  │           │  Layer: SqliteTodoRepo  │
         └──────────┬──────────────┘           └──────────▲──────────────┘
                    │                                      │
                    │ calls                                │ implements
                    │                                      │
         ┌──────────▼──────────────────────────────────────┤
         │                                                  │
         │            APPLICATION CORE                      │
         │                                                  │
         │  ┌─────────────────────────────────────────┐    │
         │  │  Use Cases (Effect.gen functions)        │    │
         │  │                                          │    │
         │  │  createTodo: Effect<Todo, E, R>          │    │
         │  │  completeTodo: Effect<Todo, E, R>        │    │
         │  │  listTodos: Effect<Todo[], E, R>         │    │
         │  └──────────────────────────────────────────┘    │
         │                                                  │
         │  ┌─────────────────────────────────────────┐    │
         │  │  Domain Model (pure types & functions)   │    │
         │  │                                          │    │
         │  │  Todo, Priority, TodoStatus              │    │
         │  │  completeTodo, transitionStatus           │    │
         │  │  ValidationError, TodoNotFound            │    │
         │  └──────────────────────────────────────────┘    │
         │                                                  │
         │  R-канал = [TodoRepository, Clock, ...]          │
         │  E-канал = [TodoNotFound, InvalidTransition, ...]│
         │                                                  │
         └──────────────────────────────────────────────────┘

              Effect.provide(AppLayer) — WIRING
              Compiler checks R = never — DEPENDENCY RULE

Ключевые выводы

  1. Context.Tag = Port — не метафора, а прямое структурное соответствие. Оба определяют типизированный контракт без реализации.

  2. Layer = Adapter — конкретная реализация контракта для конкретной технологии. Замена Layer = замена адаптера.

  3. R-канал = Dependency Rule — компилятор гарантирует, что все зависимости (порты) удовлетворены. Это сильнее, чем runtime DI.

  4. E-канал = Error Contract — ошибки как часть контракта порта, типизированные и исчерпывающие.

  5. Clean Architecture избыточна для Effect: Presenter, Output Boundary, Interactor class — всё это заменяется Effect.map, Effect.catchTag и обычными функциями.

  6. Onion Architecture близка, но её явное разделение Domain Services не нужно как отдельный слой в Effect.

  7. Hexagonal Architecture — естественный выбор для Effect, потому что Effect уже реализует Ports & Adapters на уровне своей типовой системы.


Далее: Упражнения — применяем полученные знания для классификации архитектурных решений и анализа реального кода.