Структура проекта: как гексагон отражается в файловой системе
Эталонная файловая структура гексагонального проекта на 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
Правила:
-
Нулевые внешние зависимости —
domain/импортирует толькоeffect(дляEffect,Option,Schema,Data). Никаких@effect/platform,bun:sqlite,express,axios. -
Только бизнес-логика — если код не отвечает на вопрос о бизнесе («может ли задача быть завершена?», «валиден ли заголовок?»), ему не место в
domain/. -
Самодостаточность — домен компилируется и тестируется изолированно. Можно удалить все остальные папки, и
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/, effect | application/, adapters/ |
application/ | domain/, ports/, effect | adapters/ |
adapters/ | domain/, ports/, effect, внешние библиотеки | application/ |
composition/ | Всё | — |
main.ts | composition/ | — |
Соглашения об именовании файлов
Доменные модели: 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 Portsadapters/— конкретные технологии (Layer), реализация Driven Portscomposition/— сборка всехLayerв граф зависимостейmain.ts—Effect.runForkс полным Layer
Направление зависимостей строго одностороннее: adapters → ports → domain. Эти правила можно (и нужно) проверять автоматически через ESLint, TypeScript path aliases или кастомные скрипты.