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

Антипаттерны: утечка инфраструктуры в домен

11 антипаттернов нарушения гексагональных границ — прямой импорт инфраструктуры, типы привязанные к формату БД, HTTP-типы в домене, ORM-сущности как доменные модели, HTTP-коды в ошибках, бизнес-логика в адаптерах, бог-контекст, фреймворк-зависимый домен, инфраструктура в событиях, транзитивные зависимости, конфигурация в домене. Чеклист обнаружения и скрипт автоматической проверки.

Введение: что такое «утечка инфраструктуры»

Утечка инфраструктуры (Infrastructure Leak) — это ситуация, когда детали реализации внешних технологий (БД, HTTP, файловая система, фреймворки) проникают в доменное ядро приложения.

Утечка бывает:

  • Явная — прямой import из инфраструктурной библиотеки в доменном файле
  • Неявная — доменные типы «подстраиваются» под формат БД или HTTP
  • Структурная — архитектурные решения, которые делают домен зависимым от инфраструктуры

Каждая утечка увеличивает связанность (coupling) между ядром и инфраструктурой, делая систему хрупкой: изменение БД требует изменения бизнес-логики, замена HTTP-фреймворка ломает домен, тесты невозможны без поднятия инфраструктуры.


Антипаттерн 1: Прямой импорт инфраструктуры в домен

Симптом

// ❌ ПЛОХО: src/domain/model/todo.ts
import { Database } from "bun:sqlite"  // ← УТЕЧКА!

export class Todo {
  // ...
  
  save(db: Database) {            // ← домен знает о SQLite!
    db.run(
      `INSERT INTO todos VALUES (?, ?)`,
      [this.id, this.title]
    )
  }
  
  static findById(db: Database, id: string): Todo | null {
    const row = db.query(`SELECT * FROM todos WHERE id = ?`).get(id)
    return row ? new Todo(row) : null
  }
}

Почему это плохо

  1. Todo знает о SQLite — если завтра нужен PostgreSQL, домен менять
  2. Невозможно тестировать без БД — unit-тест Todo требует реальную SQLite
  3. SQL в домене — бизнес-логика перемешана с инфраструктурой
  4. Нарушение SRP — Entity одновременно отвечает за бизнес-правила И за персистентность

Правильный подход

// ✅ ХОРОШО: src/domain/model/todo.ts
import { Schema, Effect, Option } from "effect"
// Никаких импортов из bun:sqlite, @effect/platform и т.д.

export class Todo extends Schema.Class<Todo>("Todo")({
  id: Schema.String,
  title: TodoTitle,
  status: TodoStatus,
  // ...
}) {
  // Только бизнес-логика!
  readonly complete = (now: Date): Effect.Effect<Todo, InvalidTransitionError> => {
    // ...чистая бизнес-логика...
  }
}

// Персистентность — в ПОРТЕ и АДАПТЕРЕ
// src/ports/driven/todo-repository.ts
export class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    readonly save: (todo: Todo) => Effect.Effect<void, PersistenceError>
    readonly findById: (id: TodoId) => Effect.Effect<Todo, TodoNotFoundError>
  }
>() {}

Антипаттерн 2: Доменные типы, привязанные к формату хранения

Симптом

// ❌ ПЛОХО: src/domain/model/todo.ts
export interface Todo {
  id: number                    // ← auto-increment из SQL!
  title: string
  status: string
  due_date: string | null       // ← snake_case из SQL!
  created_at: string            // ← ISO string из SQL (Date не поддерживается)
  updated_at: string
}

Что не так

  1. id: number — домен использует number, потому что SQLite хранит auto-increment. А если завтра ID — UUID?
  2. due_date: string | null — snake_case и null — это SQL-конвенции. В домене должно быть dueDate: Option<Date>.
  3. created_at: string — ISO-строка, потому что SQLite не имеет типа Date. Домен должен работать с Date.

Правильный подход

// ✅ ХОРОШО: src/domain/model/todo.ts
// Доменные типы отражают БИЗНЕС, а не БД

export class Todo extends Schema.Class<Todo>("Todo")({
  id: TodoIdSchema,                                    // Branded type, не number
  title: TodoTitle,                                     // Value Object, не string
  status: TodoStatus,                                   // Value Object, не string
  priority: Priority,                                   // Value Object, не string
  dueDate: Schema.OptionFromNullOr(Schema.DateFromSelf), // Option<Date>, не string|null
  createdAt: Schema.DateFromSelf,                       // Date, не string
  updatedAt: Schema.DateFromSelf,                       // Date, не string
}) {}

// Маппинг Domain ↔ SQL — в АДАПТЕРЕ
// src/adapters/driven/sqlite/mappers/todo.mapper.ts
interface TodoRow {
  id: string               // Domain TodoId → SQL string
  title: string            // Domain TodoTitle → SQL string
  due_date: string | null  // Domain Option<Date> → SQL string | null
  created_at: string       // Domain Date → SQL ISO string
}

Антипаттерн 3: HTTP-типы в домене

Симптом

// ❌ ПЛОХО: src/domain/services/create-todo.ts
import { Request, Response } from "express"  // ← УТЕЧКА Express!

export const createTodo = (req: Request, res: Response) => {
  const { title, priority } = req.body      // ← HTTP-специфика
  
  if (!title) {
    return res.status(400).json({ error: "Title required" })  // ← HTTP в домене!
  }
  
  const todo = new Todo(title, priority)
  // ... save ...
  
  return res.status(201).json(todo)          // ← HTTP-код в бизнес-логике!
}

Почему это плохо

  • Домен привязан к Express. Замена на Fastify, Hono или Effect HttpServer требует переписывания бизнес-логики.
  • req.body — нетипизированный any. Валидация ручная и хрупкая.
  • HTTP-коды (400, 201) — деталь презентационного слоя, не бизнеса.
  • Невозможно вызвать эту логику из CLI, gRPC или теста без HTTP-контекста.

Правильный подход

// ✅ ХОРОШО: Домен не знает о HTTP

// src/domain/model/todo.ts — чистая бизнес-логика
export class Todo {
  static create(params: { title: TodoTitle; priority: Priority }) { /* ... */ }
}

// src/ports/driving/create-todo.ts — контракт
export class CreateTodoUseCase extends Context.Tag("CreateTodoUseCase")<
  CreateTodoUseCase,
  {
    execute: (input: CreateTodoInput) => Effect.Effect<Todo, TodoValidationError>
  }
>() {}

// src/adapters/driving/http/routes/todo.routes.ts — HTTP знает о HTTP
HttpRouter.post("/api/todos",
  Effect.gen(function* () {
    const body = yield* HttpServerRequest.schemaBodyJson(CreateTodoRequestBody)
    const useCase = yield* CreateTodoUseCase
    const todo = yield* useCase.execute(mapToInput(body))
    return yield* HttpServerResponse.json(TodoResponseBody.fromDomain(todo), { status: 201 })
  })
)

Антипаттерн 4: ORM-сущности как доменные модели

Симптом (Prisma, TypeORM, Drizzle)

// ❌ ПЛОХО: ORM-модель используется как доменная
import { Entity, Column, PrimaryGeneratedColumn } from "typeorm"

@Entity()                                   // ← ORM-декоратор в домене!
export class Todo {
  @PrimaryGeneratedColumn()                 // ← Деталь SQL-схемы
  id!: number
  
  @Column({ length: 200 })                  // ← SQL constraint в домене
  title!: string
  
  @Column({ type: "varchar", nullable: true }) // ← SQL type в домене
  dueDate!: string | null
  
  // Бизнес-метод
  complete() {
    this.status = "completed"
  }
}

Почему это плохо

  • Доменная модель = ORM-модель — одна сущность на две ответственности
  • ORM-декораторы определяют SQL-схему внутри домена
  • Типы привязаны к БД (number для id, string | null для nullable)
  • Мутабельность: this.status = "completed" — ORM требует мутации
  • Замена ORM (TypeORM → Drizzle) требует переписывания домена

Правильный подход с Effect-ts

// ✅ ХОРОШО: Домен — чистые иммутабельные типы
// src/domain/model/todo.ts
export class Todo extends Schema.Class<Todo>("Todo")({
  id: TodoIdSchema,
  title: TodoTitle,
  status: TodoStatus,
  dueDate: Schema.OptionFromNullOr(Schema.DateFromSelf),
  createdAt: Schema.DateFromSelf,
}) {
  readonly complete = (now: Date): Effect.Effect<Todo, InvalidTransitionError> =>
    // Иммутабельное обновление
    Effect.succeed(new Todo({ ...this, status: TodoStatus.make("completed"), updatedAt: now }))
}

// ORM/SQL — ТОЛЬКО в адаптере
// src/adapters/driven/sqlite/repositories/todo.repository.sqlite.ts
// SQL-запросы, маппинг, декораторы — всё здесь, НЕ в домене

Антипаттерн 5: Доменные ошибки, привязанные к HTTP

Симптом

// ❌ ПЛОХО: src/domain/errors/todo-errors.ts
export class TodoNotFoundError extends Error {
  readonly statusCode = 404        // ← HTTP-код в доменной ошибке!
  readonly httpMessage = "Not Found" // ← HTTP-текст в домене!
  
  constructor(public readonly todoId: string) {
    super(`Todo ${todoId} not found`)
  }
}

export class TodoValidationError extends Error {
  readonly statusCode = 422        // ← HTTP-код в домене!
  
  constructor(public readonly errors: Array<{ field: string; message: string }>) {
    super("Validation failed")
  }
}

Почему это плохо

  • HTTP-код 404 — деталь HTTP-протокола. Домен не знает о HTTP.
  • Если приложение вызывается через CLI, что значит statusCode: 404?
  • Если API переходит на gRPC, коды состояния другие.
  • Привязка к HTTP делает доменные ошибки бесполезными в не-HTTP-контекстах.

Правильный подход

// ✅ ХОРОШО: src/domain/errors/todo-errors.ts
// Доменные ошибки описывают БИЗНЕС-ситуации, не HTTP-ответы

export class TodoNotFoundError extends Data.TaggedError("TodoNotFoundError")<{
  readonly todoId: string
  // Нет statusCode! Нет httpMessage! Только бизнес-контекст.
}> {}

export class TodoValidationError extends Data.TaggedError("TodoValidationError")<{
  readonly field: string
  readonly message: string
}> {}

export class InvalidTransitionError extends Data.TaggedError("InvalidTransitionError")<{
  readonly from: string
  readonly to: string
  readonly reason: string
}> {}
// Маппинг Error → HTTP — в HTTP-АДАПТЕРЕ
// src/adapters/driving/http/middleware/error-handler.ts

export const errorToHttpStatus = (error: DomainError): number => {
  switch (error._tag) {
    case "TodoNotFoundError":      return 404
    case "TodoValidationError":    return 422
    case "InvalidTransitionError": return 409
    case "PersistenceError":       return 500
    default:                       return 500
  }
}

Антипаттерн 6: Бизнес-логика в адаптере

Симптом

// ❌ ПЛОХО: src/adapters/driving/http/routes/todo.routes.ts
HttpRouter.post("/api/todos",
  Effect.gen(function* () {
    const body = yield* HttpServerRequest.schemaBodyJson(CreateTodoSchema)
    const repo = yield* TodoRepository
    
    // Бизнес-логика в HTTP-адаптере!
    if (body.title.length < 1) {                    // ← Валидация в адаптере
      return yield* HttpServerResponse.json(
        { error: "Title too short" },
        { status: 422 }
      )
    }
    
    // Проверка дубликатов — бизнес-правило в адаптере!
    const existing = yield* repo.findByTitle(body.title)
    if (existing) {                                  // ← Бизнес-правило в адаптере!
      return yield* HttpServerResponse.json(
        { error: "Duplicate title" },
        { status: 409 }
      )
    }
    
    // Вычисление приоритета — бизнес-логика в адаптере!
    const priority = body.dueDate && new Date(body.dueDate) < tomorrow
      ? "high"                                       // ← Бизнес-логика в адаптере!
      : body.priority
    
    const todo = new Todo({ ...body, priority })
    yield* repo.save(todo)
    return yield* HttpServerResponse.json(todo, { status: 201 })
  })
)

Почему это плохо

  • Валидация, проверка дубликатов, вычисление приоритета — это бизнес-логика, а не HTTP-логика
  • Если добавить CLI-адаптер, эту логику придётся дублировать
  • Бизнес-правила разбросаны по адаптерам — сложно найти и понять

Правильный подход

// ✅ ХОРОШО: Бизнес-логика в домене и Application Service

// Валидация — в Value Object
class TodoTitle {
  static make(raw: string): Effect.Effect<TodoTitle, TodoValidationError> {
    // Минимальная длина, максимальная длина, трим — здесь
  }
}

// Проверка дубликатов — в Application Service
const CreateTodoHandlerLive = Layer.effect(
  CreateTodoUseCase,
  Effect.gen(function* () {
    const repo = yield* TodoRepository
    return CreateTodoUseCase.of({
      execute: (input) =>
        Effect.gen(function* () {
          // Проверка дубликатов — бизнес-правило
          const exists = yield* repo.existsByTitle(input.title)
          if (exists) {
            return yield* Effect.fail(new DuplicateTitleError({ title: input.title }))
          }
          // ...
        }),
    })
  })
)

// HTTP-адаптер — ТОЛЬКО HTTP-логика
HttpRouter.post("/api/todos",
  Effect.gen(function* () {
    const body = yield* HttpServerRequest.schemaBodyJson(CreateTodoRequestBody)
    const useCase = yield* CreateTodoUseCase
    const todo = yield* useCase.execute(mapToInput(body))
    return yield* HttpServerResponse.json(TodoResponseBody.fromDomain(todo), { status: 201 })
  })
)

Антипаттерн 7: Сквозные зависимости через «бог-объект»

Симптом

// ❌ ПЛОХО: «Бог-контекст» — всё в одном объекте
interface AppContext {
  db: Database             // SQLite
  redis: RedisClient       // Кеш
  mailer: SmtpClient       // Email
  logger: Logger           // Логирование
  config: AppConfig        // Конфигурация
  auth: AuthService        // Авторизация
  fileSystem: FileSystem   // Файлы
}

// Каждая функция получает ВСЁ
const createTodo = (ctx: AppContext, input: CreateTodoInput) => {
  ctx.logger.info("Creating todo")          // Нужен только logger
  const todo = new Todo(input)
  ctx.db.run("INSERT INTO todos ...")       // Нужен только db
  ctx.mailer.send("Todo created!")          // Нужен только mailer
  return todo
}

// Проблема: createTodo зависит от Redis, Auth, FileSystem,
// хотя не использует их. Невозможно понять реальные зависимости.

Почему это плохо

  • Скрытые зависимости — сигнатура (ctx: AppContext) не говорит, что реально нужно
  • Невозможно протестировать точечно — нужно создавать ВСЕ зависимости для теста
  • Бог-объект — анти-паттерн, аналогичный god class в ООП

Правильный подход с Effect-ts

// ✅ ХОРОШО: Каждая зависимость — отдельный порт в R-канале

const createTodo = (input: CreateTodoInput) =>
  Effect.gen(function* () {
    yield* Effect.log("Creating todo")              // Effect.log — встроенный
    const repo = yield* TodoRepository               // Только то, что нужно
    const todo = Todo.create(input)
    yield* repo.save(todo)
    return todo
  })

// Тип ЯВНО показывает зависимости:
// Effect<Todo, PersistenceError, TodoRepository>
//                                ^^^^^^^^^^^^^^^
//                    Только TodoRepository, ничего лишнего!

// В тесте — предоставляем ТОЛЬКО нужное:
const testResult = createTodo(input).pipe(
  Effect.provide(TodoRepositoryInMemoryLive)   // Только InMemory repo
)

Антипаттерн 8: Фреймворк-зависимый домен

Симптом

// ❌ ПЛОХО: src/domain/model/todo.ts
import { z } from "zod"              // ← Привязка к Zod!
import { injectable } from "tsyringe" // ← Привязка к DI-контейнеру!

const TodoSchema = z.object({         // ← Zod в домене
  title: z.string().min(1).max(200),
  priority: z.enum(["low", "medium", "high"]),
})

@injectable()                          // ← DI-декоратор в домене!
export class TodoService {
  constructor(
    private repo: TodoRepository,     // ← Конструкторная инъекция
    private logger: Logger,
  ) {}
  
  create(input: z.infer<typeof TodoSchema>) {
    const parsed = TodoSchema.parse(input) // ← Zod в бизнес-методе
    // ...
  }
}

Почему это плохо

  • Замена Zod на Effect Schema требует изменения домена
  • Замена tsyringe на другой DI-контейнер ломает домен
  • @injectable() — runtime-зависимость, которая тянет весь фреймворк

Правильный подход

В Effect-ts эта проблема не существует, потому что:

  • Schema — часть Effect (не внешняя зависимость)
  • DI — через Context/Layer (не через декораторы)
  • Нет @injectable(), @inject(), constructor() DI
// ✅ ХОРОШО: Домен использует только Effect (единственная зависимость)
import { Schema, Effect, Data } from "effect"

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

// DI — через R-канал, не через конструкторы
const createTodo = (input: CreateTodoInput) =>
  Effect.gen(function* () {
    const repo = yield* TodoRepository  // Context-based DI
    // ...
  })

Антипаттерн 9: Утечка через типы событий

Симптом

// ❌ ПЛОХО: src/domain/events/todo-created.ts
import { KafkaMessage } from "kafkajs"  // ← Kafka в доменном событии!

export interface TodoCreatedEvent {
  type: "todo.created"
  payload: {
    todoId: string
    title: string
  }
  // Kafka-специфичные поля в доменном событии!
  partition: number          // ← Kafka-деталь
  offset: string             // ← Kafka-деталь
  headers: Record<string, string>  // ← Kafka-деталь
}

Правильный подход

// ✅ ХОРОШО: Доменное событие — чистый факт бизнеса
// src/domain/events/todo-created.ts
import { Schema } from "effect"

export class TodoCreated extends Schema.TaggedClass<TodoCreated>()(
  "TodoCreated",
  {
    todoId: Schema.String,
    title: Schema.String,
    occurredAt: Schema.DateFromSelf,
    // Нет partition, offset, headers — это Kafka-детали
  }
) {}

// Kafka-специфика — в АДАПТЕРЕ
// src/adapters/driven/kafka/event-publisher.kafka.ts
const publishToKafka = (event: TodoCreated) => ({
  topic: "todo-events",
  messages: [{
    key: event.todoId,
    value: JSON.stringify(Schema.encodeSync(TodoCreated)(event)),
    headers: { "event-type": "TodoCreated" },
    // partition, offset — управляются Kafka, не доменом
  }],
})

Антипаттерн 10: Нарушение Dependency Rule через транзитивные зависимости

Симптом

// src/domain/model/todo.ts
import { validateEmail } from "../../utils/validators.js"  // ← Вроде OK...

// Но!
// src/utils/validators.ts
import { Pool } from "pg"  // ← utils зависит от PostgreSQL!

export const validateEmail = async (email: string) => {
  // Проверяет уникальность email в БД! 
  const pool = new Pool()
  const result = await pool.query("SELECT * FROM users WHERE email = $1", [email])
  return result.rows.length === 0
}

Почему это опасно

Прямой импорт выглядит безобидно (../../utils/validators.js), но транзитивно домен оказывается зависимым от PostgreSQL. import — это не просто текст; это граф зависимостей.

Как обнаружить

# Проверка транзитивных зависимостей
# Если domain/ транзитивно зависит от pg, bun:sqlite и т.д. — это утечка
bun build src/domain/index.ts --external effect --dry-run 2>&1 | grep -E "pg|sqlite|express"

Правильный подход

  • utils/ не должен содержать инфраструктурный код
  • Если валидация требует БД — это не доменная валидация, а Application-уровень
  • Используйте порты для проверок, требующих инфраструктуру:
// ✅ ХОРОШО: Уникальность — через порт
const createTodo = (input: CreateTodoInput) =>
  Effect.gen(function* () {
    const repo = yield* TodoRepository
    const exists = yield* repo.existsByTitle(input.title)
    if (exists) {
      return yield* Effect.fail(new DuplicateTitleError({ title: input.title }))
    }
    // ...
  })

Антипаттерн 11: Конфигурация, зашитая в домен

Симптом

// ❌ ПЛОХО: src/domain/model/todo.ts
const MAX_TODOS_PER_USER = parseInt(process.env.MAX_TODOS ?? "100")  // ← env в домене!
const DEFAULT_PRIORITY = process.env.DEFAULT_PRIORITY ?? "medium"    // ← env в домене!

export class TodoList {
  addTodo(todo: Todo) {
    if (this.todos.length >= MAX_TODOS_PER_USER) {  // ← конфиг в бизнес-логике
      throw new Error("Too many todos")
    }
  }
}

Почему это плохо

  • process.env — побочный эффект, зависящий от среды запуска
  • Домен невозможно тестировать с разными конфигурациями без изменения env
  • Бизнес-правило «максимум N задач» зашито и не может быть изменено

Правильный подход

// ✅ ХОРОШО: Конфигурация — через порт или параметр

// Вариант A: Параметр бизнес-правила передаётся явно
export class TodoList {
  readonly addTodo = (todo: Todo, maxTodos: number): Effect.Effect<TodoList, TooManyTodosError> =>
    this.todos.length >= maxTodos
      ? Effect.fail(new TooManyTodosError({ current: this.todos.length, max: maxTodos }))
      : Effect.succeed(new TodoList({ ...this, todos: [...this.todos, todo] }))
}

// Вариант B: Конфигурация — через порт
class TodoLimitsConfig extends Context.Tag("TodoLimitsConfig")<
  TodoLimitsConfig,
  {
    readonly maxTodosPerUser: number
    readonly defaultPriority: PriorityValue
  }
>() {}

// Application Service читает конфиг из порта
const addTodoHandler = Effect.gen(function* () {
  const config = yield* TodoLimitsConfig
  const list = yield* getCurrentList()
  return yield* list.addTodo(newTodo, config.maxTodosPerUser)
})

Чеклист: обнаружение утечек

Используйте этот чеклист для code review:

Проверка src/domain/

#ПроверкаПравило
1Поиск инфраструктурных импортовdomain/ не импортирует из bun:sqlite, @effect/platform, express, ORM-библиотек
2Поиск HTTP-терминовНет statusCode, request, response, header, cookie в доменных типах
3Поиск SQL-терминовНет query, INSERT, SELECT, table, column, migration в домене
4Поиск process.envНет чтения переменных окружения в домене
5Поиск Date.now() / new Date()Время получается через порт (Clock), а не напрямую
6Поиск crypto.randomUUID()ID генерируются через порт (IdGenerator), а не напрямую
7Проверка типовДоменные типы используют Option<Date>, а не string | null
8МутабельностьДоменные Entity — иммутабельные (readonly, новый объект при изменении)

Проверка src/adapters/

#ПроверкаПравило
1Бизнес-логика в адаптерахHTTP-роуты не содержат if/else бизнес-правил
2Маппинг на границеКаждый адаптер имеет маппер (Domain ↔ Infrastructure)
3Ошибки маппятсяИнфраструктурные ошибки маппятся в доменные
4Детали не утекаютSQL-ошибки не доходят до клиента

Автоматическая проверка (скрипт)

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

const FORBIDDEN_IN_DOMAIN = [
  // Инфраструктурные библиотеки
  "bun:sqlite", "@effect/platform", "express", "fastify", "hono",
  "typeorm", "prisma", "drizzle", "knex",
  "kafkajs", "amqplib", "ioredis",
  
  // HTTP-термины
  "statusCode", "HttpRequest", "HttpResponse",
  
  // Побочные эффекты
  "process.env", "Date.now()", "Math.random()",
  "console.log", "console.error",
  
  // Мутация
  "let ", // (грубая проверка, но ловит очевидные случаи)
] as const

const domainFiles = new Glob("src/domain/**/*.ts")

let violations = 0

for await (const file of domainFiles.scan(".")) {
  const content = await Bun.file(file).text()
  
  for (const forbidden of FORBIDDEN_IN_DOMAIN) {
    if (content.includes(forbidden)) {
      console.error(`❌ ${file}: contains "${forbidden}"`)
      violations++
    }
  }
}

if (violations > 0) {
  console.error(`\n❌ Found ${violations} domain purity violations`)
  process.exit(1)
} else {
  console.log("✅ Domain is pure — no infrastructure leaks detected")
}

Сводная таблица антипаттернов

#АнтипаттернСимптомРешение
1Прямой импорт инфраструктурыimport { Database } from "bun:sqlite" в доменеПорт (Context.Tag) + Адаптер (Layer)
2Типы привязаны к формату БДdue_date: string | null, id: numberДоменные Value Objects: Option<Date>, TodoId
3HTTP-типы в доменеreq: Request, res: ResponseDriving Port + HTTP Adapter
4ORM-сущности = домен@Entity() class TodoОтдельный домен + маппер в адаптере
5HTTP-коды в доменных ошибкахstatusCode: 404Чистые доменные ошибки + маппинг в адаптере
6Бизнес-логика в адаптереif/else бизнес-правил в HTTP-роутеApplication Service + Domain
7Бог-контекст(ctx: AppContext) с 10+ зависимостямиОтдельные порты в R-канале
8Фреймворк-зависимый домен@injectable(), z.object() в доменеEffect Schema + Context DI
9Kafka в доменных событияхpartition, offset в EventЧистое событие + Kafka-адаптер
10Транзитивные зависимостиutils/ тянет PostgreSQLПроверка графа зависимостей
11Конфиг в доменеprocess.env в бизнес-логикеConfig порт или параметр

Золотое правило

Если для unit-тестирования доменного кода вам нужно поднимать БД, HTTP-сервер или любую инфраструктуру — у вас утечка инфраструктуры в домен.

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

// Тест домена — чистый, мгновенный, без инфраструктуры
import { describe, test, expect } from "bun:test"
import { Effect } from "effect"
import { Todo, TodoTitle, Priority, TodoStatus } from "../src/domain/index.js"

describe("Todo Entity", () => {
  test("create: should set status to pending", () => {
    const title = new TodoTitle({ value: "Test" })
    const priority = Priority.make("high")
    const todo = Todo.create({
      id: "test-id" as TodoId,
      title,
      priority,
      dueDate: Option.none(),
      now: new Date("2025-01-01"),
    })
    
    expect(todo.status.value).toBe("pending")
  })
  
  test("complete: should transition to completed", async () => {
    const todo = makeTodo({ status: "pending" })
    const result = await Effect.runPromise(todo.complete(new Date()))
    
    expect(result.status.value).toBe("completed")
  })
  
  test("complete: should fail for already completed todo", async () => {
    const todo = makeTodo({ status: "completed" })
    const result = await Effect.runPromiseExit(todo.complete(new Date()))
    
    expect(result._tag).toBe("Failure")
  })
})
// Ни одного import из bun:sqlite, @effect/platform, или HTTP!
// Тесты запускаются за миллисекунды.

Если ваш доменный тест выглядит так — ваша архитектура чиста. Если нет — ищите утечку.