Антипаттерны: утечка инфраструктуры в домен
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
}
}
Почему это плохо
- Todo знает о SQLite — если завтра нужен PostgreSQL, домен менять
- Невозможно тестировать без БД — unit-тест Todo требует реальную SQLite
- SQL в домене — бизнес-логика перемешана с инфраструктурой
- Нарушение 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
}
Что не так
id: number— домен используетnumber, потому что SQLite хранит auto-increment. А если завтра ID — UUID?due_date: string | null— snake_case иnull— это SQL-конвенции. В домене должно бытьdueDate: Option<Date>.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 |
| 3 | HTTP-типы в домене | req: Request, res: Response | Driving Port + HTTP Adapter |
| 4 | ORM-сущности = домен | @Entity() class Todo | Отдельный домен + маппер в адаптере |
| 5 | HTTP-коды в доменных ошибках | statusCode: 404 | Чистые доменные ошибки + маппинг в адаптере |
| 6 | Бизнес-логика в адаптере | if/else бизнес-правил в HTTP-роуте | Application Service + Domain |
| 7 | Бог-контекст | (ctx: AppContext) с 10+ зависимостями | Отдельные порты в R-канале |
| 8 | Фреймворк-зависимый домен | @injectable(), z.object() в домене | Effect Schema + Context DI |
| 9 | Kafka в доменных событиях | 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!
// Тесты запускаются за миллисекунды.
Если ваш доменный тест выглядит так — ваша архитектура чиста. Если нет — ищите утечку.