Упражнения Модуля 2
Классификация архитектурных решений из реального кода: определите стиль, найдите нарушения, предложите миграцию. Сравнительный анализ двух проектов с разными архитектурами. Проектирование Todo-приложения в каждом из четырёх стилей для наглядного сравнения.
Упражнение 1: Определи архитектурный стиль (анализ)
Для каждого фрагмента кода определите, какой архитектурный стиль используется (Layered, Clean, Onion, Hexagonal), и обоснуйте ответ.
Фрагмент A
// user-service.ts
import { PrismaClient } from "@prisma/client"
const prisma = new PrismaClient()
export class UserService {
async createUser(name: string, email: string) {
const user = await prisma.user.create({
data: { name, email },
})
return user
}
async getUser(id: string) {
return prisma.user.findUnique({ where: { id } })
}
}
Фрагмент B
// create-user.ts
import { Effect, Context, Layer } from "effect"
class UserRepository extends Context.Tag("UserRepository")<
UserRepository,
{
readonly save: (user: User) => Effect.Effect<void, RepositoryError>
readonly findByEmail: (email: string) => Effect.Effect<User | null, RepositoryError>
}
>() {}
const createUser = (name: string, email: string) =>
Effect.gen(function* () {
const repo = yield* UserRepository
const existing = yield* repo.findByEmail(email)
if (existing !== null) {
return yield* Effect.fail(new DuplicateEmailError({ email }))
}
const user: User = { id: crypto.randomUUID(), name, email }
yield* repo.save(user)
return user
})
Фрагмент C
// create-user-interactor.ts
interface CreateUserInputBoundary {
execute(input: CreateUserInput): Promise<void>
}
interface CreateUserOutputBoundary {
presentSuccess(output: CreateUserOutput): void
presentError(error: ApplicationError): void
}
class CreateUserInteractor implements CreateUserInputBoundary {
constructor(
private readonly userRepo: UserRepository,
private readonly presenter: CreateUserOutputBoundary
) {}
async execute(input: CreateUserInput): Promise<void> {
try {
const user = new User(input.name, input.email)
await this.userRepo.save(user)
this.presenter.presentSuccess({ id: user.id, name: user.name })
} catch (error) {
this.presenter.presentError(error as ApplicationError)
}
}
}
Фрагмент D
// domain-model/user.ts
class User {
constructor(
readonly id: string,
readonly name: string,
readonly email: Email
) {}
}
// domain-services/user-validator.ts
const validateUniqueEmail = (
users: ReadonlyArray<User>,
email: Email
): boolean => !users.some((u) => u.email.value === email.value)
// application-services/ports.ts
interface UserRepository {
save(user: User): Promise<void>
findAll(): Promise<ReadonlyArray<User>>
}
// infrastructure/sqlite-user-repository.ts
import { Database } from "bun:sqlite"
class SqliteUserRepository implements UserRepository {
constructor(private readonly db: Database) {}
async save(user: User): Promise<void> { /* SQL */ }
async findAll(): Promise<ReadonlyArray<User>> { /* SQL */ }
}
Ответы
Раскрыть ответы
Фрагмент A — Layered Architecture
Признаки:
- Прямая зависимость от конкретной технологии (
PrismaClient) в бизнес-сервисе - Нет интерфейсов/абстракций между слоями
- Бизнес-логика (UserService) напрямую зависит от Data Access (Prisma)
- Направление зависимостей: сверху вниз (Service → ORM → DB)
Фрагмент B — Hexagonal Architecture (с Effect)
Признаки:
Context.Tag= порт (типизированный контракт без реализации)- Бизнес-логика (
createUser) зависит от интерфейса (UserRepository), а не от реализации - R-канал (
UserRepository) = Dependency Rule, проверяемый компилятором - E-канал (
DuplicateEmailError | RepositoryError) = контракт ошибок - Нет привязки к конкретной технологии хранения
Фрагмент C — Clean Architecture
Признаки:
- Explicit Input/Output Boundaries (интерфейсы
CreateUserInputBoundaryиCreateUserOutputBoundary) - Паттерн Presenter (
CreateUserOutputBoundary.presentSuccess/presentError) - Interactor class (реализация Use Case как класс с
execute) - Dependency Inversion через конструктор
- Типичная терминология Uncle Bob
Фрагмент D — Onion Architecture
Признаки:
- Явное разделение Domain Model (
user.ts) и Domain Services (user-validator.ts) - Интерфейсы портов в
application-services/ports.ts(Application layer) - Реализация в
infrastructure/— зависит от Application layer (инвертированная зависимость) - Терминология DDD (Domain Model, Domain Services)
- Структура папок соответствует концентрическим слоям Onion
Упражнение 2: Найди нарушение Dependency Rule
В каждом фрагменте найдите нарушение Dependency Rule (зависимость, направленную не в ту сторону) и предложите исправление.
Нарушение A
// domain/todo.ts
import { Schema } from "@effect/schema"
interface Todo {
readonly id: string
readonly title: string
readonly status: TodoStatus
readonly sqliteRowId?: number // ← ???
}
Нарушение B
// domain/order.ts
import { sendEmail } from "../infrastructure/email-service"
const completeOrder = (order: Order): Order => {
const completed = { ...order, status: "completed" as const }
sendEmail(order.customerEmail, "Order completed!") // ← ???
return completed
}
Нарушение C
// use-cases/create-todo.ts
import { Effect } from "effect"
import { Database } from "bun:sqlite" // ← ???
const createTodo = (title: string, db: Database) =>
Effect.sync(() => {
db.query("INSERT INTO todos (title) VALUES (?)").run(title)
})
Ответы
Раскрыть ответы
Нарушение A: утечка инфраструктуры в домен через типы
sqliteRowId — это деталь хранения (SQLite). Доменная модель не должна знать о структуре таблицы.
Исправление:
// domain/todo.ts — чистый домен
interface Todo {
readonly id: string
readonly title: string
readonly status: TodoStatus
}
// infrastructure/sqlite/types.ts — тип для SQL-слоя
interface TodoRow {
readonly rowid: number
readonly id: string
readonly title: string
readonly status: string
}
Нарушение B: домен вызывает инфраструктуру
sendEmail — инфраструктурная операция. Домен не должен вызывать инфраструктуру напрямую.
Исправление через Effect и порты:
// Порт: уведомления
class NotificationPort extends Context.Tag("NotificationPort")<
NotificationPort,
{ readonly send: (to: string, message: string) => Effect.Effect<void> }
>() {}
// Доменная функция остаётся чистой
const completeOrder = (order: Order): Order => ({
...order,
status: "completed" as const,
})
// Оркестрация — в Application layer
const completeOrderUseCase = (orderId: string) =>
Effect.gen(function* () {
const repo = yield* OrderRepository
const notifications = yield* NotificationPort
const order = yield* repo.findById(orderId)
const completed = completeOrder(order!)
yield* repo.save(completed)
yield* notifications.send(completed.customerEmail, "Order completed!")
return completed
})
Нарушение C: Use Case зависит от конкретной технологии
Database из bun:sqlite — конкретная технология. Use Case должен зависеть от порта (абстракции), а не от реализации.
Исправление:
// Порт
class TodoRepository extends Context.Tag("TodoRepository")<
TodoRepository,
{ readonly save: (title: string) => Effect.Effect<void, RepositoryError> }
>() {}
// Use Case — зависит от порта
const createTodo = (title: string) =>
Effect.gen(function* () {
const repo = yield* TodoRepository
yield* repo.save(title)
})
// Адаптер (Layer) — зависит от конкретной технологии
const SqliteTodoRepo = Layer.effect(
TodoRepository,
Effect.gen(function* () {
const db = yield* SqliteClient
return {
save: (title) => Effect.try({
try: () => { db.query("INSERT INTO todos (title) VALUES (?)").run(title) },
catch: (e) => new RepositoryError({ cause: e }),
}),
}
})
)
Упражнение 3: Рефакторинг из Layered в Hexagonal
Дан код в стиле Layered Architecture. Перепишите его в стиле Hexagonal с использованием Effect-ts.
Исходный код (Layered)
import { Database } from "bun:sqlite"
const db = new Database("app.sqlite")
interface TodoRow {
id: string
title: string
completed: number
created_at: string
}
class TodoService {
listPending(): ReadonlyArray<{ id: string; title: string; createdAt: Date }> {
const rows = db
.query("SELECT * FROM todos WHERE completed = 0 ORDER BY created_at ASC")
.all() as ReadonlyArray<TodoRow>
return rows.map((row) => ({
id: row.id,
title: row.title,
createdAt: new Date(row.created_at),
}))
}
markCompleted(id: string): void {
const row = db
.query("SELECT * FROM todos WHERE id = ?")
.get(id) as TodoRow | null
if (!row) {
throw new Error(`Todo ${id} not found`)
}
if (row.completed === 1) {
throw new Error(`Todo ${id} is already completed`)
}
db.query("UPDATE todos SET completed = 1 WHERE id = ?").run(id)
}
}
Решение
Раскрыть решение
import { Effect, Context, Layer, Data } from "effect"
// ═══════════════════════════════════════════════════
// Слой 1: Domain Model
// ═══════════════════════════════════════════════════
type TodoStatus = "pending" | "completed"
interface Todo {
readonly id: string
readonly title: string
readonly status: TodoStatus
readonly createdAt: Date
}
// Доменные ошибки
class TodoNotFound extends Data.TaggedError("TodoNotFound")<{
readonly id: string
}> {}
class AlreadyCompleted extends Data.TaggedError("AlreadyCompleted")<{
readonly id: string
}> {}
// Доменная логика: чистая функция
const markAsCompleted = (todo: Todo): Todo =>
({ ...todo, status: "completed" as const })
// ═══════════════════════════════════════════════════
// Слой 2: Ports (Context.Tag)
// ═══════════════════════════════════════════════════
class TodoRepository extends Context.Tag("@app/TodoRepository")<
TodoRepository,
{
readonly findById: (id: string) => Effect.Effect<Todo | null>
readonly findByStatus: (status: TodoStatus) => Effect.Effect<ReadonlyArray<Todo>>
readonly save: (todo: Todo) => Effect.Effect<void>
}
>() {}
// ═══════════════════════════════════════════════════
// Слой 3: Use Cases (Application Core)
// ═══════════════════════════════════════════════════
/** Use Case: получить незавершённые задачи */
const listPending = Effect.gen(function* () {
const repo = yield* TodoRepository
return yield* repo.findByStatus("pending")
})
/** Use Case: завершить задачу */
const completeTodo = (id: string) =>
Effect.gen(function* () {
const repo = yield* TodoRepository
const todo = yield* repo.findById(id)
if (todo === null) {
return yield* Effect.fail(new TodoNotFound({ id }))
}
if (todo.status === "completed") {
return yield* Effect.fail(new AlreadyCompleted({ id }))
}
const completed = markAsCompleted(todo)
yield* repo.save(completed)
return completed
})
// ═══════════════════════════════════════════════════
// Слой 4: Adapter (Layer) — InMemory для тестов
// ═══════════════════════════════════════════════════
const InMemoryTodoRepo = Layer.sync(TodoRepository, () => {
const store = new Map<string, Todo>()
return {
findById: (id) => Effect.sync(() => store.get(id) ?? null),
findByStatus: (status) =>
Effect.sync(() =>
[...store.values()]
.filter((t) => t.status === status)
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
),
save: (todo) => Effect.sync(() => { store.set(todo.id, todo) }),
}
})
// ═══════════════════════════════════════════════════
// Слой 4: Adapter (Layer) — SQLite для продакшена
// ═══════════════════════════════════════════════════
// SQLite-специфичный тип (только внутри адаптера!)
interface TodoRow {
readonly id: string
readonly title: string
readonly completed: number
readonly created_at: string
}
const mapRowToTodo = (row: TodoRow): Todo => ({
id: row.id,
title: row.title,
status: row.completed === 1 ? "completed" : "pending",
createdAt: new Date(row.created_at),
})
const SqliteTodoRepo = Layer.scoped(
TodoRepository,
Effect.gen(function* () {
const db = yield* SqliteClient
return {
findById: (id) =>
Effect.sync(() => {
const row = db.query("SELECT * FROM todos WHERE id = ?").get(id) as TodoRow | null
return row ? mapRowToTodo(row) : null
}),
findByStatus: (status) =>
Effect.sync(() => {
const completed = status === "completed" ? 1 : 0
const rows = db
.query("SELECT * FROM todos WHERE completed = ? ORDER BY created_at ASC")
.all(completed) as ReadonlyArray<TodoRow>
return rows.map(mapRowToTodo)
}),
save: (todo) =>
Effect.sync(() => {
db.query(
"INSERT OR REPLACE INTO todos (id, title, completed, created_at) VALUES (?, ?, ?, ?)"
).run(todo.id, todo.title, todo.status === "completed" ? 1 : 0, todo.createdAt.toISOString())
}),
}
})
)
// ═══════════════════════════════════════════════════
// Запуск
// ═══════════════════════════════════════════════════
// Для тестов:
const testProgram = completeTodo("123").pipe(Effect.provide(InMemoryTodoRepo))
// Для продакшена:
// const prodProgram = completeTodo("123").pipe(Effect.provide(SqliteTodoRepo))
Ключевые улучшения после рефакторинга:
- Домен чист:
Todo,TodoStatus,markAsCompleted— ноль зависимостей - Ошибки типизированы:
TodoNotFound,AlreadyCompletedвместоthrow new Error(...) - Инфраструктура изолирована:
TodoRowи SQL живут только внутри адаптера - Тестируемость:
InMemoryTodoRepoдля тестов,SqliteTodoRepoдля продакшена - Dependency Rule enforced: R-канал гарантирует, что все порты предоставлены
Упражнение 4: Спроектируй порты для Todo-приложения
Определите все Context.Tag (порты) для Todo-приложения со следующими требованиями:
- CRUD операции для задач (создать, прочитать, обновить, удалить)
- Фильтрация задач по статусу и приоритету
- Прикрепление файлов к задаче
- Отправка уведомлений при завершении задачи
- Получение текущего времени (для тестируемости)
- Генерация уникальных идентификаторов (для тестируемости)
Для каждого порта определите:
- Имя (Tag)
- Тип: Driving или Driven
- Методы с полной типизацией (включая Error и Requirements)
Решение
Раскрыть решение
import { Effect, Context } from "effect"
// ═══════════════════════════════════════════════════
// Доменные типы (используются в портах)
// ═══════════════════════════════════════════════════
type TodoId = string & { readonly _brand: unique symbol }
type TodoStatus = "pending" | "in_progress" | "completed" | "archived"
type Priority = "low" | "medium" | "high" | "critical"
interface Todo {
readonly id: TodoId
readonly title: string
readonly description: string
readonly status: TodoStatus
readonly priority: Priority
readonly createdAt: Date
readonly completedAt: Date | null
}
interface TodoFilter {
readonly status?: TodoStatus
readonly priority?: Priority
}
interface FileAttachment {
readonly id: string
readonly todoId: TodoId
readonly filename: string
readonly mimeType: string
readonly size: number
}
// ═══════════════════════════════════════════════════
// Доменные ошибки
// ═══════════════════════════════════════════════════
class TodoNotFound extends Data.TaggedError("TodoNotFound")<{ readonly id: TodoId }> {}
class InvalidTransition extends Data.TaggedError("InvalidTransition")<{
readonly from: TodoStatus
readonly to: TodoStatus
}> {}
class DuplicateTitle extends Data.TaggedError("DuplicateTitle")<{ readonly title: string }> {}
class FileNotFound extends Data.TaggedError("FileNotFound")<{ readonly id: string }> {}
class StorageError extends Data.TaggedError("StorageError")<{ readonly cause: unknown }> {}
class NotificationError extends Data.TaggedError("NotificationError")<{ readonly cause: unknown }> {}
// ═══════════════════════════════════════════════════
// DRIVEN PORTS (что приложению нужно от инфраструктуры)
// ═══════════════════════════════════════════════════
/** Driven Port: персистентность задач */
class TodoRepository extends Context.Tag("@todo/TodoRepository")<
TodoRepository,
{
readonly findById: (id: TodoId) => Effect.Effect<Todo | null, StorageError>
readonly findAll: (filter?: TodoFilter) => Effect.Effect<ReadonlyArray<Todo>, StorageError>
readonly save: (todo: Todo) => Effect.Effect<void, StorageError>
readonly delete: (id: TodoId) => Effect.Effect<void, StorageError>
readonly existsByTitle: (title: string) => Effect.Effect<boolean, StorageError>
}
>() {}
/** Driven Port: файловое хранилище */
class FileStorage extends Context.Tag("@todo/FileStorage")<
FileStorage,
{
readonly upload: (todoId: TodoId, filename: string, data: Uint8Array) => Effect.Effect<FileAttachment, StorageError>
readonly download: (attachmentId: string) => Effect.Effect<Uint8Array, FileNotFound | StorageError>
readonly listByTodo: (todoId: TodoId) => Effect.Effect<ReadonlyArray<FileAttachment>, StorageError>
readonly remove: (attachmentId: string) => Effect.Effect<void, StorageError>
}
>() {}
/** Driven Port: уведомления */
class NotificationService extends Context.Tag("@todo/NotificationService")<
NotificationService,
{
readonly notify: (message: string) => Effect.Effect<void, NotificationError>
}
>() {}
/** Driven Port: генерация ID (детерминированность для тестов) */
class IdGenerator extends Context.Tag("@todo/IdGenerator")<
IdGenerator,
{
readonly generate: () => Effect.Effect<TodoId>
}
>() {}
/** Driven Port: часы (детерминированность для тестов) */
class Clock extends Context.Tag("@todo/Clock")<
Clock,
{
readonly now: () => Effect.Effect<Date>
}
>() {}
Обратите внимание:
- Каждый порт — изолированная единица с минимальным API
- Ошибки типизированы:
StorageErrorдля инфраструктурных сбоев,TodoNotFound/FileNotFoundдля доменных IdGeneratorиClock— порты для детерминированности, что критично для тестирования- Все методы возвращают
Effect, а неPromise— это обеспечивает единую модель обработки ошибок и зависимостей
Упражнение 5: Мини-эссе
Ответьте на один из вопросов в 5–10 предложений:
-
Почему Layered Architecture провоцирует Database-Driven Design? Опишите механизм, по которому слоистая архитектура естественно приводит к проектированию «от таблиц БД».
-
В чём принципиальная разница между «зависимости направлены вниз» (Layered) и «зависимости направлены внутрь» (Hexagonal/Clean/Onion)? Почему это не просто перестановка стрелок, а фундаментальное изменение?
-
Как R-канал Effect<A, E, R> реализует Dependency Rule? Объясните, как компилятор TypeScript гарантирует, что все зависимости предоставлены.