Архитектура приложений: модули, слои, SOLID, зависимости, паттерны в JS/TS
Архитектура в контексте JavaScript и TypeScript — это набор решений, которые делают кодовую базу изменяемой и надёжной по мере роста. Если стиль и читаемость помогают понимать отдельные файлы, а дисциплина ошибок и типы помогают делать поведение предсказуемым, то архитектура отвечает на вопрос: как собрать систему так, чтобы изменения не ломали всё вокруг.
Связь с предыдущими темами курса:
Из темы про читаемость берём принцип: смысл должен быть виден по структуре. Архитектура делает эту структуру системной.
Из темы про надёжный JavaScript берём идею границ: ошибки и I/O обрабатываются на краях, а внутри — чистая доменная логика.
Из темы про TypeScript глубоко берём контракты и моделирование состояний. Архитектура помогает правильно разместить эти контракты по слоям и модулям.!Диаграмма показывает слои и направление зависимостей
Что такое хороший архитектурный результат
Хорошая архитектура обычно даёт следующие свойства:
Локальность изменений: изменение в одном месте минимально затрагивает остальные.
Тестируемость: бизнес-правила тестируются без реальной сети, базы и UI.
Ясные границы: понятно, где доменная логика, где I/O, где обработка ошибок, где преобразование данных.
Контролируемые зависимости: верхние уровни не знают деталей нижних, а зависят от контрактов.
Масштабируемость команды: разные люди могут работать в разных частях, не конфликтуя постоянно.Важно: хорошая архитектура — это не максимальная абстракция. Это минимально достаточная структура под текущую сложность и ожидаемый рост.
Модули в JS/TS: базовый строительный блок
В JavaScript модульность обычно означает файлы и импорты. В TypeScript к этому добавляется типовая граница.
ESM как основной формат модулей
Современный стандарт — ECMAScript Modules:
export делает часть модуля публичной.
import фиксирует зависимость явно.Справка:
MDN: JavaScript modulesПрактические правила качества для модулей:
Экспортируйте минимально необходимое. Чем меньше публичный API, тем проще сопровождение.
Не делайте глубокие импорты в чужие внутренности: import x from "features/payments/internal/x" создаёт хрупкие связи.
Старайтесь, чтобы модуль можно было понять по одному файлу входа: обычно это index.ts или явный публичный API.Изоляция и циклические зависимости
Циклические зависимости (A импортирует B, B импортирует A) ухудшают предсказуемость:
сложнее понять порядок инициализации;
часть значений может быть undefined в момент импорта;
растёт вероятность скрытых багов.Практика:
выносите общие типы и контракты в отдельный модуль;
разрывайте циклы через зависимости на интерфейсы и передачу зависимостей параметрами;
избегайте выполнения сложной логики на уровне модуля при импорте.Баррел-файлы: польза и риск
Баррел-файл — это модуль, который реэкспортирует другие:
Плюсы:
проще импортировать публичный API;
меньше шума в местах использования.Риски:
можно случайно сделать внутренние вещи публичными;
иногда усложняется tree-shaking и анализ зависимостей.Если используете баррелы, держите правило: баррел отражает только публичный API.
Слои: как разделять ответственность
Слои — это способ разместить код по типу ответственности. Упрощённая, но практичная схема для многих приложений:
Domain — бизнес-смысл и инварианты.
Application — сценарии использования, оркестрация.
Infrastructure — БД, сеть, файловая система, внешние SDK.
UI/Delivery — HTTP-контроллеры, CLI, компоненты UI.Ключевой принцип: домен не должен зависеть от инфраструктуры.
Domain: бизнес-правила и типы, которые выражают смысл
Domain содержит:
сущности и value objects;
бизнес-правила;
доменные ошибки;
контракты для зависимостей, если они нужны домену.Пример: деньги как целое число, как из темы про edge cases:
Domain должен быть максимально чистым: минимум побочных эффектов и I/O.
Application: use cases и оркестрация
Application отвечает за то, как выполняется сценарий:
какие шаги и в каком порядке;
какие зависимости использовать;
как интерпретировать ожидаемые ошибки.Здесь удобно использовать подход из темы про надёжность: ожидаемые ошибки как значения.
Обратите внимание:
createOrder не делает реальных HTTP-запросов и не знает про SQL.
зависимости передаются через параметр deps.
ошибки, которые являются нормальной частью сценария, возвращаются через Result.Infrastructure: детали I/O и адаптеры
Infrastructure содержит реализации интерфейсов:
репозитории для базы;
HTTP-клиенты;
интеграции;
логирование, метрики.Смысл: инфраструктура зависит от приложения или домена, потому что реализует их контракты.
UI/Delivery: граница и преобразование в протокол
Это слой, который превращает доменные исходы в конкретный протокол: HTTP-ответ, UI-состояние, CLI-вывод.
Именно на границе уместны:
маппинг ошибок на статусы;
логирование;
рантайм-валидация входных данных;
корреляционные идентификаторы.SOLID в JS/TS: как применять без «религии»
SOLID — набор принципов, которые помогают снижать связность и делать код расширяемым. В JS/TS их стоит воспринимать как диагностику проблем, а не как требование «везде классы».
Справка:
Wikipedia: SOLIDSRP: принцип единственной ответственности
SRP: модуль или функция должны иметь одну осмысленную причину для изменения.
Практические признаки нарушения:
функция одновременно валидирует данные, делает запросы и форматирует UI;
модуль является «свалкой» утилит без общей темы;
изменения в протоколе API требуют менять доменные расчёты.Решение обычно архитектурное: разделить на слои и отделить вычисления от эффектов, как мы делали в теме про чистые функции.
OCP: открытость для расширения, закрытость для модификации
Смысл: добавляя новый сценарий, вы должны по возможности добавлять код, а не переписывать существующий.
В JS/TS это часто достигается:
стратегиями;
таблицами соответствий;
композиционными обработчиками.Пример стратегии расчёта скидки:
Добавить новую скидку можно добавлением новой стратегии, не меняя applyDiscount.
LSP: подстановка Лисков
Если тип B является подтипом A, то B должен быть взаимозаменяем с A без сюрпризов.
В JS/TS чаще всего это проявляется не в наследовании, а в реализациях интерфейсов:
репозиторий в памяти и репозиторий в базе должны одинаково соблюдать контракт;
мок для тестов должен вести себя как реальный сервис в важных аспектах.Если реализация нарушает ожидания, это приводит к скрытым багам в use case.
ISP: разделение интерфейсов
Лучше иметь несколько маленьких интерфейсов, чем один «комбайн». Это снижает связанность.
Плохой сигнал:
интерфейс требует реализовать методы, которые не нужны клиенту;
тесты вынуждены мокать лишнее.В TS это решается выделением узких контрактов:
DIP: инверсия зависимостей
Смысл: высокоуровневый код не должен зависеть от низкоуровневых деталей напрямую. Оба должны зависеть от абстракций.
В TS это обычно означает:
интерфейс в домене или приложении;
реализация в инфраструктуре;
связывание в одном месте, часто это называют composition root.!Схема показывает DIP и место сборки зависимостей
Управление зависимостями: как делать связи явными
Dependency injection без фреймворков
В JS/TS часто достаточно простого подхода:
передавать зависимости параметром deps;
создавать зависимости в отдельном модуле сборки.Плюсы:
легко тестировать;
видно, что именно нужно use case;
минимум магии.Границы и анти-коррупционный слой
Если вы интегрируетесь с внешним API, не позволяйте его моделям «протечь» в домен.
Практика:
внешний формат живёт в инфраструктуре;
доменный формат живёт в домене;
между ними адаптер.Это уменьшает стоимость изменений: если внешний API поменялся, доменная часть остаётся стабильной.
Конфигурация как зависимость
Конфигурация (env, URL-ы, ключи) — тоже зависимость. Не тащите её в домен.
Правило:
домен не читает process.env и не знает про окружение;
конфигурация читается на границе и передаётся вниз как данные.Справка по переменным окружения Node.js:
Node.js documentation: process.envПаттерны проектирования, которые реально полезны в JS/TS
Паттерны ценны не названиями, а тем, что дают стандартные решения типовых проблем. Ниже — практичные паттерны, которые часто повышают качество в JS/TS.
Strategy: заменяет ветвление и поддерживает OCP
Подходит, когда:
есть несколько вариантов поведения;
варианты переключаются по условию;
ожидается рост вариантов.Реализация может быть функцией, а не классом.
Adapter: защита домена от внешних форматов
Подходит, когда:
внешний SDK имеет неудобный интерфейс;
внешний API нестабилен;
вам нужно преобразование данных.Здесь адаптер изолирует:
формат запроса;
ошибки провайдера;
семантику ответа.Repository: отделяет домен от хранения
Подходит, когда:
вы хотите тестировать без реальной базы;
есть несколько реализаций хранения;
нужна единая точка доступа к агрегату.Важно: репозиторий не должен превращаться в «бог-объект» для любых запросов. Сохраняйте фокус на доменной модели.
Factory: явное создание сложных объектов
Подходит, когда:
создание сущности требует валидации;
нужен единый способ создать корректный объект.В TS factory часто лучше делать функцией, а не классом.
Command: единица действия и логирование
Подходит, когда:
нужно логировать, повторять, откатывать действия;
есть очередь задач;
важна трассировка.В JS/TS команда часто выражается объектом с методом execute или просто функцией с метаданными.
Типичная структура проекта: практичный шаблон
Универсальной структуры нет, но можно начать с простой схемы, которая хорошо масштабируется:
src/domain/
src/application/
src/infrastructure/
src/ui/ или src/delivery/
src/shared/ для действительно общих вещейПринципы, чтобы структура работала:
Доменные типы и функции не импортируют из инфраструктуры.
Application может импортировать домен.
Infrastructure импортирует домен и application, чтобы реализовать их контракты.
UI импортирует application и собирает зависимости.Архитектурные анти-паттерны, которые часто портят качество
«Сервисный слой» без смысла
Симптом:
всё называется SomethingService;
функции делают «всё понемногу»;
нет явных use case и доменной модели.Лечение:
назвать сценарии как use case (createOrder, cancelSubscription);
разделить чистые вычисления и эффекты;
выделить контракты на зависимости.Протекание инфраструктуры в домен
Симптом:
доменная функция принимает Request или Response;
доменные типы содержат поля, нужные только базе или UI.Лечение:
DTO на границе;
маппинг и адаптеры;
доменные типы выражают бизнес-смысл, а не формат хранения.Слишком ранняя абстракция
Симптом:
интерфейсы создаются «на всякий случай»;
много уровней проксирования;
чтение кода усложняется без явной выгоды.Практика:
абстрагируйте там, где есть вариативность или дорогой риск;
не бойтесь прямых вызовов внутри одного слоя;
держите цель: облегчить изменения и тестирование.Как понять, что архитектуру пора улучшать
Признаки, что текущая структура мешает качеству:
любое изменение требует правок в десятках файлов;
тесты трудно писать без поднятия базы и сети;
код-ревью постоянно упирается в вопрос «куда это положить»;
количество багов от асинхронности и ошибок интеграций растёт;
появляется страх рефакторинга.Архитектура — это продолжение всех предыдущих тем: она делает читаемость масштабируемой, надёжность управляемой, а TypeScript-контракты размещает там, где они дают максимальную пользу.
Стабильная цель: доменная логика остаётся чистой и проверяемой, инфраструктура заменяемой, а границы — явными.