Упражнения Модуля 1
Практические задания на закрепление: идентификация видов coupling и cohesion по фрагментам кода, анализ графа зависимостей, вычисление цикломатической сложности и рефакторинг, проектирование файловой структуры Todo-приложения с нуля.
Упражнение 1: Идентификация видов coupling
Задание: Определите вид coupling (Content, Common, External, Control, Stamp, Data) в каждом из фрагментов и предложите, как понизить его до Data Coupling.
Фрагмент A
class UserService {
private cache = new Map<string, User>()
getUser(id: string): User | undefined {
return this.cache.get(id)
}
}
class TodoService {
constructor(private userService: UserService) {}
getUserTodos(userId: string) {
// Обращение к приватному кешу через приведение типа
const allUsers = (this.userService as any).cache
const user = allUsers.get(userId)
if (!user) throw new Error("User not found")
return this.fetchTodos(userId)
}
private fetchTodos(userId: string) { /* ... */ }
}
Ответ
Content Coupling. TodoService обращается к приватному полю cache через as any. Это патологическая связанность — модуль ковыряется во внутренностях другого.
Исправление:
class TodoService {
constructor(private userService: UserService) {}
getUserTodos(userId: string) {
const user = this.userService.getUser(userId) // Публичный метод
if (!user) throw new Error("User not found")
return this.fetchTodos(userId)
}
}
Ещё лучше — зависеть не от UserService, а от минимального интерфейса:
interface UserChecker {
readonly exists: (id: string) => boolean
}
class TodoService {
constructor(private users: UserChecker) {}
getUserTodos(userId: string) {
if (!this.users.exists(userId)) throw new Error("User not found")
return this.fetchTodos(userId)
}
}
Теперь — Data Coupling: TodoService зависит только от boolean, который возвращает exists.
Фрагмент B
// config.ts
export const APP_CONFIG = {
dbPath: "app.db",
port: 3000,
maxTodos: 100,
smtpHost: "mail.example.com",
jwtSecret: "super-secret",
}
// todo-service.ts
import { APP_CONFIG } from "./config"
function createTodo(title: string) {
if (countTodos() >= APP_CONFIG.maxTodos) {
throw new Error("Limit reached")
}
// ...
}
// auth-service.ts
import { APP_CONFIG } from "./config"
function verifyToken(token: string) {
return jwt.verify(token, APP_CONFIG.jwtSecret)
}
Ответ
Common Coupling. Оба модуля зависят от одного глобального объекта APP_CONFIG. Изменение структуры конфигурации (переименование поля, разделение на секции) затрагивает оба.
К тому же todo-service имеет доступ к jwtSecret, что ему совершенно не нужно (Stamp Coupling поверх Common).
Исправление — каждый модуль получает только то, что ему нужно:
// todo-service.ts
function createTodo(title: string, maxTodos: number) {
if (countTodos() >= maxTodos) throw new Error("Limit reached")
}
// auth-service.ts
function verifyToken(token: string, secret: string) {
return jwt.verify(token, secret)
}
С Effect:
const TodoConfig = Effect.Config.number("MAX_TODOS")
const AuthConfig = Effect.Config.string("JWT_SECRET").pipe(Config.map(Redacted.make))
Каждый модуль декларирует свою конфигурацию независимо — Data Coupling.
Фрагмент C
function processTodo(todo: Todo, action: "complete" | "archive" | "delete" | "reopen") {
switch (action) {
case "complete": return { ...todo, status: "completed" }
case "archive": return { ...todo, status: "archived" }
case "delete": return null
case "reopen": return { ...todo, status: "active" }
}
}
Ответ
Control Coupling. Параметр action управляет внутренней логикой функции. Вызывающий код знает, какие действия поддерживаются, и управляет поведением через строковый флаг.
Исправление — отдельная функция для каждого действия:
const completeTodo = (todo: Todo): Todo =>
({ ...todo, status: "completed" as const })
const archiveTodo = (todo: Todo): Todo =>
({ ...todo, status: "archived" as const })
const reopenTodo = (todo: Todo): Todo =>
({ ...todo, status: "active" as const })
Каждая функция — чистая, с единственной ответственностью. Data Coupling: принимает Todo, возвращает Todo.
Упражнение 2: Измерение cohesion
Задание: Определите вид cohesion для каждого модуля и предложите рефакторинг для достижения Functional Cohesion.
Модуль A: helpers.ts
export function formatDate(date: Date): string {
return date.toISOString().split("T")[0]
}
export function hashPassword(password: string): string {
return Bun.password.hashSync(password)
}
export function generateSlug(text: string): string {
return text.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "")
}
export function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value))
}
export function sendEmail(to: string, subject: string, body: string): void {
// SMTP logic
}
Ответ
Coincidental Cohesion. Пять функций, не связанных друг с другом: форматирование дат, хеширование паролей, генерация slug, математика, отправка email. Они оказались вместе случайно.
Рефакторинг — разместить каждую функцию рядом с её доменом:
domain/todo/todo.ts → generateSlug (если slug — свойство Todo)
domain/auth/password.ts → hashPassword
domain/shared/date-format.ts → formatDate
domain/shared/math.ts → clamp (если вообще нужен)
adapters/email/mailer.ts → sendEmail
Или, если использовать Effect-подход: hashPassword — эффект (Bun.password.hashSync), sendEmail — эффект. Они должны быть адаптерами, а не утилитами.
Модуль B: todo-module.ts
export interface Todo {
readonly id: string
readonly title: string
readonly status: "active" | "completed"
}
export const createTodo = (id: string, title: string): Todo => ({
id, title, status: "active"
})
export const completeTodo = (todo: Todo): Todo => ({
...todo, status: "completed"
})
export const isActive = (todo: Todo): boolean =>
todo.status === "active"
export const getTitle = (todo: Todo): string =>
todo.title
Ответ
Functional Cohesion. Все элементы работают с одной сущностью Todo и обслуживают её жизненный цикл: создание, завершение, проверки. Это целевое состояние — рефакторинг не требуется.
Упражнение 3: Анализ направления зависимостей
Задание: Нарисуйте граф зависимостей (кто кого импортирует) для приведённого кода. Найдите нарушения Dependency Rule. Предложите исправление.
// --- domain/todo.ts ---
import { Database } from "bun:sqlite" // ???
export interface Todo { id: string; title: string; done: boolean }
export function completeTodo(db: Database, id: string): Todo {
const todo = db.query("SELECT * FROM todos WHERE id = ?").get(id) as Todo
if (todo.done) throw new Error("Already done")
db.run("UPDATE todos SET done = 1 WHERE id = ?", [id])
return { ...todo, done: true }
}
// --- adapters/http-server.ts ---
import { completeTodo } from "../domain/todo"
import { Database } from "bun:sqlite"
const db = new Database("app.db")
Bun.serve({
fetch(req) {
const url = new URL(req.url)
if (url.pathname.startsWith("/todos/") && req.method === "PUT") {
const id = url.pathname.split("/")[2]
const result = completeTodo(db, id)
return Response.json(result)
}
return new Response("Not found", { status: 404 })
}
})
Ответ
Граф зависимостей:
domain/todo.ts → bun:sqlite ← НАРУШЕНИЕ! Домен зависит от инфраструктуры
adapters/http-server.ts → domain/todo.ts (допустимо)
adapters/http-server.ts → bun:sqlite (допустимо для адаптера)
Нарушения:
domain/todo.tsимпортируетbun:sqlite— домен знает о базе данных.- Функция
completeTodoпринимаетDatabase— бизнес-правило связано с SQLite. - SQL-запрос внутри доменной функции.
Исправление:
// domain/todo.ts — НОЛЬ импортов
export interface Todo { readonly id: string; readonly title: string; readonly done: boolean }
export const completeTodo = (todo: Todo): Todo => {
if (todo.done) throw new Error("Already done")
return { ...todo, done: true }
}
// ports/todo-repository.ts
import type { Todo } from "../domain/todo"
export interface TodoRepository {
readonly findById: (id: string) => Promise<Todo | null>
readonly save: (todo: Todo) => Promise<void>
}
// adapters/sqlite/todo-repo.ts
import type { TodoRepository } from "../../ports/todo-repository"
import { Database } from "bun:sqlite"
// ... реализация
// app/complete-todo.ts
import { completeTodo } from "../domain/todo"
import type { TodoRepository } from "../ports/todo-repository"
export async function handleCompleteTodo(repo: TodoRepository, id: string) {
const todo = await repo.findById(id)
if (!todo) throw new Error("Not found")
const completed = completeTodo(todo)
await repo.save(completed)
return completed
}
Новый граф:
domain/todo.ts → (ничего) ✅
ports/todo-repository.ts → domain/todo.ts ✅
app/complete-todo.ts → domain/todo.ts ✅
app/complete-todo.ts → ports/todo-repo ✅
adapters/sqlite/ → ports/todo-repo ✅
adapters/sqlite/ → bun:sqlite ✅ (адаптеру можно)
adapters/http/ → app/complete-todo ✅
Все стрелки указывают внутрь, к домену. Ни одна стрелка не идёт наружу от домена.
Упражнение 4: Цикломатическая сложность
Задание: Вычислите цикломатическую сложность функции. Предложите рефакторинг, снижающий сложность до ≤ 5.
function processRequest(req: {
method: string
path: string
body?: unknown
headers: Record<string, string>
}) {
if (req.method === "GET" && req.path === "/todos") {
return getAllTodos()
} else if (req.method === "GET" && req.path.startsWith("/todos/")) {
const id = req.path.split("/")[2]
if (!id) return { status: 400, body: "Missing id" }
const todo = getTodoById(id)
if (!todo) return { status: 404, body: "Not found" }
return { status: 200, body: todo }
} else if (req.method === "POST" && req.path === "/todos") {
if (!req.headers["content-type"]?.includes("application/json")) {
return { status: 415, body: "JSON required" }
}
if (!req.body || typeof req.body !== "object") {
return { status: 400, body: "Invalid body" }
}
const body = req.body as { title?: string }
if (!body.title || body.title.length === 0) {
return { status: 400, body: "Title required" }
}
if (body.title.length > 200) {
return { status: 400, body: "Title too long" }
}
return createTodo(body.title)
} else if (req.method === "PUT" && req.path.startsWith("/todos/")) {
const id = req.path.split("/")[2]
if (!id) return { status: 400, body: "Missing id" }
return completeTodo(id)
} else if (req.method === "DELETE" && req.path.startsWith("/todos/")) {
const id = req.path.split("/")[2]
if (!id) return { status: 400, body: "Missing id" }
return deleteTodo(id)
}
return { status: 405, body: "Method not allowed" }
}
Ответ
Подсчёт: Базовая сложность = 1. Считаем каждый if, else if, &&, ||, ?.:
if (req.method === "GET" && req.path === "/todos")→ +2else if (req.method === "GET" && req.path.startsWith(...))→ +2if (!id)→ +1if (!todo)→ +1else if (req.method === "POST" && req.path === "/todos")→ +2if (!req.headers[...]?.includes(...))→ +1if (!req.body || typeof req.body !== "object")→ +2if (!body.title || body.title.length === 0)→ +2if (body.title.length > 200)→ +1else if (req.method === "PUT" && ...)→ +2if (!id)→ +1else if (req.method === "DELETE" && ...)→ +2if (!id)→ +1
Цикломатическая сложность ≈ 21. Это в два раза выше допустимого порога.
Рефакторинг — разделение по ответственностям:
// Роутер — O(n) по количеству маршрутов, сложность 1 на маршрут
type Handler = (params: RouteParams) => Response
const routes: ReadonlyArray<{ method: string; pattern: RegExp; handler: Handler }> = [
{ method: "GET", pattern: /^\/todos$/, handler: handleListTodos },
{ method: "GET", pattern: /^\/todos\/(.+)$/, handler: handleGetTodo },
{ method: "POST", pattern: /^\/todos$/, handler: handleCreateTodo },
{ method: "PUT", pattern: /^\/todos\/(.+)$/, handler: handleCompleteTodo },
{ method: "DELETE", pattern: /^\/todos\/(.+)$/, handler: handleDeleteTodo },
] as const
// Каждый обработчик — сложность ≤ 3
function handleCreateTodo(params: RouteParams): Response {
const parsed = parseTodoInput(params.body) // Schema.decode
if (Either.isLeft(parsed)) return badRequest(parsed.left)
return created(createTodo(parsed.right.title))
}
Цикломатическая сложность каждой отдельной функции: ≤ 5. Общая логика разложена на compose-способные части.
Упражнение 5: Спроектируй Todo-приложение
Задание (проектное): Это задание вы будете развивать на протяжении всего курса. Начните сейчас с высокоуровневого дизайна.
Для Todo-приложения со следующими требованиями:
- Пользователь может создавать, завершать, архивировать и удалять задачи
- У задачи есть заголовок, приоритет (low/medium/high), опциональный срок (due date)
- Задачи хранятся в SQLite
- API доступен через HTTP (REST)
- При завершении задачи отправляется уведомление
Выполните:
- Нарисуйте (текстом) структуру папок проекта с разделением на domain/, ports/, adapters/, app/.
- Определите какие файлы будут в каждой папке (просто имена, без кода).
- Нарисуйте граф зависимостей: кто от кого зависит (стрелки).
- Убедитесь, что все стрелки указывают внутрь (к домену).
- Перечислите все порты (driving и driven), которые вам понадобятся.
Пример ответа
Структура папок:
src/
domain/
todo/
todo.ts — Entity: Todo, TodoId, TodoStatus, Priority
todo-events.ts — Events: TodoCreated, TodoCompleted, TodoArchived
todo-errors.ts — Errors: TodoNotFound, InvalidTransition, DuplicateTitle
ports/
todo-repository.ts — Driven Port: CRUD для Todo
notification.ts — Driven Port: отправка уведомлений
clock.ts — Driven Port: текущее время (детерминизм)
app/
create-todo.ts — Use Case: создание задачи
complete-todo.ts — Use Case: завершение задачи
archive-todo.ts — Use Case: архивация
delete-todo.ts — Use Case: удаление
list-todos.ts — Use Case: получение списка
get-todo.ts — Use Case: получение одной задачи
adapters/
sqlite/
sqlite-client.ts — Инфраструктура: подключение к SQLite
todo-repo-sqlite.ts — Adapter: реализация TodoRepository для SQLite
migrations.ts — Миграции схемы БД
http/
todo-routes.ts — Adapter: HTTP-маршруты для Todo
error-mapper.ts — Маппинг доменных ошибок → HTTP-кодов
notification/
console-notifier.ts — Adapter: вывод в консоль (dev)
email-notifier.ts — Adapter: email (production)
test/
todo-repo-memory.ts — Adapter: in-memory (тесты)
noop-notifier.ts — Adapter: no-op (тесты)
main.ts — Точка сборки: Layer composition
Граф зависимостей:
domain/todo/* → (ничего)
ports/* → domain/todo/*
app/* → domain/todo/*, ports/*
adapters/sqlite/* → ports/todo-repository, domain/todo/*
adapters/http/* → app/*, domain/todo/* (для типов ошибок)
adapters/notification/* → ports/notification
adapters/test/* → ports/*
main.ts → app/*, adapters/*
Все стрелки от внешних слоёв к внутренним. Домен ни от чего не зависит.
Порты:
Driving (входные):
- Use Case интерфейсы в
app/— API для внешнего мира
Driven (выходные):
TodoRepository— персистентностьNotificationService— уведомленияClock— текущее время (для детерминированных тестов)
Чеклист самопроверки по Модулю 1
После выполнения упражнений убедитесь, что вы можете ответить «да» на каждый вопрос:
- Я понимаю разницу между essential и accidental complexity
- Я могу назвать 5 корневых причин неуправляемой сложности
- Я могу определить вид coupling по фрагменту кода
- Я могу определить вид cohesion по структуре модуля
- Я понимаю, почему зависимости должны указывать внутрь (к домену)
- Я могу объяснить Dependency Inversion Principle своими словами
- Я понимаю, как Effect.Service (R-канал) делает зависимости явными
- Я могу назвать три главные цели архитектуры и объяснить, как их достичь
- Я могу спроектировать структуру папок для Hexagonal-проекта
- Я понимаю, почему «рефакторинг потом» — плохая стратегия