Типобезопасный домен: Гексагональная архитектура на базе Effect Структура проекта: как гексагон отражается в файловой системе
Глава

Структура проекта: как гексагон отражается в файловой системе

Эталонная файловая структура гексагонального проекта на Effect-ts — domain/, ports/, application/, adapters/, composition/. Детальный разбор каждой зоны с правилами импортов, соглашения об именовании, граф зависимостей между папками, масштабирование через модульную структуру (Bounded Contexts), автоматизация проверки зависимостей через ESLint и скрипты.

Почему структура папок — это архитектурное решение

Структура проекта — это первое, что видит новый разработчик. За 30 секунд, просматривая дерево папок, он формирует ментальную модель системы. Если папки организованы по техническим слоям (controllers/, models/, services/), разработчик будет думать слоями. Если папки организованы по архитектурным зонам (domain/, ports/, adapters/), разработчик будет думать гексагонами.

Роберт Мартин (Uncle Bob) сформулировал это как принцип Screaming Architecture:

Архитектура должна «кричать» о назначении системы. Посмотрев на верхний уровень папок, вы должны понять: «это Todo-приложение с гексагональной архитектурой», а не «это Express-приложение с Prisma».


Два подхода к организации: по слоям vs по фичам

Подход 1: Организация по техническим слоям (Layer-first)

Традиционный подход, где папки верхнего уровня отражают технические роли:

src/
├── controllers/        # HTTP handlers
│   ├── todo.controller.ts
│   └── user.controller.ts
├── services/           # Business logic
│   ├── todo.service.ts
│   └── user.service.ts
├── repositories/       # Data access
│   ├── todo.repository.ts
│   └── user.repository.ts
├── models/             # Data structures
│   ├── todo.model.ts
│   └── user.model.ts
└── middleware/
    ├── auth.middleware.ts
    └── logging.middleware.ts

Проблемы:

  • Файлы одной фичи (Todo) разбросаны по 5 папкам
  • Невозможно понять, какие бизнес-правила есть у Todo, не обойдя все папки
  • Тенденция к «жирным» сервисам, которые делают всё
  • Не видно архитектурных границ

Подход 2: Организация по архитектурным зонам (Architecture-first)

Гексагональный подход, где папки верхнего уровня отражают архитектурные зоны:

src/
├── domain/             # APPLICATION CORE — чистая бизнес-логика
├── ports/              # PORTS — контракты между ядром и внешним миром
├── application/        # APPLICATION SERVICES — оркестрация Use Cases
├── adapters/           # ADAPTERS — реализации портов
└── main.ts             # WIRING — сборка всех Layer

Это рекомендуемый подход для гексагональной архитектуры с Effect-ts. Рассмотрим его детально.


Эталонная структура проекта

Вот полная структура Todo-приложения с гексагональной архитектурой:

todo-app/

├── src/
│   │
│   ├── domain/                          # ═══ APPLICATION CORE ═══
│   │   │                                # Чистая бизнес-логика. Нулевые зависимости.
│   │   │
│   │   ├── model/                       # Доменные модели
│   │   │   ├── todo.ts                  # Todo Entity
│   │   │   ├── todo-list.ts             # TodoList Aggregate
│   │   │   ├── todo-id.ts              # TodoId (Branded Type)
│   │   │   ├── todo-title.ts           # TodoTitle (Value Object)
│   │   │   ├── todo-status.ts          # TodoStatus (Value Object)
│   │   │   ├── priority.ts             # Priority (Value Object)
│   │   │   └── index.ts                # Barrel export
│   │   │
│   │   ├── events/                      # Доменные события
│   │   │   ├── todo-created.ts
│   │   │   ├── todo-completed.ts
│   │   │   ├── todo-title-changed.ts
│   │   │   ├── todo-archived.ts
│   │   │   └── index.ts
│   │   │
│   │   ├── errors/                      # Доменные ошибки
│   │   │   ├── todo-validation-error.ts
│   │   │   ├── todo-not-found-error.ts
│   │   │   ├── invalid-transition-error.ts
│   │   │   ├── duplicate-title-error.ts
│   │   │   └── index.ts
│   │   │
│   │   ├── services/                    # Доменные сервисы
│   │   │   ├── todo-prioritizer.ts
│   │   │   ├── duplicate-checker.ts
│   │   │   └── index.ts
│   │   │
│   │   └── index.ts                     # Публичный API домена
│   │
│   ├── ports/                           # ═══ PORTS ═══
│   │   │                                # Контракты (интерфейсы). Effect.Service определения.
│   │   │
│   │   ├── driving/                     # Входные порты (Primary)
│   │   │   ├── create-todo.ts           # CreateTodoUseCase
│   │   │   ├── complete-todo.ts         # CompleteTodoUseCase
│   │   │   ├── get-todo.ts             # GetTodoUseCase
│   │   │   ├── list-todos.ts           # ListTodosUseCase
│   │   │   ├── delete-todo.ts          # DeleteTodoUseCase
│   │   │   ├── update-todo-title.ts    # UpdateTodoTitleUseCase
│   │   │   └── index.ts
│   │   │
│   │   ├── driven/                      # Выходные порты (Secondary)
│   │   │   ├── todo-repository.ts       # TodoRepository
│   │   │   ├── event-store.ts           # EventStore
│   │   │   ├── notification-service.ts  # NotificationService
│   │   │   ├── file-storage.ts          # FileStorage
│   │   │   ├── id-generator.ts          # IdGenerator
│   │   │   └── index.ts
│   │   │
│   │   └── index.ts
│   │
│   ├── application/                     # ═══ APPLICATION LAYER ═══
│   │   │                                # Оркестрация. Реализация Use Cases.
│   │   │
│   │   ├── commands/                    # Command Handlers
│   │   │   ├── create-todo.handler.ts
│   │   │   ├── complete-todo.handler.ts
│   │   │   ├── delete-todo.handler.ts
│   │   │   ├── update-todo-title.handler.ts
│   │   │   └── index.ts
│   │   │
│   │   ├── queries/                     # Query Handlers
│   │   │   ├── get-todo.handler.ts
│   │   │   ├── list-todos.handler.ts
│   │   │   └── index.ts
│   │   │
│   │   ├── middleware/                  # Application-level middleware
│   │   │   ├── logging.ts
│   │   │   ├── validation.ts
│   │   │   └── index.ts
│   │   │
│   │   └── index.ts
│   │
│   ├── adapters/                        # ═══ ADAPTERS ═══
│   │   │                                # Реализации портов для конкретных технологий.
│   │   │
│   │   ├── driving/                     # Входные адаптеры
│   │   │   ├── http/                    # HTTP (REST API)
│   │   │   │   ├── routes/
│   │   │   │   │   ├── todo.routes.ts
│   │   │   │   │   └── health.routes.ts
│   │   │   │   ├── dto/                 # Data Transfer Objects
│   │   │   │   │   ├── create-todo.request.ts
│   │   │   │   │   ├── todo.response.ts
│   │   │   │   │   └── index.ts
│   │   │   │   ├── middleware/
│   │   │   │   │   ├── error-handler.ts
│   │   │   │   │   ├── cors.ts
│   │   │   │   │   └── auth.ts
│   │   │   │   ├── server.ts            # HTTP server setup
│   │   │   │   └── index.ts
│   │   │   │
│   │   │   └── cli/                     # CLI (альтернативный вход)
│   │   │       ├── commands/
│   │   │       └── index.ts
│   │   │
│   │   ├── driven/                      # Выходные адаптеры
│   │   │   ├── sqlite/                  # SQLite persistence
│   │   │   │   ├── client.ts            # SqliteClient Layer
│   │   │   │   ├── migrations/
│   │   │   │   │   ├── 001-create-todos.ts
│   │   │   │   │   └── runner.ts
│   │   │   │   ├── repositories/
│   │   │   │   │   └── todo.repository.sqlite.ts
│   │   │   │   ├── mappers/
│   │   │   │   │   └── todo.mapper.ts   # Todo ↔ TodoRow
│   │   │   │   └── index.ts
│   │   │   │
│   │   │   ├── in-memory/               # In-memory (для тестов)
│   │   │   │   ├── todo.repository.memory.ts
│   │   │   │   ├── event-store.memory.ts
│   │   │   │   └── index.ts
│   │   │   │
│   │   │   ├── filesystem/              # File storage
│   │   │   │   ├── local-file-storage.ts
│   │   │   │   └── index.ts
│   │   │   │
│   │   │   └── notification/            # Notification service
│   │   │       ├── console-notifier.ts
│   │   │       ├── email-notifier.ts
│   │   │       └── index.ts
│   │   │
│   │   └── index.ts
│   │
│   ├── config/                          # ═══ CONFIGURATION ═══
│   │   ├── app.config.ts               # Effect Config определения
│   │   ├── database.config.ts
│   │   └── index.ts
│   │
│   ├── composition/                     # ═══ COMPOSITION ROOT ═══
│   │   │                                # Сборка всех Layer
│   │   ├── layers.ts                    # Определение графа зависимостей
│   │   ├── production.ts                # Production конфигурация
│   │   ├── development.ts               # Development конфигурация
│   │   └── index.ts
│   │
│   └── main.ts                          # ═══ ENTRY POINT ═══
│                                        # Effect.runFork с полным Layer

├── test/
│   ├── domain/                          # Unit-тесты домена
│   │   ├── model/
│   │   │   ├── todo.test.ts
│   │   │   ├── todo-title.test.ts
│   │   │   └── priority.test.ts
│   │   └── services/
│   │       └── duplicate-checker.test.ts
│   │
│   ├── application/                     # Unit-тесты Use Cases
│   │   ├── commands/
│   │   │   ├── create-todo.test.ts
│   │   │   └── complete-todo.test.ts
│   │   └── queries/
│   │       └── list-todos.test.ts
│   │
│   ├── adapters/                        # Integration-тесты
│   │   ├── sqlite/
│   │   │   └── todo.repository.sqlite.test.ts
│   │   └── http/
│   │       └── todo.routes.test.ts
│   │
│   ├── e2e/                             # End-to-end тесты
│   │   └── todo-flow.e2e.test.ts
│   │
│   └── helpers/                         # Тестовые утилиты
│       ├── test-layers.ts               # InMemory Layer для тестов
│       ├── factories.ts                 # Фабрики доменных объектов
│       └── assertions.ts               # Кастомные ассерты

├── package.json
├── tsconfig.json
├── bunfig.toml
└── README.md

Детальный разбор каждой зоны

Зона 1: domain/ — Application Core

src/domain/
├── model/
├── events/
├── errors/
├── services/
└── index.ts

Правила:

  1. Нулевые внешние зависимостиdomain/ импортирует только effect (для Effect, Option, Schema, Data). Никаких @effect/platform, bun:sqlite, express, axios.

  2. Только бизнес-логика — если код не отвечает на вопрос о бизнесе («может ли задача быть завершена?», «валиден ли заголовок?»), ему не место в domain/.

  3. Самодостаточность — домен компилируется и тестируется изолированно. Можно удалить все остальные папки, и domain/ продолжит работать.

// src/domain/model/todo-title.ts
import { Schema } from "effect"

// ✓ Только effect, никаких внешних зависимостей
export class TodoTitle extends Schema.Class<TodoTitle>("TodoTitle")({
  value: Schema.String.pipe(
    Schema.trimmed(),
    Schema.minLength(1),
    Schema.maxLength(200),
  ),
}) {}

Проверка зависимостей — автоматизация через правило в package.json или ESLint:

// eslint правило (концептуально)
// Файлы в src/domain/ не могут импортировать из:
// - src/adapters/
// - src/ports/      (спорный момент — см. ниже)
// - src/application/
// - @effect/platform
// - bun:sqlite
// - любых внешних HTTP/DB библиотек

Содержимое domain/index.ts — barrel file, определяющий публичный API домена:

// src/domain/index.ts
// Только то, что нужно другим слоям

// Модели
export { Todo } from "./model/todo.js"
export { TodoTitle } from "./model/todo-title.js"
export { TodoStatus } from "./model/todo-status.js"
export { Priority } from "./model/priority.js"
export type { TodoId } from "./model/todo-id.js"

// События
export { TodoCreated } from "./events/todo-created.js"
export { TodoCompleted } from "./events/todo-completed.js"

// Ошибки
export { TodoValidationError } from "./errors/todo-validation-error.js"
export { TodoNotFoundError } from "./errors/todo-not-found-error.js"
export { InvalidTransitionError } from "./errors/invalid-transition-error.js"

// Доменные сервисы
export { DuplicateChecker } from "./services/duplicate-checker.js"

Зона 2: ports/ — контракты

src/ports/
├── driving/
├── driven/
└── index.ts

Порты — это мост между ядром и внешним миром. Они определяют что нужно, но не как это реализовать.

Вопрос: куда помещать порты?

Существует два подхода, и оба имеют право на жизнь:

Подход A: Порты в отдельной папке (ports/)

src/
├── domain/
├── ports/          ◄── отдельная папка
├── application/
└── adapters/

Преимущества: явное разделение, легко найти все контракты в одном месте.

Подход B: Порты внутри домена (domain/ports/)

src/
├── domain/
│   ├── model/
│   ├── ports/      ◄── внутри домена
│   └── ...
├── application/
└── adapters/

Преимущества: порты «принадлежат» ядру (что семантически верно), тесная связь с доменными типами.

Рекомендация для Effect-ts: Подход A (отдельная папка). Причина — в Effect-ts порты (Context.Tag) используются и в application/, и в adapters/, поэтому удобнее, когда они в нейтральном месте.

// src/ports/driven/todo-repository.ts
import { Context, Effect } from "effect"
import type { Todo, TodoId } from "../../domain/index.js"
import type { TodoNotFoundError, PersistenceError } from "../../domain/index.js"

export class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    readonly save: (todo: Todo) => Effect.Effect<void, PersistenceError>
    readonly findById: (id: TodoId) => Effect.Effect<Todo, TodoNotFoundError>
    readonly findAll: Effect.Effect<ReadonlyArray<Todo>, PersistenceError>
    readonly delete: (id: TodoId) => Effect.Effect<void, TodoNotFoundError>
  }
>() {}
// src/ports/driving/create-todo.ts
import { Context, Effect } from "effect"
import type { Todo } from "../../domain/index.js"
import type { TodoValidationError } from "../../domain/index.js"

export interface CreateTodoInput {
  readonly title: string
  readonly priority: "low" | "medium" | "high"
  readonly dueDate?: string
}

export class CreateTodoUseCase extends Context.Tag("CreateTodoUseCase")<
  CreateTodoUseCase,
  {
    readonly execute: (
      input: CreateTodoInput
    ) => Effect.Effect<Todo, TodoValidationError>
  }
>() {}

Зона 3: application/ — оркестрация

src/application/
├── commands/
├── queries/
├── middleware/
└── index.ts

Application Layer реализует Driving Ports. Каждый файл в commands/ или queries/ — это Layer, который предоставляет реализацию одного Use Case.

// src/application/commands/create-todo.handler.ts
import { Effect, Layer } from "effect"
import { CreateTodoUseCase } from "../../ports/driving/index.js"
import { TodoRepository } from "../../ports/driven/index.js"
import { Todo, TodoTitle, Priority } from "../../domain/index.js"

export const CreateTodoHandlerLive = Layer.effect(
  CreateTodoUseCase,
  Effect.gen(function* () {
    const repo = yield* TodoRepository
    
    return CreateTodoUseCase.of({
      execute: (input) =>
        Effect.gen(function* () {
          const title = yield* TodoTitle.make(input.title)
          const priority = Priority.make(input.priority)
          const todo = Todo.create({ title, priority })
          yield* repo.save(todo)
          return todo
        }),
    })
  })
)

Правила для application/:

  • Импортирует из domain/ (доменные типы, ошибки)
  • Импортирует из ports/ (контракты)
  • НЕ импортирует из adapters/ (конкретные реализации)
  • Не содержит бизнес-логики (она в domain/)
  • Не содержит инфраструктурного кода (он в adapters/)

Зона 4: adapters/ — инфраструктура

src/adapters/
├── driving/
│   ├── http/
│   └── cli/
├── driven/
│   ├── sqlite/
│   ├── in-memory/
│   ├── filesystem/
│   └── notification/
└── index.ts

Адаптеры — самая «грязная» часть проекта. Здесь живут SQL-запросы, HTTP-хендлеры, файловые операции, сетевые вызовы. И это нормально — вся сложность инфраструктуры изолирована в этой зоне.

// src/adapters/driven/sqlite/repositories/todo.repository.sqlite.ts
import { Effect, Layer, Option } from "effect"
import { TodoRepository } from "../../../../ports/driven/index.js"
import { SqliteClient } from "../client.js"
import { todoFromRow, todoToRow } from "../mappers/todo.mapper.js"

export const TodoRepositorySqliteLive = Layer.effect(
  TodoRepository,
  Effect.gen(function* () {
    const sql = yield* SqliteClient
    
    return TodoRepository.of({
      save: (todo) =>
        Effect.gen(function* () {
          const row = todoToRow(todo)
          yield* sql.execute(
            `INSERT OR REPLACE INTO todos 
             (id, title, status, priority, due_date, created_at) 
             VALUES ($id, $title, $status, $priority, $dueDate, $createdAt)`,
            row
          )
        }),
      
      findById: (id) =>
        Effect.gen(function* () {
          const row = yield* sql.executeOne(
            `SELECT * FROM todos WHERE id = $id`,
            { id }
          )
          return todoFromRow(row)
        }),
        
      findAll: Effect.gen(function* () {
        const rows = yield* sql.executeAll(`SELECT * FROM todos ORDER BY created_at DESC`)
        return rows.map(todoFromRow)
      }),
      
      delete: (id) =>
        Effect.gen(function* () {
          yield* sql.execute(`DELETE FROM todos WHERE id = $id`, { id })
        }),
    })
  })
)

Правила для adapters/:

  • Импортирует из ports/ (реализует контракты)
  • Импортирует из domain/ (маппинг доменных типов)
  • Импортирует внешние библиотеки (bun:sqlite, @effect/platform)
  • НЕ импортирует из application/ (адаптер не знает о Use Cases)
  • НЕ импортируется из domain/ (домен не знает об адаптерах)

Зона 5: composition/ — сборка

src/composition/
├── layers.ts
├── production.ts
├── development.ts
└── index.ts

Composition Root — единственное место, где все Layer собираются вместе. Это точка, где адаптеры подключаются к портам:

// src/composition/production.ts
import { Layer } from "effect"

// Driven Adapters
import { TodoRepositorySqliteLive } from "../adapters/driven/sqlite/index.js"
import { SqliteClientLive } from "../adapters/driven/sqlite/client.js"
import { LocalFileStorageLive } from "../adapters/driven/filesystem/index.js"

// Application Handlers
import { CreateTodoHandlerLive } from "../application/commands/create-todo.handler.js"
import { CompleteTodoHandlerLive } from "../application/commands/complete-todo.handler.js"
import { ListTodosHandlerLive } from "../application/queries/list-todos.handler.js"

// Driving Adapter
import { HttpServerLive } from "../adapters/driving/http/server.js"

// ════════════════════════════════════════
// Production Layer Graph
// ════════════════════════════════════════

// Infrastructure Layer (нижний уровень)
const InfrastructureLayer = Layer.mergeAll(
  SqliteClientLive,
  LocalFileStorageLive,
)

// Repository Layer (зависит от Infrastructure)
const RepositoryLayer = TodoRepositorySqliteLive.pipe(
  Layer.provide(InfrastructureLayer)
)

// Application Layer (зависит от Repository)
const ApplicationLayer = Layer.mergeAll(
  CreateTodoHandlerLive,
  CompleteTodoHandlerLive,
  ListTodosHandlerLive,
).pipe(
  Layer.provide(RepositoryLayer)
)

// HTTP Layer (зависит от Application)
export const ProductionLayer = HttpServerLive.pipe(
  Layer.provide(ApplicationLayer)
)
// src/composition/development.ts
import { Layer } from "effect"
import { TodoRepositoryInMemoryLive } from "../adapters/driven/in-memory/index.js"
// ...

// Development: InMemory вместо SQLite
const RepositoryLayer = TodoRepositoryInMemoryLive

export const DevelopmentLayer = HttpServerLive.pipe(
  Layer.provide(
    Layer.mergeAll(
      CreateTodoHandlerLive,
      CompleteTodoHandlerLive,
      ListTodosHandlerLive,
    ).pipe(Layer.provide(RepositoryLayer))
  )
)

Точка входа:

// src/main.ts
import { Effect, Layer } from "effect"
import { BunRuntime } from "@effect/platform-bun"
import { ProductionLayer } from "./composition/production.js"
import { HttpServer } from "./adapters/driving/http/server.js"

const program = Effect.gen(function* () {
  const server = yield* HttpServer
  yield* server.start()
  yield* Effect.log("🚀 Todo App started on port 3000")
  yield* Effect.never // keep running
})

BunRuntime.runMain(
  program.pipe(Effect.provide(ProductionLayer))
)

Граф зависимостей между папками

Допустимые импорты (→ означает «может импортировать из»):

                    ┌─────────┐
                    │ domain/ │  ← Ни от чего не зависит
                    └────┬────┘

            ┌────────────┼────────────┐
            ▼            ▼            ▼
       ┌─────────┐ ┌──────────┐ ┌────────────┐
       │ ports/  │ │          │ │            │
       └────┬────┘ │          │ │            │
            │      │          │ │            │
            ▼      ▼          │ │            │
     ┌──────────────────┐    │ │            │
     │  application/    │────┘ │            │
     └────────┬─────────┘     │            │
              │               │            │
              ▼               ▼            │
     ┌──────────────────────────────┐     │
     │        adapters/             │─────┘
     └──────────────┬───────────────┘


     ┌──────────────────────────────┐
     │     composition/             │ ← Знает обо всём
     └──────────────┬───────────────┘


     ┌──────────────────────────────┐
     │          main.ts             │
     └──────────────────────────────┘

Таблица разрешённых импортов:

ПапкаМожет импортировать изНЕ может импортировать из
domain/effectВсё остальное
ports/domain/, effectapplication/, adapters/
application/domain/, ports/, effectadapters/
adapters/domain/, ports/, effect, внешние библиотекиapplication/
composition/Всё
main.tscomposition/

Соглашения об именовании файлов

Доменные модели: kebab-case без суффиксов

domain/model/todo.ts              # Entity
domain/model/todo-title.ts        # Value Object
domain/model/todo-id.ts           # Branded Type
domain/model/priority.ts          # Value Object / Enum

Порты: имя контракта в kebab-case

ports/driven/todo-repository.ts       # Driven Port
ports/driven/event-store.ts           # Driven Port
ports/driving/create-todo.ts          # Driving Port (Use Case)
ports/driving/list-todos.ts           # Driving Port (Query)

Application: имя.handler.ts

application/commands/create-todo.handler.ts
application/commands/complete-todo.handler.ts
application/queries/list-todos.handler.ts

Адаптеры: имя.технология.ts

adapters/driven/sqlite/repositories/todo.repository.sqlite.ts
adapters/driven/in-memory/todo.repository.memory.ts
adapters/driving/http/routes/todo.routes.ts

Маппинг между доменным и инфраструктурным типами

adapters/driven/sqlite/mappers/todo.mapper.ts

Содержимое маппера:

// adapters/driven/sqlite/mappers/todo.mapper.ts
import { Todo, TodoTitle, TodoStatus, Priority } from "../../../../domain/index.js"
import { Option } from "effect"

// SQL row type — инфраструктурный тип
export interface TodoRow {
  readonly id: string
  readonly title: string
  readonly status: string
  readonly priority: string
  readonly due_date: string | null
  readonly created_at: string
}

// Domain → SQL
export const todoToRow = (todo: Todo): TodoRow => ({
  id: todo.id,
  title: todo.title.value,
  status: todo.status.value,
  priority: todo.priority.value,
  due_date: Option.match(todo.dueDate, {
    onNone: () => null,
    onSome: (d) => d.toISOString(),
  }),
  created_at: todo.createdAt.toISOString(),
})

// SQL → Domain
export const todoFromRow = (row: TodoRow): Todo =>
  new Todo({
    id: row.id as TodoId,
    title: new TodoTitle({ value: row.title }),
    status: TodoStatus.make(row.status),
    priority: Priority.make(row.priority),
    dueDate: row.due_date !== null
      ? Option.some(new Date(row.due_date))
      : Option.none(),
    createdAt: new Date(row.created_at),
  })

Тестовая структура: зеркало src/

test/
├── domain/           # Зеркалит src/domain/
│   ├── model/
│   └── services/
├── application/      # Зеркалит src/application/
│   ├── commands/
│   └── queries/
├── adapters/         # Зеркалит src/adapters/
│   ├── sqlite/
│   └── http/
├── e2e/              # Тесты полного пайплайна
└── helpers/          # Общие утилиты для тестов
    ├── test-layers.ts
    ├── factories.ts
    └── assertions.ts

test/helpers/test-layers.ts — InMemory-адаптеры для быстрых тестов:

// test/helpers/test-layers.ts
import { Layer } from "effect"
import { TodoRepositoryInMemoryLive } from "../../src/adapters/driven/in-memory/index.js"
import { CreateTodoHandlerLive } from "../../src/application/commands/create-todo.handler.js"

// Полный тестовый Layer — InMemory вместо SQLite
export const TestLayer = Layer.mergeAll(
  CreateTodoHandlerLive,
  // ...другие handlers
).pipe(
  Layer.provide(TodoRepositoryInMemoryLive)
)

test/helpers/factories.ts — фабрики доменных объектов:

// test/helpers/factories.ts
import { Todo, TodoTitle, Priority, TodoStatus } from "../../src/domain/index.js"
import { Option } from "effect"

export const makeTodo = (overrides?: Partial<{
  readonly title: string
  readonly priority: string
  readonly status: string
}>): Todo =>
  new Todo({
    id: `todo-${crypto.randomUUID()}` as TodoId,
    title: new TodoTitle({ value: overrides?.title ?? "Test Todo" }),
    status: TodoStatus.make(overrides?.status ?? "pending"),
    priority: Priority.make(overrides?.priority ?? "medium"),
    dueDate: Option.none(),
    createdAt: new Date("2025-01-01"),
  })

Масштабирование: модульная структура

Когда проект растёт и появляются несколько Bounded Contexts (например, Todo, User, Notification), структура масштабируется по модулям:

src/
├── modules/
│   ├── todo/
│   │   ├── domain/
│   │   ├── ports/
│   │   ├── application/
│   │   ├── adapters/
│   │   └── index.ts          # Публичный API модуля
│   │
│   ├── user/
│   │   ├── domain/
│   │   ├── ports/
│   │   ├── application/
│   │   ├── adapters/
│   │   └── index.ts
│   │
│   └── notification/
│       ├── domain/
│       ├── ports/
│       ├── application/
│       ├── adapters/
│       └── index.ts

├── shared/                    # Общие утилиты между модулями
│   ├── kernel/                # Shared Kernel (общие Value Objects)
│   │   ├── email.ts
│   │   ├── user-id.ts
│   │   └── index.ts
│   └── infrastructure/        # Общая инфраструктура
│       ├── sqlite-client.ts
│       └── logger.ts

├── composition/
│   └── production.ts          # Сборка всех модулей

└── main.ts

Правила межмодульного взаимодействия:

  • Модули общаются только через порты (Anti-Corruption Layer)
  • Модуль todo/ не импортирует из user/domain/ напрямую
  • Общие типы живут в shared/kernel/
  • Каждый модуль — самостоятельный гексагон

Автоматизация проверки зависимостей

Для enforce’а правил зависимостей можно использовать инструменты:

Подход 1: ESLint с eslint-plugin-boundaries

// .eslintrc.json
{
  "plugins": ["boundaries"],
  "settings": {
    "boundaries/elements": [
      { "type": "domain", "pattern": "src/domain/*" },
      { "type": "ports", "pattern": "src/ports/*" },
      { "type": "application", "pattern": "src/application/*" },
      { "type": "adapters", "pattern": "src/adapters/*" },
      { "type": "composition", "pattern": "src/composition/*" }
    ]
  },
  "rules": {
    "boundaries/element-types": [2, {
      "default": "disallow",
      "rules": [
        { "from": "domain", "allow": [] },
        { "from": "ports", "allow": ["domain"] },
        { "from": "application", "allow": ["domain", "ports"] },
        { "from": "adapters", "allow": ["domain", "ports"] },
        { "from": "composition", "allow": ["domain", "ports", "application", "adapters"] }
      ]
    }]
  }
}

Подход 2: TypeScript path aliases

// tsconfig.json
{
  "compilerOptions": {
    "paths": {
      "@domain/*": ["./src/domain/*"],
      "@ports/*": ["./src/ports/*"],
      "@app/*": ["./src/application/*"],
      "@adapters/*": ["./src/adapters/*"]
    }
  }
}

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

// src/application/commands/create-todo.handler.ts
import { Todo } from "@domain/model/todo.js"           // ✓ OK
import { TodoRepository } from "@ports/driven/index.js" // ✓ OK
// import { SqliteClient } from "@adapters/driven/sqlite/client.js"  // ✗ ЗАПРЕЩЕНО

Подход 3: Простой скрипт проверки (для Bun)

// scripts/check-deps.ts
import { Glob } from "bun"

const forbidden: Record<string, ReadonlyArray<string>> = {
  "src/domain": ["src/adapters", "src/application", "src/ports", "@effect/platform", "bun:sqlite"],
  "src/ports": ["src/adapters", "src/application"],
  "src/application": ["src/adapters"],
} as const

for (const [zone, banned] of Object.entries(forbidden)) {
  const glob = new Glob(`${zone}/**/*.ts`)
  for await (const file of glob.scan(".")) {
    const content = await Bun.file(file).text()
    for (const dep of banned) {
      if (content.includes(`from "${dep}`) || content.includes(`from '${dep}`)) {
        console.error(`❌ ${file} imports from forbidden zone: ${dep}`)
        process.exit(1)
      }
    }
  }
}
console.log("✅ All dependency rules are satisfied")

Резюме

Структура проекта — это визуализация архитектуры в файловой системе. В гексагональной архитектуре с Effect-ts:

  • domain/ — чистое ядро, нулевые зависимости от инфраструктуры
  • ports/ — контракты (Context.Tag), определяющие границы
  • application/ — оркестрация Use Cases, реализация Driving Ports
  • adapters/ — конкретные технологии (Layer), реализация Driven Ports
  • composition/ — сборка всех Layer в граф зависимостей
  • main.tsEffect.runFork с полным Layer

Направление зависимостей строго одностороннее: adapters → ports → domain. Эти правила можно (и нужно) проверять автоматически через ESLint, TypeScript path aliases или кастомные скрипты.