Coupling и Cohesion
Два фундаментальных свойства, определяющих качество архитектуры. Мы разберём полный спектр coupling — от патологического Content до идеального Data — и полный спектр cohesion — от случайного Coincidental до целевого Functional. Вы научитесь диагностировать проблемы по коду и увидите, как Effect-ts делает coupling явным через систему типов.
Фундаментальный принцип
Если вся наука об архитектуре ПО сводится к одной фразе, то это:
Стремитесь к низкой связанности (low coupling) между модулями и высокой связности (high cohesion) внутри модулей.
Это два противоположных, но дополняющих друг друга свойства. Coupling описывает, насколько сильно модули зависят друг от друга. Cohesion описывает, насколько элементы внутри одного модуля связаны общей целью. Идеальный модуль — это чёрный ящик: всё внутри тесно связано общим назначением (high cohesion), а снаружи он взаимодействует с другими модулями через минимальный, хорошо определённый интерфейс (low coupling).
Coupling: связанность между модулями
Определение
Coupling — степень взаимозависимости между двумя модулями. Если изменение в модуле A требует изменений в модуле B — они связаны. Чем больше изменений требует — тем сильнее связь.
Спектр coupling: от худшего к лучшему
Ларри Константайн и Эдвард Юрдон классифицировали связанность по убыванию вредности (1979):
1. Content Coupling (патологическая) — НАИХУДШАЯ
Один модуль напрямую обращается к внутренностям другого: читает приватные данные, модифицирует внутреннее состояние, прыгает в середину кода другого модуля.
// ❌ Content Coupling: прямой доступ к внутренностям
class TodoService {
private todos: Map<string, Todo> = new Map()
addTodo(todo: Todo) {
this.todos.set(todo.id, todo)
}
}
class ReportService {
constructor(private todoService: TodoService) {}
getReport() {
// Обращаемся напрямую к приватному полю (через any или приведение)
const todos = (this.todoService as any).todos // ← Content coupling!
return [...todos.values()].filter(t => t.done).length
}
}
В TypeScript content coupling часто реализуется через as any, через прямой доступ к _private полям или через обращение к объектам по индексу.
2. Common Coupling (глобальная)
Два модуля используют общее глобальное состояние. Изменение состояния одним модулем неявно влияет на другой.
// ❌ Common Coupling: глобальное состояние
// globals.ts
export const globalState = {
currentUser: null as User | null,
dbConnection: null as Database | null,
config: {} as Record<string, string>,
}
// todo-service.ts
import { globalState } from "./globals"
function createTodo(title: string) {
if (!globalState.currentUser) throw new Error("Not logged in")
globalState.dbConnection!.run("INSERT ...", [title, globalState.currentUser.id])
}
// auth-service.ts
import { globalState } from "./globals"
function login(user: User) {
globalState.currentUser = user // Мутация, влияющая на todo-service
}
Здесь todo-service и auth-service не импортируют друг друга, но связаны через globalState. Это неявная и опасная связь: изменение порядка вызовов login → createTodo ломает систему.
3. External Coupling
Два модуля зависят от одного внешнего формата данных, протокола или API. Если внешний формат меняется — ломаются оба модуля.
// ❌ External Coupling: оба модуля зависят от конкретного формата API
// todo-export.ts
function exportTodos(todos: readonly Todo[]): string {
return todos.map(t =>
`${t.id},${t.title},${t.done ? "1" : "0"},${t.createdAt.toISOString()}`
).join("\n")
}
// todo-import.ts
function importTodos(csv: string): readonly Todo[] {
return csv.split("\n").map(line => {
const [id, title, done, createdAt] = line.split(",")
return { id, title, done: done === "1", createdAt: new Date(createdAt) }
})
}
// Если формат CSV изменится — ломаются оба модуля
4. Control Coupling
Один модуль передаёт другому флаг, управляющий его поведением. Вызывающий код знает как работает вызываемый модуль — он управляет его внутренней логикой.
// ❌ Control Coupling: флаг управляет поведением
function getTodos(includeArchived: boolean, sortBy: "date" | "priority", format: "json" | "csv") {
let todos = db.query("SELECT * FROM todos").all()
if (!includeArchived) {
todos = todos.filter(t => !t.archived)
}
todos.sort((a, b) =>
sortBy === "date"
? a.createdAt - b.createdAt
: a.priority - b.priority
)
return format === "json"
? JSON.stringify(todos)
: todos.map(t => `${t.id},${t.title}`).join("\n")
}
Каждый флаг создаёт точку ветвления. Три булевых флага дают 8 путей выполнения. Это нарушает Single Responsibility и делает функцию неустойчивой к изменениям.
Лучший подход — разделить на отдельные функции с ясным контрактом:
// ✅ Каждая функция — одна ответственность
function getActiveTodos(): readonly Todo[] { /* ... */ }
function getAllTodos(): readonly Todo[] { /* ... */ }
function sortByDate(todos: readonly Todo[]): readonly Todo[] { /* ... */ }
function sortByPriority(todos: readonly Todo[]): readonly Todo[] { /* ... */ }
function formatAsJson(todos: readonly Todo[]): string { /* ... */ }
function formatAsCsv(todos: readonly Todo[]): string { /* ... */ }
// Композиция на стороне вызывающего:
const result = pipe(
getActiveTodos(),
sortByPriority,
formatAsJson
)
5. Stamp Coupling (структурная)
Модули обмениваются составными структурами данных, но каждый использует лишь часть полей. Изменение неиспользуемых полей структуры может потребовать перекомпиляции или привести к путанице.
// ❌ Stamp Coupling: передаём целый объект User, используя одно поле
interface User {
readonly id: string
readonly name: string
readonly email: string
readonly passwordHash: string
readonly plan: "free" | "pro"
readonly createdAt: Date
}
function sendWelcomeEmail(user: User) {
// Используем только email и name, но получаем весь User
// включая passwordHash — зачем email-сервису знать хеш пароля?
mailer.send(user.email, `Welcome, ${user.name}!`)
}
Исправление — передавать только необходимые данные:
// ✅ Передаём только то, что нужно
function sendWelcomeEmail(email: string, name: string) {
mailer.send(email, `Welcome, ${name}!`)
}
// Или через минимальный интерфейс:
interface EmailRecipient {
readonly email: string
readonly name: string
}
function sendWelcomeEmail(recipient: EmailRecipient) {
mailer.send(recipient.email, `Welcome, ${recipient.name}!`)
}
6. Data Coupling — НАИЛУЧШАЯ
Модули обмениваются только простыми данными (примитивами или простыми структурами), и каждый элемент данных используется получателем.
// ✅ Data Coupling: минимальный обмен данными
function createTodoId(): string {
return crypto.randomUUID()
}
function formatTitle(raw: string): string {
return raw.trim().slice(0, 200)
}
function isOverdue(dueDate: Date, now: Date): boolean {
return dueDate.getTime() < now.getTime()
}
Каждая функция принимает ровно то, что ей нужно, и возвращает ровно один результат. Нет лишних зависимостей, нет побочных эффектов, нет флагов.
Сводная таблица видов coupling
| Вид | Опасность | Описание | Как исправить |
|---|---|---|---|
| Content | 🔴 Критическая | Доступ к внутренностям модуля | Инкапсуляция, приватность |
| Common | 🔴 Высокая | Общее глобальное состояние | DI, иммутабельность |
| External | 🟠 Средняя | Общий внешний формат | Адаптеры, Anti-Corruption Layer |
| Control | 🟠 Средняя | Флаги, управляющие поведением | Полиморфизм, разделение функций |
| Stamp | 🟡 Низкая | Лишние данные в контракте | Минимизация интерфейсов |
| Data | 🟢 Минимальная | Только необходимые данные | Целевое состояние |
Temporal Coupling: скрытый враг
Отдельный и особенно коварный вид — temporal coupling: зависимость от порядка вызовов. Он не виден в системе типов, но приводит к хрупким, непредсказуемым багам.
// ❌ Temporal Coupling: порядок вызовов критичен, но не выражен в типах
class TodoApp {
private db: Database | null = null
private server: Server | null = null
async initialize() {
this.db = new Database("app.db") // Шаг 1: обязателен первым
await this.runMigrations() // Шаг 2: требует this.db
this.server = Bun.serve({ /* ... */ }) // Шаг 3: требует this.db
}
// Если вызвать до initialize — runtime error
createTodo(title: string) {
this.db!.run("INSERT ...", [title]) // ← null-assertion = симптом temporal coupling
}
}
Наличие ! (non-null assertion) — надёжный индикатор temporal coupling. Если вы видите this.something! — значит существует порядок вызовов, который нигде не закреплён.
Effect решает temporal coupling радикально: зависимости выражены в типе R, и компилятор гарантирует, что все зависимости предоставлены до запуска:
import { Effect, Context, Layer } from "effect"
// Порт (контракт)
class TodoRepository extends Context.Tag("TodoRepository")<
TodoRepository,
{
readonly create: (title: string) => Effect.Effect<Todo, TodoError>
readonly findById: (id: TodoId) => Effect.Effect<Todo, TodoNotFound>
}
>() {}
// Использование — тип R ЯВНО требует TodoRepository
const createTodo = (title: string): Effect.Effect<Todo, TodoError, TodoRepository> =>
Effect.gen(function* () {
const repo = yield* TodoRepository
return yield* repo.create(title)
})
// Если не предоставить TodoRepository — код НЕ СКОМПИЛИРУЕТСЯ
// Effect.runPromise(createTodo("Buy milk"))
// ^^^ Type error: TodoRepository is not in the environment
// Правильно:
const program = createTodo("Buy milk").pipe(
Effect.provide(TodoRepositoryLive) // Компилятор проверяет, что тип совпадает
)
Cohesion: связность внутри модуля
Определение
Cohesion — степень, в которой элементы модуля (функции, данные, типы) принадлежат друг другу и работают на одну общую цель. Высокая связность означает, что модуль делает одну вещь хорошо. Низкая связность означает, что модуль — мешанина из несвязанных функций.
Спектр cohesion: от худшего к лучшему
1. Coincidental Cohesion — НАИХУДШАЯ
Элементы модуля не имеют между собой ничего общего. Они оказались вместе случайно — обычно в файле с именем utils.ts или helpers.ts.
// ❌ Coincidental Cohesion: свалка несвязанных функций
// utils.ts
export function formatDate(date: Date): string { /* ... */ }
export function validateEmail(email: string): boolean { /* ... */ }
export function calculateTax(amount: number, rate: number): number { /* ... */ }
export function slugify(text: string): string { /* ... */ }
export function retry<T>(fn: () => Promise<T>, attempts: number): Promise<T> { /* ... */ }
export function deepClone<T>(obj: T): T { /* ... */ }
export function generateId(): string { /* ... */ }
export function parseCSV(text: string): string[][] { /* ... */ }
Восемь функций, ни одна не связана с другой. Форматирование даты, валидация email, расчёт налогов — это разные домены. Файл utils.ts — антипаттерн, который растёт бесконечно и не имеет чёткого предназначения.
2. Logical Cohesion
Элементы модуля выполняют логически похожие операции, но над разными данными и для разных целей.
// ❌ Logical Cohesion: всё, что связано с "валидацией" — но это разные домены
// validators.ts
export function validateTodoTitle(title: string): boolean { /* ... */ }
export function validateUserEmail(email: string): boolean { /* ... */ }
export function validatePaymentAmount(amount: number): boolean { /* ... */ }
export function validateFileSize(bytes: number): boolean { /* ... */ }
Все функции — «валидаторы», но они относятся к совершенно разным контекстам (задачи, пользователи, платежи, файлы). Объединение по «типу операции» вместо «области бизнеса» — признак логической связности.
3. Temporal Cohesion
Элементы модуля выполняются в одно и то же время (при старте, при завершении, при ошибке).
// ❌ Temporal Cohesion: объединено потому что "всё при старте"
// startup.ts
export async function initialize() {
await connectToDatabase()
await runMigrations()
await loadConfig()
await warmUpCache()
await startMetricsCollector()
await registerHealthCheck()
await startHttpServer()
}
Все операции выполняются при старте, но они не связаны друг с другом: база данных, конфигурация, кеш, метрики, HTTP — это разные подсистемы.
4. Procedural Cohesion
Элементы модуля выполняются в определённой последовательности, но работают с разными данными.
// ❌ Procedural Cohesion: шаги процесса, но разные ответственности
export function processOrder(orderId: string) {
const order = fetchOrder(orderId) // Шаг 1: загрузка
const validated = validateOrder(order) // Шаг 2: валидация
const priced = calculatePrice(validated) // Шаг 3: расчёт
const receipt = generateReceipt(priced) // Шаг 4: документ
sendConfirmation(order.email, receipt) // Шаг 5: уведомление
updateInventory(order.items) // Шаг 6: инвентарь
}
5. Communicational Cohesion
Элементы модуля работают с одними и теми же данными, но выполняют разные операции над ними.
// 🟡 Communicational Cohesion: все работают с Todo, но делают разное
// todo-operations.ts
export function formatTodoForDisplay(todo: Todo): string { /* ... */ }
export function serializeTodoToJSON(todo: Todo): string { /* ... */ }
export function calculateTodoPriority(todo: Todo): number { /* ... */ }
export function isTodoOverdue(todo: Todo): boolean { /* ... */ }
Это уже лучше — все функции работают с Todo. Но форматирование для отображения и сериализация в JSON — это разные слои (представление vs инфраструктура).
6. Sequential Cohesion
Выход одного элемента является входом для следующего. Элементы образуют конвейер (pipeline).
// ✅ Sequential Cohesion: pipeline обработки
const processRawTodo = flow(
parseTodoInput, // string → RawTodoInput
validateTodoInput, // RawTodoInput → ValidatedInput
normalizeTitle, // ValidatedInput → NormalizedInput
createTodoEntity, // NormalizedInput → Todo
)
7. Functional Cohesion — НАИЛУЧШАЯ
Все элементы модуля работают вместе для выполнения одной хорошо определённой задачи. Каждый элемент необходим, ни один не лишний.
// ✅ Functional Cohesion: всё служит одной цели — управлению Todo Entity
// domain/todo/todo.ts
/** Уникальный идентификатор задачи */
type TodoId = string & { readonly _brand: unique symbol }
/** Статус задачи */
type TodoStatus = "active" | "completed" | "archived"
/** Задача — неизменяемая сущность с идентичностью */
interface Todo {
readonly id: TodoId
readonly title: string
readonly status: TodoStatus
readonly createdAt: Date
readonly completedAt: Date | null
}
/** Создание новой задачи */
const create = (id: TodoId, title: string, now: Date): Todo => ({
id,
title,
status: "active",
createdAt: now,
completedAt: null,
})
/** Завершение задачи */
const complete = (todo: Todo, now: Date): Todo =>
todo.status === "active"
? { ...todo, status: "completed", completedAt: now }
: todo // Идемпотентность
/** Архивация завершённой задачи */
const archive = (todo: Todo): Todo =>
todo.status === "completed"
? { ...todo, status: "archived" }
: todo
/** Проверка просроченности */
const isOverdue = (todo: Todo, dueDate: Date, now: Date): boolean =>
todo.status === "active" && dueDate.getTime() < now.getTime()
Всё в одном файле: тип, конструктор, поведение (переходы состояний), запросы (проверки). Каждая функция работает с Todo и реализует один аспект его жизненного цикла. Ничего лишнего, ничего не хватает.
Сводная таблица видов cohesion
| Вид | Качество | Описание | Пример |
|---|---|---|---|
| Coincidental | 🔴 | Случайное соседство | utils.ts |
| Logical | 🔴 | По типу операции | validators.ts |
| Temporal | 🟠 | По времени выполнения | startup.ts |
| Procedural | 🟠 | По последовательности шагов | processOrder() |
| Communicational | 🟡 | По общим данным | todo-operations.ts |
| Sequential | 🟢 | Pipeline | parse → validate → create |
| Functional | 🟢 | Единая цель | domain/todo/todo.ts |
Взаимосвязь coupling и cohesion
Coupling и cohesion — не независимые метрики. Они коррелируют:
Низкая cohesion → Высокий coupling. Если модуль отвечает за слишком многое, его части неизбежно зависят от внешних модулей для своих «побочных» задач. Модуль-свалка utils.ts будет импортировать и базу данных, и HTTP-клиент, и файловую систему.
Высокая cohesion → Низкий coupling. Если модуль сфокусирован на одной задаче, ему нужно меньше внешних зависимостей. Модуль domain/todo.ts не импортирует ничего, кроме своих собственных типов.
Визуально:
HIGH COHESION LOW COHESION
LOW COUPLING HIGH COUPLING
┌──────────┐ ┌──────────┐ ┌──────────────────────┐
│ ● ● ● │ │ ▲ ▲ ▲ │ │ ● ▲ ■ ◆ ▲ ● │
│ ● ● ● │ │ ▲ ▲ ▲ │ │ ▲ ●──────────▲ ■ │
│ ● ● │ │ ▲ ▲ │ │ ■ ◆ ●──▲ ◆ ● │
└────│─────┘ └────│─────┘ └──│──│──│──│──│──│──┘
│ │ │ │ │ │ │ │
└──thin───────┘ └──┴──┴──┴──┴──┘
interface spaghetti
connections
Измерение coupling и cohesion на практике
LCOM — Lack of Cohesion of Methods
Метрика, предложенная Чидамбером и Кемерером (1994). Для класса с m методами и a атрибутами: LCOM считает, сколько пар методов не разделяют общих атрибутов.
Упрощённый вариант: если класс можно разделить на два независимых класса, у которых нет общих полей — его cohesion низкая.
// LCOM = высокий (низкая cohesion)
// Можно разделить на TodoService и UserService
class GodService {
private todoDB: Database
private userDB: Database
private mailer: Mailer
// Группа 1: работает с todoDB
createTodo(title: string) { /* todoDB */ }
listTodos() { /* todoDB */ }
completeTodo(id: string) { /* todoDB */ }
// Группа 2: работает с userDB и mailer
registerUser(email: string) { /* userDB, mailer */ }
resetPassword(email: string) { /* userDB, mailer */ }
// Группы не пересекаются → LCOM высокий → нужно разделить
}
Instability и Abstractness (Robert C. Martin)
Instability (I) = Ce / (Ca + Ce) — насколько модуль «хрупок» к изменениям.
Abstractness (A) = количество абстрактных типов / общее количество типов.
Distance from Main Sequence (D) = |A + I − 1| — чем ближе к нулю, тем лучше.
Модули должны лежать на «главной последовательности» — диагонали от (A=1, I=0) до (A=0, I=1). Отклонения:
- Zone of Pain (A≈0, I≈0): конкретный, стабильный модуль, от которого все зависят. Болезненно менять (пример: конкретный класс
Database, используемый повсюду — нужна абстракция). - Zone of Uselessness (A≈1, I≈1): абстрактный, но нестабильный. Никто не реализует эти интерфейсы — мёртвый код.
Практический подсчёт: Fan-in и Fan-out
Для быстрой оценки используйте подсчёт импортов:
Fan-in (сколько модулей импортируют данный) = Ca. Fan-out (сколько модулей данный модуль импортирует) = Ce.
domain/todo.ts
Fan-in: 12 (используется всеми слоями) → стабильный, менять осторожно
Fan-out: 0 (не импортирует ничего) → независимый, легко тестировать
adapters/sqlite-todo-repo.ts
Fan-in: 1 (используется только при сборке) → можно менять свободно
Fan-out: 3 (domain/todo, ports/repo, bun:sqlite) → зависит от многих
Effect-ts: coupling и cohesion через систему типов
Effect-ts предоставляет уникальные инструменты для управления coupling и cohesion, встроенные в систему типов.
Service — высокая cohesion порта
Context.Tag и Effect.Service группируют связанные операции в единый контракт с функциональной cohesion:
import { Effect, Context } from "effect"
// Порт: высокая cohesion — все методы работают с одним агрегатом
class TodoRepository extends Context.Tag("TodoRepository")<
TodoRepository,
{
readonly findById: (id: TodoId) => Effect.Effect<Todo, TodoNotFound>
readonly findAll: () => Effect.Effect<ReadonlyArray<Todo>>
readonly save: (todo: Todo) => Effect.Effect<void, TodoSaveError>
readonly delete: (id: TodoId) => Effect.Effect<void, TodoNotFound>
}
>() {}
Все четыре метода — операции над Todo. Ничего лишнего (не TodoAndUserAndPaymentRepository), ничего не хватает (полный CRUD). Это функциональная cohesion, выраженная в типе.
R-канал — coupling, видимый компилятору
В обычном TypeScript зависимости — это импорты. Компилятор проверяет, что импортированные имена существуют, но не проверяет, что все зависимости предоставлены.
В Effect зависимости — часть типа Effect<A, E, R>. Канал R перечисляет все сервисы, которые нужны для выполнения эффекта:
// Тип говорит: "Для выполнения нужны TodoRepository И NotificationService"
const createAndNotify = (
title: string
): Effect.Effect<Todo, TodoError | NotificationError, TodoRepository | NotificationService> =>
Effect.gen(function* () {
const repo = yield* TodoRepository
const notifier = yield* NotificationService
const todo = yield* repo.save(Todo.create(title))
yield* notifier.notify(`Created: ${todo.title}`)
return todo
})
Coupling здесь явный и проверяемый: функция зависит от TodoRepository и NotificationService, и это видно в её сигнатуре. Если вы добавите зависимость — тип изменится, и все вызывающие модули будут вынуждены её предоставить.
Layer — controlled coupling между портом и реализацией
Layer связывает абстракцию (порт) с конкретикой (адаптер), но делает это в одном месте — при финальной сборке программы:
import { Layer } from "effect"
// Адаптер: единственное место, где знают о SQLite
const TodoRepositorySqlite = Layer.succeed(
TodoRepository,
{
findById: (id) => /* SQLite query */,
findAll: () => /* SQLite query */,
save: (todo) => /* SQLite insert/update */,
delete: (id) => /* SQLite delete */,
}
)
// Тестовый адаптер: единственное место, где знают о Map
const TodoRepositoryInMemory = Layer.succeed(
TodoRepository,
{
findById: (id) => /* Map.get */,
findAll: () => /* Map.values */,
save: (todo) => /* Map.set */,
delete: (id) => /* Map.delete */,
}
)
// Бизнес-логика НЕ ЗНАЕТ, какой адаптер используется
// → Data Coupling между бизнес-логикой и портом
// → Coupling с адаптером только в точке сборки
Паттерны для достижения low coupling / high cohesion
1. Организация по бизнес-домену, а не по техническому слою
// ❌ По техническому слою — низкая cohesion
src/
controllers/
todo-controller.ts
user-controller.ts
models/
todo-model.ts
user-model.ts
services/
todo-service.ts
user-service.ts
repositories/
todo-repository.ts
user-repository.ts
// ✅ По бизнес-домену — высокая cohesion
src/
domain/
todo/
todo.ts // Entity + Value Objects
todo-events.ts // Domain Events
todo-errors.ts // Domain Errors
user/
user.ts
user-events.ts
ports/
todo-repository.ts // Контракт
notification.ts // Контракт
adapters/
sqlite/
todo-repo-sqlite.ts // Реализация
http/
todo-routes.ts // Реализация
В первом варианте, чтобы понять как работает «создание задачи», вы должны открыть 4 файла в 4 папках. Во втором — всё о Todo лежит рядом.
2. Dependency Inversion: зависимости указывают внутрь
Вместо того чтобы бизнес-логика зависела от базы данных, база данных (адаптер) зависит от контракта (порта), который определён рядом с бизнес-логикой.
// ❌ Бизнес-логика зависит от инфраструктуры
TodoService → SQLiteDatabase
// ✅ Инфраструктура зависит от контракта бизнес-логики
TodoService → TodoRepository (порт/интерфейс)
↑
TodoRepoSqlite (адаптер/реализация)
3. Interface Segregation: маленькие, сфокусированные интерфейсы
Вместо одного «жирного» интерфейса — несколько маленьких:
// ❌ Один толстый интерфейс — stamp coupling для всех потребителей
interface TodoRepository {
readonly findById: (id: TodoId) => Effect.Effect<Todo, TodoNotFound>
readonly findAll: (filter: TodoFilter) => Effect.Effect<ReadonlyArray<Todo>>
readonly save: (todo: Todo) => Effect.Effect<void>
readonly delete: (id: TodoId) => Effect.Effect<void>
readonly count: () => Effect.Effect<number>
readonly archive: (before: Date) => Effect.Effect<number>
readonly export: (format: "csv" | "json") => Effect.Effect<string>
}
// ✅ Разделение по назначению
// Читающие операции
class TodoReader extends Context.Tag("TodoReader")<TodoReader, {
readonly findById: (id: TodoId) => Effect.Effect<Todo, TodoNotFound>
readonly findAll: (filter: TodoFilter) => Effect.Effect<ReadonlyArray<Todo>>
}>() {}
// Пишущие операции
class TodoWriter extends Context.Tag("TodoWriter")<TodoWriter, {
readonly save: (todo: Todo) => Effect.Effect<void>
readonly delete: (id: TodoId) => Effect.Effect<void>
}>() {}
Антипаттерны: признаки нарушения coupling/cohesion
God Object / God Module
Один модуль, который знает и делает слишком многое. Признаки: более 500 строк, более 10 публичных функций, Fan-out > 10.
Feature Envy
Функция модуля A больше работает с данными модуля B, чем с данными своего модуля. Признак: функция принимает объект из другого модуля и вызывает 3+ его метода или читает 3+ его полей.
Shotgun Surgery
Одно бизнес-изменение требует правок в 5+ файлах. Признак: каждый коммит затрагивает файлы из разных папок/слоёв.
Divergent Change
Один файл меняется по совершенно разным причинам. Признак: в git-логе один файл появляется и в задачах «добавить новый статус задачи», и в задачах «исправить email-нотификации», и в задачах «обновить формат API».
Ключевые выводы
- Coupling — сила зависимости между модулями. Стремитесь к Data Coupling.
- Cohesion — степень единства цели внутри модуля. Стремитесь к Functional Cohesion.
- Низкая cohesion автоматически приводит к высокому coupling — это взаимосвязанные метрики.
- Effect-ts делает coupling явным через R-канал и проверяемым на этапе компиляции.
- Организация по бизнес-домену даёт высокую cohesion; по техническому слою — низкую.
- Temporal coupling — скрытый и опасный вид, который Effect устраняет через систему типов.
- God Object, Feature Envy, Shotgun Surgery — верные признаки нарушения баланса.