Цели архитектуры: тестируемость, заменяемость, эволюционность
Архитектура — не самоцель, а средство достижения конкретных эксплуатационных свойств. Мы определим три главные цели — тестируемость без инфраструктуры, заменяемость адаптеров одной строкой, эволюционность без каскадных изменений — и покажем, как защитить их автоматическими fitness functions в CI/CD.
Архитектура — это не самоцель
Архитектура не нужна «ради архитектуры». Она нужна для достижения конкретных эксплуатационных свойств системы. Если архитектура не улучшает ни одного из этих свойств — она лишний overhead.
Три главные цели:
- Тестируемость — возможность убедиться, что система работает правильно, быстро и надёжно.
- Заменяемость — возможность менять части системы (технологии, адаптеры, библиотеки) без переписывания всего остального.
- Эволюционность — возможность развивать систему в ответ на новые требования без экспоненциального роста сложности.
Есть и другие свойства (масштабируемость, безопасность, производительность), но эти три — фундамент, без которого остальные недостижимы.
Цель 1: Тестируемость
Что значит «тестируемая система»
Тестируемая система — это система, в которой:
- Бизнес-правила можно проверить без поднятия инфраструктуры (базы данных, HTTP-сервера, файловой системы).
- Тесты бизнес-логики выполняются за миллисекунды, а не секунды.
- Адаптеры можно протестировать изолированно от бизнес-логики.
- Полный прогон тестов занимает минуты, а не часы.
- Тесты детерминированы: запускаются в любом порядке, не влияют друг на друга, дают одинаковый результат.
Почему плохая архитектура убивает тестируемость
В монолитном коде бизнес-правило переплетено с инфраструктурой, и протестировать одно без другого невозможно:
// ❌ Нетестируемый код: бизнес-правило вплетено в инфраструктуру
export async function completeTodo(id: string) {
const db = new Database("app.db") // Реальная БД
const todo = db.query("SELECT * FROM todos WHERE id = ?").get(id)
if (!todo) throw new Error("Not found")
if ((todo as any).done) throw new Error("Already done")
db.run("UPDATE todos SET done = 1 WHERE id = ?", [id])
await fetch("https://api.slack.com/notify", { // Реальный HTTP
method: "POST",
body: JSON.stringify({ text: `Todo ${id} completed` })
})
}
// Чтобы протестировать правило "нельзя завершить завершённую задачу":
// 1. Нужна реальная SQLite (или мок Database с 5+ методами)
// 2. Нужен mock/stub для fetch (или реальный Slack)
// 3. Тест зависит от порядка выполнения (нужно вставить данные ДО теста)
// 4. Тест медленный (I/O)
// 5. Тест недетерминирован (сеть может отказать)
Как Hexagonal делает код тестируемым
Разделение на домен, порты и адаптеры создаёт три уровня тестирования:
Уровень 1: Unit-тесты домена (мгновенные, чистые)
import { describe, it, expect } from "bun:test"
import { completeTodo, type Todo } from "../domain/todo"
describe("completeTodo", () => {
const activeTodo: Todo = {
id: "1" as TodoId,
title: "Buy milk",
status: "active",
createdAt: new Date("2025-01-01"),
completedAt: null,
}
it("should complete an active todo", () => {
const result = completeTodo(activeTodo, new Date("2025-01-15"))
expect(result.status).toBe("completed")
expect(result.completedAt).toEqual(new Date("2025-01-15"))
})
it("should not complete an already completed todo", () => {
const completed = { ...activeTodo, status: "completed" as const }
expect(() => completeTodo(completed, new Date())).toThrow()
})
})
// Время: < 1 мс на тест. Никакой инфраструктуры. Чистые функции.
Уровень 2: Тесты Use Case с тестовыми адаптерами (быстрые, изолированные)
import { Effect } from "effect"
import { completeTodoUseCase } from "../app/complete-todo-use-case"
import { TodoRepositoryTest } from "../adapters/test/todo-repo-test"
import { NotificationServiceTest } from "../adapters/test/notification-test"
describe("completeTodo use case", () => {
const testLayer = Layer.mergeAll(
TodoRepositoryTest, // In-memory Map вместо SQLite
NotificationServiceTest, // Запись в массив вместо Slack
)
it("should save completed todo and notify", async () => {
const result = await Effect.runPromise(
completeTodoUseCase("todo-1", new Date()).pipe(
Effect.provide(testLayer)
)
)
expect(result.status).toBe("completed")
})
})
// Время: < 5 мс на тест. In-memory, без I/O.
Уровень 3: Integration-тесты адаптеров (с реальной инфраструктурой)
import { Database } from "bun:sqlite"
import { createSqliteTodoRepo } from "../adapters/sqlite/todo-repo-sqlite"
describe("TodoRepositorySqlite", () => {
let db: Database
beforeEach(() => {
db = new Database(":memory:") // In-memory SQLite — быстро, изолированно
db.run("CREATE TABLE todos (...)")
})
afterEach(() => db.close())
it("should save and retrieve a todo", async () => {
const repo = createSqliteTodoRepo(db)
const todo = makeTodo({ id: "1", title: "Test" })
await repo.save(todo)
const found = await repo.findById("1")
expect(found).toEqual(todo)
})
})
// Время: ~10 мс. Реальный SQLite, но in-memory.
Метрика тестируемости
Практическая формула:
Testability Score = (число Unit-тестов домена × 1 мс + число Integration-тестов × 50 мс)
/ общее время прогона тестов
Цель: > 80% тестов — быстрые unit-тесты домена
Цель 2: Заменяемость
Что значит «заменяемая система»
Заменяемая система — это система, в которой:
- Замена базы данных (SQLite → PostgreSQL) затрагивает один файл-адаптер.
- Замена HTTP-фреймворка не требует изменений в бизнес-логике.
- Замена провайдера email-уведомлений — один новый адаптер + одна строка в конфигурации.
- Вся система может работать без любого конкретного адаптера (с заглушкой).
Заменяемость в действии
Представим: Todo-приложение начало жизнь с SQLite, но выросло и нуждается в PostgreSQL. В хорошей архитектуре это выглядит так:
Изменения при переходе SQLite → PostgreSQL:
❌ БЕЗ архитектуры:
├── domain/todo.ts (изменён — типы зависят от ORM)
├── domain/todo-service.ts (изменён — SQL в бизнес-логике)
├── routes/todos.ts (изменён — SQL в обработчиках)
├── routes/stats.ts (изменён — SQL для аналитики)
├── utils/db.ts (изменён — подключение)
├── test/todo.test.ts (изменён — тесты завязаны на SQLite)
└── test/stats.test.ts (изменён)
Итого: 7 файлов, все слои затронуты
✅ С Hexagonal Architecture:
├── adapters/postgres/todo-repo-pg.ts (добавлен — новый адаптер)
└── main.ts (изменён — одна строка: Layer)
Итого: 1 новый файл, 1 строка изменена
Механизм заменяемости в Effect
Заменяемость обеспечивается тем, что Layer — это «провод», соединяющий порт с адаптером. Замена провода не затрагивает ни порт, ни потребителей порта:
// main.ts — единственное место, где выбирается адаптер
// Вариант 1: SQLite (разработка)
const AppLayer = Layer.mergeAll(
TodoRepositorySqlite,
NotificationServiceConsole,
AuthServiceMock,
).pipe(
Layer.provide(SqliteClientLive)
)
// Вариант 2: PostgreSQL + real services (production)
const AppLayer = Layer.mergeAll(
TodoRepositoryPostgres,
NotificationServiceSlack,
AuthServiceJWT,
).pipe(
Layer.provide(PgClientLive),
Layer.provide(SlackClientLive),
)
// Вариант 3: всё в памяти (тесты)
const AppLayer = Layer.mergeAll(
TodoRepositoryInMemory,
NotificationServiceNoop,
AuthServiceAlwaysAllow,
)
// Бизнес-логика одна и та же во всех трёх случаях
const program = completeTodoUseCase("todo-1", new Date())
Effect.runPromise(program.pipe(Effect.provide(AppLayer)))
Матрица заменяемости
| Компонент | Заменяется на | Файлы затронуты |
|---|---|---|
| SQLite | PostgreSQL | 1 адаптер + 1 строка в main |
| REST API | GraphQL | 1 адаптер (новый роутер) |
| Email через SMTP | Email через SendGrid | 1 адаптер |
| JWT auth | OAuth2 | 1 адаптер |
| Console logger | Structured JSON logger | 1 Layer |
| Bun runtime | Node.js runtime | Адаптеры platform-specific |
Цель 3: Эволюционность
Что значит «эволюционирующая система»
Эволюционная архитектура (термин Нила Форда и Ребекки Парсонс) — архитектура, которая поддерживает управляемые инкрементальные изменения в ответ на новые требования.
Признаки эволюционной системы:
- Добавление новой фичи не требует изменения существующего кода (Open/Closed Principle).
- Новая фича затрагивает предсказуемый набор файлов.
- Время на добавление N-й фичи сопоставимо с временем на (N-1)-ю фичу.
- Система может расти от 1 до 100 фич без архитектурного кризиса.
Эволюция в Hexagonal: добавление новой фичи
Допустим, в Todo-приложение нужно добавить теги (tags). В Hexagonal Architecture это означает:
Шаг 1: Расширить домен
domain/tag.ts — новый Value Object
domain/todo.ts — добавить поле tags: ReadonlyArray<Tag>
Шаг 2: Расширить порт (если нужно)
ports/todo-repository.ts — добавить findByTag(tag: Tag)
Шаг 3: Обновить адаптеры
adapters/sqlite/todo-repo-sqlite.ts — новый SQL-запрос
Шаг 4: Добавить Use Case
app/add-tag-to-todo.ts — новый Use Case
Шаг 5: Добавить HTTP-маршрут
adapters/http/todo-routes.ts — POST /todos/:id/tags
Каждый шаг затрагивает один слой. Изменения не каскадируют. Существующие тесты продолжают проходить (тег — опциональное поле).
Fitness Functions: автоматическая проверка архитектуры
Эволюционность можно защитить автоматическими проверками — fitness functions:
// test/architecture.test.ts — fitness functions
import { describe, it, expect } from "bun:test"
import { readdir } from "fs/promises"
import { join } from "path"
describe("Architecture Fitness Functions", () => {
it("domain/ should not import from adapters/", async () => {
const domainFiles = await getTypeScriptFiles("src/domain")
for (const file of domainFiles) {
const content = await Bun.file(file).text()
expect(content).not.toMatch(/from\s+["'].*adapters/)
expect(content).not.toMatch(/from\s+["']bun:sqlite/)
expect(content).not.toMatch(/from\s+["']express/)
}
})
it("domain/ should not import from app/", async () => {
const domainFiles = await getTypeScriptFiles("src/domain")
for (const file of domainFiles) {
const content = await Bun.file(file).text()
expect(content).not.toMatch(/from\s+["'].*\/app\//)
}
})
it("ports/ should only import from domain/", async () => {
const portFiles = await getTypeScriptFiles("src/ports")
for (const file of portFiles) {
const content = await Bun.file(file).text()
const imports = content.match(/from\s+["']([^"']+)["']/g) ?? []
for (const imp of imports) {
expect(imp).toMatch(/domain|effect|@effect/)
}
}
})
it("each module should have < 300 lines", async () => {
const allFiles = await getTypeScriptFiles("src")
for (const file of allFiles) {
const content = await Bun.file(file).text()
const lines = content.split("\n").length
expect(lines).toBeLessThan(300)
}
})
it("cyclomatic complexity of domain functions < 10", async () => {
// Интеграция с ESLint или custom анализатор
// ...
})
})
Fitness functions запускаются в CI/CD и ловят нарушения архитектуры до того, как они попадут в main.
Дополнительные цели
Понятность (Comprehensibility)
Система понятна, если новый разработчик может за разумное время (1–3 дня) разобраться, как она устроена, и внести осмысленное изменение.
Hexagonal Architecture помогает понятности через:
- Предсказуемую структуру папок:
domain/,ports/,adapters/,app/— каждый разработчик знает, где искать бизнес-логику, где контракты, где реализации. - Явные зависимости: R-канал Effect-а показывает все зависимости функции прямо в сигнатуре — не нужно читать тело функции.
- Разделение ответственностей: каждый файл отвечает за одну вещь — не нужно держать в голове весь контекст.
Разворачиваемость (Deployability)
Система легко разворачивается, если:
- Конфигурация отделена от кода (Effect Config).
- Адаптеры выбираются через конфигурацию, а не через изменение кода.
- Healthcheck и graceful shutdown встроены (Effect Runtime).
- Миграции БД автоматизированы и идемпотентны.
Наблюдаемость (Observability)
В Hexagonal Architecture наблюдаемость — это ещё один адаптер:
// Наблюдаемость через Layer — подключается без изменения бизнес-логики
const ObservabilityLayer = Layer.mergeAll(
LoggerLayer, // Effect.Logger → JSON structured logs
TracingLayer, // Effect.Tracer → OpenTelemetry spans
MetricsLayer, // Effect.Metric → Prometheus counters
)
// Бизнес-логика не знает о логах, трейсах и метриках
// Они добавляются при сборке:
const ProductionApp = AppLayer.pipe(
Layer.provide(ObservabilityLayer)
)
Компромиссы: когда архитектура избыточна
Честный разговор: архитектура — не серебряная пуля. Есть случаи, когда Hexagonal Architecture избыточна:
Одноразовые скрипты. Если вы пишете скрипт для однократной миграции данных — файловая структура из 10 папок не нужна.
Прототипы. Если цель — проверить гипотезу за 2 дня и выбросить код — архитектура замедлит вас.
Микроскопические сервисы. Если ваш сервис — 100 строк, один endpoint, один запрос к БД — структура «домен, порты, адаптеры» будет boilerplate без содержания.
Правило большого пальца:
Если проект будет жить > 3 месяцев
И его будут поддерживать > 1 человек
И он содержит > 1 бизнес-правило
→ Архитектура окупится
Иначе → начните просто, рефакторьте при росте
Но даже в простых проектах стоит соблюдать базовые принципы: иммутабельность, разделение чистых функций и побочных эффектов, явные зависимости. Effect-ts даёт их «бесплатно» — это не overhead, а стиль написания кода.
Связь целей с Hexagonal Architecture
| Цель | Как достигается в Hexagonal | Инструмент Effect |
|---|---|---|
| Тестируемость | Домен без зависимостей → unit-тесты за мс | Effect.runSync в тестах |
| Заменяемость | Порт = абстракция, адаптер = реализация | Layer для подмены |
| Эволюционность | Новая фича = новый Use Case + адаптеры | Композиция Layer.mergeAll |
| Понятность | Предсказуемая структура | R-канал как документация |
| Разворачиваемость | Конфигурация через порты | Effect.Config |
| Наблюдаемость | Логи/трейсы/метрики как адаптеры | Effect.Logger, Tracer, Metric |
Ключевые выводы
- Архитектура — средство, не цель. Она нужна для конкретных свойств: тестируемость, заменяемость, эволюционность.
- Тестируемость: домен тестируется за миллисекунды без инфраструктуры. 80%+ тестов — быстрые unit-тесты.
- Заменяемость: замена технологии затрагивает один адаптер и одну строку в точке сборки.
- Эволюционность: новая фича — предсказуемый набор изменений, не каскад по всей системе.
- Fitness functions защищают архитектуру автоматически, в CI/CD.
- Архитектура не всегда нужна — для скриптов и прототипов она избыточна. Но для долгоживущих систем она окупается за 2–4 месяца.
- Effect-ts даёт эти свойства на уровне типов: R-канал = явные зависимости, Layer = заменяемость, чистые функции = тестируемость.