Типобезопасный домен: Гексагональная архитектура на базе Effect Repository как Driven Port: интерфейс через Effect.Service
Глава

Repository как Driven Port: интерфейс через Effect.Service

Repository как Secondary (Driven) Port в Hexagonal Architecture. Пошаговое построение порта: доменные типы → ошибки → Context.Tag (Port) → использование в Application Service → Layer (Adapter) → Wiring. R-канал как compile-time Dependency Rule. Несколько адаптеров для одного порта. Антипаттерны: инфраструктурные типы, SQL, mutable state в контракте.

Введение: Port = интерфейс на языке домена

В модуле 5 мы установили фундаментальное соответствие: Context.Tag = Port. Теперь мы применим это к Repository — самому важному Driven Port в любом приложении с доменной моделью.

Repository как Driven Port — это контракт, который:

  • Определяется доменом — на языке бизнеса, а не базы данных
  • Реализуется инфраструктурой — конкретным адаптером (SQLite, InMemory, PostgreSQL)
  • Обеспечивает Dependency Rule — домен не зависит от деталей хранения

В Effect-ts этот контракт выражается через Context.Tag, а его реализация — через Layer. Давайте разберём, как именно это работает, шаг за шагом.


Driven Port в Hexagonal Architecture: теория

Классификация портов

В гексагональной архитектуре есть два типа портов:

           Driving Adapters                      Driven Adapters
           (HTTP, CLI, GUI)                     (SQLite, Email, FS)
                 │                                      ▲
                 ▼                                      │
        ┌─────────────┐                        ┌───────────────┐
        │   Driving    │                        │    Driven     │
        │    Port      │                        │     Port      │
        │  (Primary)   │                        │  (Secondary)  │
        └──────┬───────┘                        └───────┬───────┘
               │                                        │
               ▼                                        │
        ┌──────────────────────────────────────────────────────┐
        │                                                      │
        │                  APPLICATION CORE                     │
        │                                                      │
        │     Domain Model  ←──  Application Services          │
        │                                                      │
        └──────────────────────────────────────────────────────┘

Driving Port (Primary): определяет API, через который внешний мир вызывает наше приложение. Пример: CreateTodoUseCase.

Driven Port (Secondary): определяет, что приложению нужно от внешнего мира. Пример: TodoRepository — приложению нужно хранить и извлекать Todo.

Направление зависимости

Ключевое правило — Dependency Rule: зависимости всегда указывают внутрь, к домену.

SQLiteAdapter ──depends on──► TodoRepository (Port) ◄──uses── Application Service
     ▲                              │
     │                              │
  Инфраструктура              Определён в домене
  ЗАВИСИТ от порта            НЕ зависит от инфраструктуры

Порт TodoRepository определён внутри домена (или на границе домена). Адаптер TodoRepositorySqlite живёт в инфраструктуре и зависит от порта. Никогда не наоборот.


Repository как Driven Port на Effect-ts: пошаговое построение

Шаг 1: Определяем доменные типы

Прежде чем определить Repository, нам нужны доменные типы, которыми он оперирует. Они определены в предыдущих модулях:

import { Schema, Data } from "effect"

// === Value Objects ===

// TodoId — идентификатор агрегата (branded type)
const TodoId = Schema.String.pipe(
  Schema.brand("TodoId")
)
type TodoId = typeof TodoId.Type

// TodoTitle — заголовок задачи с валидацией
const TodoTitle = Schema.String.pipe(
  Schema.minLength(1),
  Schema.maxLength(200),
  Schema.brand("TodoTitle")
)
type TodoTitle = typeof TodoTitle.Type

// Priority — приоритет как перечисление
const Priority = Schema.Literal("low", "medium", "high", "critical")
type Priority = typeof Priority.Type

// Status — статус задачи
const Status = Schema.Literal("active", "completed", "archived")
type Status = typeof Status.Type

// === Entity (Aggregate Root) ===

class Todo extends Schema.Class<Todo>("Todo")({
  id: TodoId,
  title: TodoTitle,
  priority: Priority,
  status: Status,
  createdAt: Schema.DateFromSelf,
  completedAt: Schema.OptionFromSelf(Schema.DateFromSelf),
}) {
  // Бизнес-методы — чистые функции
  complete(): Todo {
    return new Todo({
      ...this,
      status: "completed" as Status,
      completedAt: Option.some(new Date()),
    })
  }
}

// === Domain Errors ===

class TodoNotFound extends Schema.TaggedError<TodoNotFound>()(
  "TodoNotFound",
  { id: TodoId }
) {}

Шаг 2: Определяем ошибки Repository

Repository имеет собственные ошибки, отделённые от доменных:

import { Schema } from "effect"

// Ошибка инфраструктуры хранения — может случиться при любой операции
class RepositoryError extends Schema.TaggedError<RepositoryError>()(
  "RepositoryError",
  {
    operation: Schema.Literal("save", "findById", "delete", "findAll"),
    message: Schema.String,
    cause: Schema.optional(Schema.Unknown),
  }
) {}

Обратите внимание: RepositoryError — это ошибка инфраструктуры хранения, а не доменная ошибка. TodoNotFound — доменная ошибка (бизнес-значение: “задача не существует”). RepositoryError — техническая ошибка (что-то пошло не так при обращении к хранилищу).

Шаг 3: Определяем Repository как Context.Tag (Port)

Вот ключевой момент — определение порта:

import { Context, Effect, Option } from "effect"

// ────────────────────────────────────────────────────────
// TodoRepository — Driven Port
// Определяется в доменном слое (или на границе домена)
// Файл: src/domain/ports/TodoRepository.ts
// ────────────────────────────────────────────────────────

class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    readonly save: (todo: Todo) => Effect.Effect<void, RepositoryError>
    
    readonly findById: (
      id: TodoId
    ) => Effect.Effect<Option.Option<Todo>, RepositoryError>
    
    readonly delete: (id: TodoId) => Effect.Effect<void, RepositoryError>
    
    readonly findAll: () => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
  }
>() {}

Разберём каждый элемент:

Context.Tag("TodoRepository")

Это уникальный идентификатор сервиса в системе типов Effect. Строка "TodoRepository" используется для отладки и является ключом в Context.

Тип параметров <TodoRepository, Shape>

Первый параметр (TodoRepository) — сам тип тега (рекурсивная ссылка для self-typing). Второй параметр (Shape) — контракт порта: набор методов, которые должен реализовать любой адаптер.

readonly на каждом методе

Иммутабельность — фундаментальный принцип. Контракт не может быть изменён после определения.

Сигнатуры методов

Каждый метод возвращает Effect<A, E> (без R, потому что адаптер сам реализует метод — ему не нужны дополнительные зависимости на уровне контракта порта). Это важное наблюдение: контракт порта описывает, что метод делает, а не от чего он зависит.

Шаг 4: Используем порт в Application Service

Теперь Application Service использует TodoRepository как зависимость:

import { Effect, pipe } from "effect"

// ────────────────────────────────────────────────────────
// CreateTodoUseCase — Application Service
// Файл: src/application/use-cases/CreateTodoUseCase.ts
// ────────────────────────────────────────────────────────

interface CreateTodoInput {
  readonly title: string
  readonly priority: Priority
}

const createTodo = (input: CreateTodoInput) =>
  Effect.gen(function* () {
    // 1. Валидация и создание доменного объекта
    const title = yield* Schema.decode(TodoTitle)(input.title)
    const id = yield* generateTodoId()
    
    const todo = new Todo({
      id,
      title,
      priority: input.priority,
      status: "active" as Status,
      createdAt: new Date(),
      completedAt: Option.none(),
    })
    
    // 2. Получаем Repository из контекста (порт!)
    const repo = yield* TodoRepository
    
    // 3. Сохраняем через порт
    yield* repo.save(todo)
    
    return todo
  })
//  ^? Effect<Todo, RepositoryError | ParseError, TodoRepository>
//                                                 ^^^^^^^^^^^^^^
//                                    Зависимость в R-канале!

Посмотрите на выведенный тип createTodo:

Effect<Todo, RepositoryError | ParseError, TodoRepository>
         │              │                      │
         │              │                      └── R: нужен TodoRepository
         │              └── E: может упасть с RepositoryError или ParseError
         └── A: возвращает Todo

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

Шаг 5: Создаём адаптер (Layer)

Адаптер — это Layer, который реализует контракт порта:

import { Layer, Effect, Option } from "effect"

// ────────────────────────────────────────────────────────
// InMemory адаптер — для тестов и разработки
// Файл: src/infrastructure/adapters/TodoRepositoryInMemory.ts
// ────────────────────────────────────────────────────────

const TodoRepositoryInMemory = Layer.sync(TodoRepository, () => {
  // Внутреннее состояние адаптера
  const store = new Map<string, Todo>()
  
  return {
    save: (todo: Todo) =>
      Effect.sync(() => {
        store.set(todo.id, todo)
      }),
    
    findById: (id: TodoId) =>
      Effect.sync(() => {
        const found = store.get(id)
        return found ? Option.some(found) : Option.none()
      }),
    
    delete: (id: TodoId) =>
      Effect.sync(() => {
        store.delete(id)
      }),
    
    findAll: () =>
      Effect.sync(() => 
        Array.from(store.values()) as ReadonlyArray<Todo>
      ),
  }
})
//  ^? Layer<TodoRepository, never, never>
//           ^^^^^^^^^^^^^^
//     Этот Layer ПРЕДОСТАВЛЯЕТ TodoRepository

Шаг 6: Подключаем адаптер (Wiring)

import { Effect, pipe } from "effect"

// ────────────────────────────────────────────────────────
// Точка входа — сборка приложения
// Файл: src/main.ts
// ────────────────────────────────────────────────────────

const program = createTodo({
  title: "Написать статью о Repository",
  priority: "high" as Priority,
})
//  ^? Effect<Todo, RepositoryError | ParseError, TodoRepository>

// Подключаем адаптер — TodoRepository "исчезает" из R-канала
const runnable = pipe(
  program,
  Effect.provide(TodoRepositoryInMemory)
)
//  ^? Effect<Todo, RepositoryError | ParseError, never>
//                                                ^^^^^
//                               Все зависимости удовлетворены!

// Запускаем
Effect.runPromise(runnable).then(console.log)

Анатомия Driven Port: три компонента

Каждый Driven Port в Effect-ts состоит из трёх компонентов:

┌─────────────────────────────────────────────────────────────────┐
│                    DRIVEN PORT (полная структура)                 │
│                                                                  │
│  1. КОНТРАКТ (Context.Tag + Shape)                               │
│     ┌─────────────────────────────────────────────────┐         │
│     │ class TodoRepository extends Context.Tag(...)    │         │
│     │   save: (todo: Todo) => Effect<void, RepoError> │         │
│     │   findById: (id: TodoId) => Effect<Option<Todo>>│         │
│     └─────────────────────────────────────────────────┘         │
│     Определён в: src/domain/ports/                               │
│                                                                  │
│  2. ОШИБКИ (TaggedError)                                         │
│     ┌─────────────────────────────────────────────────┐         │
│     │ class RepositoryError extends TaggedError(...)   │         │
│     └─────────────────────────────────────────────────┘         │
│     Определены в: src/domain/errors/                             │
│                                                                  │
│  3. ТИПЫ (доменные типы для сигнатур)                            │
│     ┌─────────────────────────────────────────────────┐         │
│     │ Todo, TodoId, Option<Todo>, ReadonlyArray<Todo>  │         │
│     └─────────────────────────────────────────────────┘         │
│     Определены в: src/domain/model/                              │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

Все три компонента живут в ДОМЕННОМ слое.
Адаптер (Layer) живёт в ИНФРАСТРУКТУРНОМ слое и ЗАВИСИТ от них.

R-канал как Dependency Rule

Как R-канал обеспечивает Dependency Rule

R-канал в Effect<A, E, R> — это compile-time реализация Dependency Rule. Рассмотрим, как это работает на практике:

// Domain Layer — определяет контракт
class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  { readonly save: (todo: Todo) => Effect.Effect<void, RepositoryError> }
>() {}

// Application Layer — использует контракт
const createTodo = (input: CreateTodoInput) =>
  Effect.gen(function* () {
    const repo = yield* TodoRepository       // ← Запрашивает порт
    yield* repo.save(todo)                   // ← Использует порт
  })
//  ^? Effect<Todo, ..., TodoRepository>     // ← R содержит TodoRepository

// Infrastructure Layer — реализует контракт
const TodoRepositorySqlite = Layer.effect(TodoRepository, /* ... */)
//  ^? Layer<TodoRepository, ...>            // ← Предоставляет TodoRepository

Цепочка зависимостей:

  1. Domain определяет TodoRepository (Tag)
  2. Application использует TodoRepository → появляется в R-канале
  3. Infrastructure реализует TodoRepository через Layer → убирает из R-канала

Если Infrastructure не предоставит реализацию — код не скомпилируется. Компилятор TypeScript гарантирует Dependency Rule:

// ❌ Не скомпилируется: R = TodoRepository, а provide не дан
Effect.runPromise(createTodo({ title: "test", priority: "high" }))
// Type error: Effect<Todo, ..., TodoRepository> is not assignable to Effect<Todo, ..., never>

// ✅ Скомпилируется: provide убирает TodoRepository из R
Effect.runPromise(
  pipe(
    createTodo({ title: "test", priority: "high" }),
    Effect.provide(TodoRepositorySqlite)
  )
)

Сравнение с традиционным DI

В традиционных DI-контейнерах (inversify, tsyringe) ошибки — runtime:

// Традиционный DI — ошибка в RUNTIME
container.bind(TodoRepository).to(TodoRepositorySqlite)
const useCase = container.get(CreateTodoUseCase)
// Если забыли bind — узнаем только при запуске: "No binding for TodoRepository"

// Effect DI — ошибка в COMPILE TIME
const program = createTodo(input)
const runnable = Effect.provide(program, TodoRepositorySqlite)
// Если забыли provide — TypeScript ошибка при компиляции

Правила размещения файлов

Структура файлов для Driven Port

src/
├── domain/
│   ├── model/
│   │   ├── Todo.ts              ← Aggregate Root
│   │   ├── TodoId.ts            ← Value Object
│   │   └── TodoTitle.ts         ← Value Object
│   │
│   ├── ports/
│   │   └── TodoRepository.ts    ← PORT (Context.Tag + Shape)
│   │
│   └── errors/
│       ├── TodoNotFound.ts      ← Domain Error
│       └── RepositoryError.ts   ← Repository Error

├── application/
│   └── use-cases/
│       └── CreateTodo.ts        ← Uses TodoRepository (R-канал)

└── infrastructure/
    └── adapters/
        ├── TodoRepositoryInMemory.ts  ← Adapter (Layer)
        └── TodoRepositorySqlite.ts    ← Adapter (Layer)

Ключевые правила:

  1. TodoRepository.ts (порт) — в domain/ports/. Это контракт, определённый доменом.
  2. TodoRepositoryInMemory.ts и TodoRepositorySqlite.ts (адаптеры) — в infrastructure/adapters/. Они реализуют контракт.
  3. RepositoryError.ts — в domain/errors/. Ошибки порта определяются доменом.
  4. Импорты идут ТОЛЬКО внутрь:
    • infrastructure/ → импортирует из domain/
    • application/ → импортирует из domain/
    • domain/ → НЕ импортирует из infrastructure/ или application/

Проверка направления зависимостей

Визуально проверяем импорты:

// ✅ infrastructure/adapters/TodoRepositorySqlite.ts
import { TodoRepository } from "../../domain/ports/TodoRepository"  // ← внутрь
import { Todo } from "../../domain/model/Todo"                      // ← внутрь
import { RepositoryError } from "../../domain/errors/RepositoryError" // ← внутрь

// ✅ application/use-cases/CreateTodo.ts
import { TodoRepository } from "../../domain/ports/TodoRepository"  // ← внутрь
import { Todo } from "../../domain/model/Todo"                      // ← внутрь

// ❌ domain/model/Todo.ts
import { Database } from "bun:sqlite"  // ← НАРУЖУ! Нарушение Dependency Rule!

Несколько адаптеров для одного порта

Одна из главных ценностей Port — возможность иметь несколько реализаций:

// ── Порт (один) ──────────────────────────────────
class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  TodoRepositoryShape
>() {}

// ── Адаптер 1: InMemory (для тестов) ─────────────
const TodoRepositoryInMemory: Layer.Layer<TodoRepository> = 
  Layer.sync(TodoRepository, () => ({
    save: (todo) => Effect.sync(() => { store.set(todo.id, todo) }),
    findById: (id) => Effect.sync(() => Option.fromNullable(store.get(id))),
    delete: (id) => Effect.sync(() => { store.delete(id) }),
    findAll: () => Effect.sync(() => Array.from(store.values())),
  }))

// ── Адаптер 2: SQLite (для production) ───────────
const TodoRepositorySqlite: Layer.Layer<TodoRepository, never, SqliteClient> =
  Layer.effect(TodoRepository,
    Effect.gen(function* () {
      const db = yield* SqliteClient
      return {
        save: (todo) => Effect.try(() => db.run("INSERT OR REPLACE ...", [todo.id, ...])),
        findById: (id) => Effect.try(() => {
          const row = db.get("SELECT * FROM todos WHERE id = ?", [id])
          return row ? Option.some(rowToTodo(row)) : Option.none()
        }),
        // ...
      }
    })
  )

// ── Адаптер 3: HTTP (для микросервисов) ──────────
const TodoRepositoryHttp: Layer.Layer<TodoRepository, never, HttpClient> =
  Layer.effect(TodoRepository,
    Effect.gen(function* () {
      const http = yield* HttpClient
      return {
        save: (todo) => pipe(
          http.post("/api/todos", { body: todo }),
          Effect.mapError(toRepositoryError)
        ),
        // ...
      }
    })
  )

Выбор адаптера происходит на этапе сборки, не в бизнес-логике:

// Тесты
const testProgram = pipe(program, Effect.provide(TodoRepositoryInMemory))

// Development
const devProgram = pipe(program, Effect.provide(TodoRepositorySqlite))

// Микросервисная конфигурация
const prodProgram = pipe(program, Effect.provide(TodoRepositoryHttp))

Полный пример: от порта до запуска

Соберём всё в один связный пример:

import { Context, Effect, Layer, Option, Schema, pipe } from "effect"

// ╔══════════════════════════════════════════════════════╗
// ║  DOMAIN LAYER                                        ║
// ╚══════════════════════════════════════════════════════╝

// --- Value Objects ---
const TodoId = Schema.String.pipe(Schema.brand("TodoId"))
type TodoId = typeof TodoId.Type

const TodoTitle = Schema.String.pipe(
  Schema.minLength(1),
  Schema.maxLength(200),
  Schema.brand("TodoTitle")
)
type TodoTitle = typeof TodoTitle.Type

// --- Errors ---
class RepositoryError extends Schema.TaggedError<RepositoryError>()(
  "RepositoryError",
  { message: Schema.String }
) {}

// --- Entity ---
class Todo extends Schema.Class<Todo>("Todo")({
  id: TodoId,
  title: TodoTitle,
  done: Schema.Boolean,
}) {}

// --- Port (Driven) ---
class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    readonly save: (todo: Todo) => Effect.Effect<void, RepositoryError>
    readonly findById: (id: TodoId) => Effect.Effect<Option.Option<Todo>, RepositoryError>
    readonly delete: (id: TodoId) => Effect.Effect<void, RepositoryError>
    readonly findAll: () => Effect.Effect<ReadonlyArray<Todo>, RepositoryError>
  }
>() {}

// ╔══════════════════════════════════════════════════════╗
// ║  APPLICATION LAYER                                    ║
// ╚══════════════════════════════════════════════════════╝

const createTodo = (title: string) =>
  Effect.gen(function* () {
    const validTitle = yield* Schema.decode(TodoTitle)(title)
    const todo = new Todo({
      id: TodoId.make(`todo-${Date.now()}`),
      title: validTitle,
      done: false,
    })
    
    const repo = yield* TodoRepository
    yield* repo.save(todo)
    return todo
  })

const getAllTodos = Effect.gen(function* () {
  const repo = yield* TodoRepository
  return yield* repo.findAll()
})

// ╔══════════════════════════════════════════════════════╗
// ║  INFRASTRUCTURE LAYER                                 ║
// ╚══════════════════════════════════════════════════════╝

const TodoRepositoryInMemory = Layer.sync(TodoRepository, () => {
  const store = new Map<string, Todo>()
  
  return {
    save: (todo) => Effect.sync(() => { store.set(todo.id, todo) }),
    findById: (id) => Effect.sync(() => Option.fromNullable(store.get(id))),
    delete: (id) => Effect.sync(() => { store.delete(id) }),
    findAll: () => Effect.sync(() => Array.from(store.values()) as ReadonlyArray<Todo>),
  }
})

// ╔══════════════════════════════════════════════════════╗
// ║  COMPOSITION ROOT                                     ║
// ╚══════════════════════════════════════════════════════╝

const program = Effect.gen(function* () {
  yield* createTodo("Изучить Repository Pattern")
  yield* createTodo("Написать адаптер для SQLite")
  
  const todos = yield* getAllTodos
  return todos
})

const main = pipe(
  program,
  Effect.provide(TodoRepositoryInMemory)
)

Effect.runPromise(main).then((todos) => {
  console.log(`Создано ${todos.length} задач`)
  todos.forEach((t) => console.log(`  - ${t.title}`))
})

Driven Port vs Driving Port: ключевые различия

ХарактеристикаDriving Port (Primary)Driven Port (Secondary)
Кто вызываетВнешний мир → приложениеПриложение → внешний мир
ПримерCreateTodoUseCaseTodoRepository
ОпределяетAPI приложенияПотребности приложения
АдаптерHTTP Controller, CLISQLite, InMemory, API Client
Направление данныхВходящие запросыИсходящие операции
Кто реализуетApplication LayerInfrastructure Layer

Repository — всегда Driven Port, потому что это приложение обращается к хранилищу, а не наоборот. Приложение говорит: “мне нужно сохранить этот агрегат” — и Repository обеспечивает эту потребность.


Антипаттерны: что ломает Port

Антипаттерн 1: Инфраструктурные типы в контракте

// ❌ Утечка инфраструктуры
import { Database } from "bun:sqlite"

class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    readonly save: (todo: Todo, db: Database) => Effect.Effect<void>
    //                         ^^^^^^^^^^^
    //        Инфраструктурный тип в контракте порта!
  }
>() {}

Антипаттерн 2: SQL в контракте

// ❌ SQL — деталь реализации
class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    readonly query: (sql: string, params: ReadonlyArray<unknown>) => Effect.Effect<unknown>
    //              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    //          Это не Repository, это Database Client
  }
>() {}

Антипаттерн 3: Возврат инфраструктурных ошибок

// ❌ SQLite ошибки в контракте порта
import { SqliteError } from "bun:sqlite"

class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    readonly save: (todo: Todo) => Effect.Effect<void, SqliteError>
    //                                                 ^^^^^^^^^^^
    //             Инфраструктурная ошибка в доменном контракте!
  }
>() {}

// ✅ Доменная ошибка
class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    readonly save: (todo: Todo) => Effect.Effect<void, RepositoryError>
    //                                                 ^^^^^^^^^^^^^^^
    //                 Абстрактная ошибка, определённая доменом
  }
>() {}

Антипаттерн 4: Mutable state в контракте

// ❌ Mutable state
class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    cache: Map<string, Todo>  // ← Mutable, не readonly
    save: (todo: Todo) => void  // ← Синхронный, без Effect
  }
>() {}

// ✅ Immutable, Effect-based
class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    readonly save: (todo: Todo) => Effect.Effect<void, RepositoryError>
  }
>() {}

Итоги

  1. Repository — это Driven Port: контракт определён доменом, реализован инфраструктурой
  2. Context.Tag — это Port: Context.Tag в Effect-ts — прямой аналог порта в Hexagonal Architecture
  3. Layer — это Adapter: каждый адаптер реализуется как Layer, который удовлетворяет Tag
  4. R-канал — это Dependency Rule: компилятор гарантирует, что все зависимости предоставлены
  5. Множество адаптеров: один порт может иметь сколько угодно реализаций (InMemory, SQLite, HTTP)
  6. Направление зависимостей: infrastructure/ → domain/, никогда наоборот
  7. Чистота контракта: никаких инфраструктурных типов, SQL, database-специфичных ошибок

В следующей статье мы детально разберём контракт Repository — какие именно методы должен содержать порт, какие типы возвращать, и как обеспечить полноту контракта.