Архитектурные паттерны Архитектурные паттерны: карта трека и как выбирать архитектуру
0%

Архитектурные паттерны: карта трека и как выбирать архитектуру

Архитектурные паттерны: карта трека и как выбирать архитектуру

Эта статья — вход в трек. Она не пересказывает остальные двенадцать материалов, а даёт то, без чего они превращаются в набор модных слов: систему координат. После неё вы сможете ответить про любую систему на три вопроса: какие решения здесь архитектурные, какими силами они продиктованы и когда их можно будет пересмотреть.

1. Что такое архитектура и почему это не «схема с квадратиками»

Самое рабочее определение принадлежит Ральфу Джонсону, его любит цитировать Мартин Фаулер в эссе «Who Needs an Architect?»:

Архитектура — это те решения, которые разработчики считают трудноизменяемыми, и общее понимание системы, разделяемое опытными разработчиками.

Здесь два независимых утверждения, и оба важны.

Первое: архитектура = множество дорогих в отмене решений. Переименовать метод — не архитектура: цена отмены близка к нулю. Сменить SQL на key-value хранилище, разрезать приложение на процессы, выбрать межсервисный протокол — архитектура: отмена стоит месяцы. Amazon называет это «дверями в одну и в две стороны»: обратимые решения принимаются быстро и локально, необратимые — медленно, с записью и явным разбором альтернатив.

Второе: архитектура — это разделяемая модель. Если пять инженеров рисуют пять разных схем одной системы, архитектуры нет, даже если есть диаграмма в Confluence. Отсюда ценность общего языка: C4 model для уровней детализации, arc42 для структуры описания, ADR для истории решений.

Есть и третье, эмпирическое определение, удобное для оценки: архитектура определяет форму кривой стоимости изменений. Хорошая держит цену типового изменения примерно постоянной по мере роста системы; плохая — делает её растущей.

Стоимость изменения при недоинженерии, переинженерии и эволюционном подходе

Обратите внимание на жёлтую кривую: переинженерия — это не «безопасно, но дороже», это тоже проигрыш. Вы заплатили за гибкость по осям, по которым система так и не менялась, и одновременно замедлили обучение — потому что каждая гипотеза проверяется дольше.

2. Карта трека

Маршруты чтения под задачу: навести порядок в коде — 01 → 02 → 11; решить, резать ли монолит — 02 → 03 → 06 → 11; спроектировать интеграции — 07 → 04 → 06; выдержать нагрузку — 08 → 09 → 12; данные и консистентность — 05 → 06 → 04; готовиться к system design интервью — 12 → 03 → 08 → 09. Полный список:

  • https://courses.digitable.life/post/architecture-patterns/01-layered-hexagonal-clean/ — как устроены границы внутри одного приложения.
  • https://courses.digitable.life/post/architecture-patterns/02-monolith-and-modular-monolith/ — почему модульный монолит чаще всего правильный ответ.
  • https://courses.digitable.life/post/architecture-patterns/03-microservices/ — границы, коммуникация, данные и настоящая цена распределённости.
  • https://courses.digitable.life/post/architecture-patterns/04-event-driven/ — брокеры, топики, гарантии доставки, порядок сообщений.
  • https://courses.digitable.life/post/architecture-patterns/05-cqrs-and-event-sourcing/ — разделение моделей чтения и записи, журнал событий как источник истины.
  • https://courses.digitable.life/post/architecture-patterns/06-saga-and-distributed-transactions/ — что делать, когда двухфазный коммит недоступен.
  • https://courses.digitable.life/post/architecture-patterns/07-api-styles/ — REST, GraphQL, gRPC, вебхуки, эволюция контрактов.
  • https://courses.digitable.life/post/architecture-patterns/08-caching-and-scaling/ — уровни кэша, инвалидация, шардирование, репликация.
  • https://courses.digitable.life/post/architecture-patterns/09-resilience-patterns/ — circuit breaker, retry, bulkhead, backpressure, graceful degradation.
  • https://courses.digitable.life/post/architecture-patterns/10-serverless-and-edge/ — модель без серверов и вычисления на краю сети.
  • https://courses.digitable.life/post/architecture-patterns/11-architecture-decisions/ — ADR, анализ компромиссов, ATAM, fitness functions.
  • https://courses.digitable.life/post/architecture-patterns/12-system-design/ — сквозной практикум: от требований до цифр и схемы.

Трек опирается на соседние курсы: https://courses.digitable.life/post/principles/00-overview/ (связность и зацепление — язык, на котором формулируются причины решений), https://courses.digitable.life/post/design-patterns/00-overview/ (паттерны уровня классов), https://courses.digitable.life/post/ddd/00-overview/ (моделирование домена и границы контекстов) и https://courses.digitable.life/post/data-engineering/00-overview/ (аналитические нагрузки).

3. Оси, а не список стилей

Архитектуру часто воспринимают как выбор из меню: «монолит, микросервисы или serverless». Это ложная дихотомия — на деле вы независимо выбираете положение по нескольким осям:

  1. Разделение кода — насколько явны границы модулей и запрещены ли обращения внутрь чужого.
  2. Разделение развёртывания — сколько независимо релизимых юнитов.
  3. Разделение данных — одна схема на всех или своё хранилище у каждого владельца.
  4. Синхронность связи — вызов с ожиданием ответа или сообщение.
  5. Управление процессом — оркестрация (есть дирижёр) или хореография (каждый реагирует сам).

Две первые оси дают самую наглядную картинку:

Пространство архитектурных стилей по осям распределённости и автономности данных

Ключевой вывод — красный путь. Распределённый монолит получается, когда разделение развёртывания выросло, а разделение кода и данных — нет: сервисы ходят в общую БД, релизятся только вместе, но каждый вызов теперь идёт по сети и может отказать. Все издержки распределённой системы и ни одного преимущества. Отсюда MonolithFirst Фаулера: границы почти никогда не удаётся угадать до встречи с реальными пользователями. Контраргумент Stefan Tilkov тоже верен: если границы известны из домена заранее, монолит-первый создаёт связность, которую резать дороже. Обе позиции сходятся в одном: дорого не количество сервисов, а неверно проведённые границы.

4. Движущие силы: атрибуты качества

Функциональные требования почти не влияют на выбор архитектуры — «показать список заказов» реализуется в любом стиле. Влияют атрибуты качества: характеристики как, а не что. Каталог — ISO/IEC 25010, метод работы с ними — Bass, Clements, Kazman, «Software Architecture in Practice».

Главная ошибка — формулировать их прилагательными. «Система должна быть быстрой и надёжной» — не требование, а пожелание. Требование — сценарий атрибута качества из шести частей:

Источник:      10 000 одновременных пользователей
Стимул:        отправляют запрос на оформление заказа
Артефакт:      сервис checkout
Условия:       обычная нагрузка, один AZ недоступен
Отклик:        заказ принят и подтверждён
Мера отклика:  p99 <= 400 мс, доля ошибок < 0.1% за 5-минутное окно

Такой сценарий проверяется автоматически и однозначно указывает на решения: «один AZ недоступен» требует репликации и запаса ёмкости, «p99 400 мс» запрещает синхронную цепочку из восьми сервисов, «доля ошибок» задаёт бюджет ошибок в смысле Google SRE. Атрибуты конфликтуют между собой — и работа с этими конфликтами и есть работа архитектора:

Усиливаем Обычно платим
Доступность через репликацию Консистентностью (статьи 05, 06)
Производительность через кэш Свежестью данных и сложностью инвалидации (08)
Автономность команд через сервисы Сквозной наблюдаемостью и отладкой (03, 09)
Безопасность через изоляцию Задержкой и стоимостью эксплуатации
Скорость первой поставки Стоимостью изменений через год (график выше)

Правило, которое стоит запомнить дословно: архитектура — это не выбор лучшего решения, а осознанный выбор того, чем вы платите. Если вы не можете назвать, что стало хуже, — вы просто ещё не нашли, что именно.

5. Закон Конвея как проектный инструмент

Мелвин Конвей (1967) заметил: организации проектируют системы, копирующие структуру их коммуникаций. Это не афоризм, а ограничение, которое ломает архитектуры чаще, чем технологии. Практический вывод — «обратный манёвр Конвея»: если нужна архитектура из N автономных частей, сначала сделайте N команд, владеющих этими частями целиком, вместе с эксплуатацией. Три команды — фронтенд, бэкенд, DBA — физически не смогут поддерживать двадцать автономных сервисов: каждое изменение потребует согласования трёх команд, то есть станет распределённым монолитом на уровне процесса (Team Topologies). Отсюда эвристика для оценки чужого решения: посчитайте, сколько команд должно договориться, чтобы выкатить типовую фичу. Если больше двух — проблема в границах, а не в CI.

6. Как выбирать: явная процедура

Не «интуиция опытного архитектора», а воспроизводимые шаги.

Обратите внимание: почти все ветки ведут не к «микросервисам», а к точечному ответу на конкретное узкое место. Это и есть эволюционная архитектура — решение принимается в момент максимума информации, а не минимума. Сравнение стилей по двум практическим осям — операционной сложности и требуемой автономности команд:

Правый нижний квадрант — то самое состояние, в которое команды попадают чаще всего: операционная сложность распределённой системы уже оплачена, автономность ещё не получена.

7. Считаем, а не спорим: количественный выбор

Спор «монолит против микросервисов» становится продуктивным, когда превращается в таблицу взвешенных оценок. Ниже — маленькая рабочая модель. Она не «решает за вас»: её ценность в том, что она заставляет явно назвать веса и оценки, а потом их обсуждать.

"""Взвешенный выбор архитектурного стиля по атрибутам качества."""
from dataclasses import dataclass

ATTRS = ("скорость_поставки", "простота_эксплуатации", "масштабируемость",
         "изоляция_отказов", "автономность_команд", "стоимость_владения")


@dataclass(frozen=True)
class Style:
    name: str
    scores: dict[str, int]   # оценки 0..5 по каждому атрибуту из ATTRS
    teams_required: int      # сколько автономных команд нужно, чтобы стиль окупился


@dataclass(frozen=True)
class Context:
    weights: dict[str, float]        # важность атрибутов, сумма произвольна
    teams: int                       # команд с владением end-to-end
    platform_maturity: float = 1.0   # 0..1: CI/CD, трассировки, дежурства, IaC


def style(name: str, row: tuple[int, ...], teams: int) -> Style:
    return Style(name, dict(zip(ATTRS, row)), teams)


# Оценки — не истина, а стартовая точка для калибровки на своём контексте.
STYLES = [
    style("Монолит",               (5, 5, 2, 1, 1, 5), 1),
    style("Модульный монолит",     (4, 4, 3, 2, 3, 4), 2),
    style("Сервисы по контекстам", (3, 2, 4, 4, 4, 2), 4),
    style("Микросервисы",          (2, 1, 5, 5, 5, 1), 8),
]


def evaluate(s: Style, ctx: Context) -> float:
    """Взвешенная оценка стиля с поправкой на организационные ограничения."""
    total = sum(ctx.weights.values()) or 1.0
    base = sum(s.scores.get(a, 0) * w for a, w in ctx.weights.items()) / total
    if ctx.teams < s.teams_required:      # организационный разрыв: команд не хватает
        base /= s.teams_required / max(ctx.teams, 1)
    if s.teams_required > 1:              # незрелая платформа бьёт по распределённым
        base *= 0.4 + 0.6 * ctx.platform_maturity
    return round(base, 3)


def rank(ctx: Context) -> list[tuple[str, float]]:
    return sorted(((s.name, evaluate(s, ctx)) for s in STYLES),
                  key=lambda pair: pair[1], reverse=True)


if __name__ == "__main__":
    cases = {  # веса перечислены в том же порядке, что и ATTRS
        "Стартап, 4 инженера":   Context(dict(zip(ATTRS, (5, 4, 1, 1, 1, 4))), 1, 0.3),
        "Маркетплейс, 9 команд": Context(dict(zip(ATTRS, (2, 2, 5, 5, 5, 2))), 9, 0.9),
    }
    for title, ctx in cases.items():
        print(title)
        for name, score in rank(ctx):
            print(f"  {score:5.3f}  {name}")

Вывод:

Стартап, 4 инженера     Маркетплейс, 9 команд
  4.312  Монолит          3.715  Микросервисы
  1.088  Модульный        3.312  Сервисы по контекстам
  0.390  Сервисы          2.865  Модульный монолит
  0.150  Микросервисы     2.381  Монолит

Разрыв в первом случае почти тридцатикратный — и это не про «микросервисы плохие», а про то, что при одной команде и незрелой платформе они не могут окупиться в принципе.

Сложность. S — число стилей, A — число атрибутов: evaluateO(A) по времени и O(1) по дополнительной памяти, rankO(S·A + S·log S) по времени и O(S) по памяти. Числа микроскопические, и это принципиально: модель должна быть достаточно дешёвой, чтобы её пересчитывали на каждом квартальном ревью с изменившимися весами.

Чего модель не делает: она не знает про регуляторику (требование держать персональные данные в отдельном контуре может перевесить всё), про легаси, про стоимость найма под стек, а оценки взяты «в среднем по индустрии». Её единственная настоящая функция — сделать разногласия видимыми: если два инженера расходятся в выводе, они расходятся в конкретном весе конкретного атрибута, а это уже обсуждаемо.

8. Решения нужно фиксировать: ADR и его жизненный цикл

Архитектура, о которой не осталось письменных следов, деградирует за один цикл ротации команды: новые люди не знают, какие альтернативы уже отвергнуты и почему. Дешёвое противоядие — Architecture Decision Record Майкла Найгарда: одна страница в репозитории на одно значимое решение.

Неизменяемость — не формализм: ценность ADR в истории. «Мы выбрали PostgreSQL, потому что в 2024 у нас не было опыта эксплуатации Cassandra» — это знание о причинах, и именно оно объясняет, почему в 2026 решение можно пересматривать.

Минимальный набор разделов: контекст (цифры, а не ощущения), решение, альтернативы с причиной отказа, последствия — обязательно и плюсы, и минусы — и критерий пересмотра («если лаг консьюмера p99 > 60 с — возвращаемся к вопросу»). Последний раздел отличает живой ADR от бюрократии: он заранее говорит, при каких наблюдаемых условиях решение перестаёт быть верным. Полный формат и ATAM — в https://courses.digitable.life/post/architecture-patterns/11-architecture-decisions/.

9. Архитектура, которую проверяет CI: fitness functions

Решение «домен не зависит от инфраструктуры» живёт ровно до первого спешного релиза, если его не проверяет машина. Идея fitness function из Building Evolutionary Architectures проста: любое архитектурное правило превращается в автотест.

"""Fitness function: домен не должен импортировать инфраструктуру.

Работает на статическом AST: проверяемый код не импортируется и не выполняется —
это важно для CI, где побочные эффекты недопустимы.
"""
import ast
import pathlib
import sys

# Пакет-ключ не имеет права импортировать ни один из пакетов-значений.
FORBIDDEN: dict[str, tuple[str, ...]] = {
    "app.domain": ("app.infra", "sqlalchemy", "requests", "boto3", "kafka"),
    "app.application": ("app.infra", "sqlalchemy"),
}


def module_name(path: pathlib.Path, root: pathlib.Path) -> str:
    """app/domain/order.py -> app.domain.order"""
    rel = path.relative_to(root).with_suffix("")
    return ".".join(p for p in rel.parts if p != "__init__")


def imports_of(tree: ast.AST) -> set[str]:
    """Имена всех модулей, импортируемых в файле."""
    found: set[str] = set()
    for node in ast.walk(tree):
        if isinstance(node, ast.Import):
            found.update(alias.name for alias in node.names)
        elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
            found.add(node.module)
    return found


def check(root: pathlib.Path) -> list[str]:
    """Список нарушений вида 'модуль -> запрещённый импорт'."""
    violations = []
    for path in root.rglob("*.py"):
        name = module_name(path, root)
        banned = tuple(b for pkg, bs in FORBIDDEN.items()
                       if name == pkg or name.startswith(pkg + ".") for b in bs)
        if not banned:
            continue
        tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
        for imp in sorted(imports_of(tree)):
            if any(imp == b or imp.startswith(b + ".") for b in banned):
                violations.append(f"{name} -> {imp}  ({path})")
    return violations


if __name__ == "__main__":                 # запуск в CI: python arch_check.py src/
    problems = check(pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else "."))
    print(*(f"ARCH VIOLATION: {p}" for p in problems), sep="\n")
    sys.exit(1 if problems else 0)

Сложность. O(N · T) по времени, где N — число файлов, T — средний размер AST (разбор и один обход на файл), и O(T_max) по памяти — дерево держим по одному файлу за раз. На репозитории в 5000 файлов это единицы секунд, значит проверку можно ставить в pre-commit.

Такой тест — это исполняемое ADR: когда через год кто-то импортирует SQLAlchemy в доменную модель, сборка упадёт с внятным сообщением. Промышленные аналоги: ArchUnit для Java, NetArchTest для .NET, import-linter для Python, dependency-cruiser для TypeScript. Разумный набор — 5–15 функций: направления зависимостей, запрет доступа к чужой БД, лимит p95 в нагрузочном тесте, размер публичного API модуля, наличие таймаута у каждого HTTP-клиента.

10. Немного истории: почему стили сменяли друг друга

Каждый стиль решал боль предыдущего и создавал новую. Знание цепочки защищает от восприятия свежего термина как прогресса.

Показательна судьба ESB: централизованная шина решала интеграцию, но становилась узким местом и техническим, и организационным — отсюда лозунг микросервисов «smart endpoints, dumb pipes». Через десять лет выяснилось, что сотни мелких сервисов стоят дороже, чем считалось, и маятник качнулся к модульному монолиту (https://courses.digitable.life/post/architecture-patterns/02-monolith-and-modular-monolith/). Ни один из переходов не был «прогрессом вообще» — каждый обменивал одни издержки на другие.

11. Типичные ошибки

  • Resume-driven development. Технологию выбрали потому, что её интересно изучить. Признак: в обосновании нет ни одного измеримого сценария атрибута качества.
  • Архитектура под нагрузку, которой нет. Шардирование при 200 RPS, Kafka ради трёх событий в минуту. Считайте: 1000 RPS при 20 мс обработки — это ~20 одновременных запросов, один средний сервер. Прикидка «на салфетке» из https://courses.digitable.life/post/architecture-patterns/12-system-design/ экономит годы.
  • Границы по техническим слоям вместо доменных. Сервис «валидации», сервис «работы с БД», сервис «нотификаций»: любая фича трогает все три — та же связность, только через сеть. Границы проводят по ограниченным контекстам (https://courses.digitable.life/post/ddd/00-overview/), а слои живут внутри сервиса (https://courses.digitable.life/post/architecture-patterns/01-layered-hexagonal-clean/).
  • Общая база у нескольких сервисов. Схема БД становится публичным API без версионирования, любая миграция ломает соседей (https://courses.digitable.life/post/architecture-patterns/03-microservices/).
  • Игнорирование сети как источника отказов. «Восемь заблуждений распределённых вычислений» Питера Дойча живы: синхронная цепочка из пяти сервисов с доступностью 99.9% каждый даёт 99.5% — почти четыре часа простоя в месяц (https://courses.digitable.life/post/architecture-patterns/09-resilience-patterns/).
  • Отсутствие обратимости. Нет ни флага, ни способа откатиться, ни метрики, по которой станет видно, что решение ошибочно.
  • Диаграмма вместо архитектуры. Схема в вики, разошедшаяся с кодом за два спринта; лечится fitness functions и генерацией схем из кода.
  • «Мы всё перепишем». Пока вы пишете новую систему, старая набирает фичи. Альтернатива — Strangler Fig: новое растёт рядом, трафик переключается по частям, старое удаляется постепенно.

12. Как это выглядит в проде

  • Архитектура — это то, что видно на дашборде. Разговор начинается не со схемы, а с цифр: p50/p99 по эндпоинтам, доля ошибок, лаг консьюмеров, насыщение пулов. Минимальный набор задают «четыре золотых сигнала» из Google SRE: latency, traffic, errors, saturation.
  • Обзоры регулярны и дёшевы. Не «архитектурный комитет раз в квартал», а короткий разбор при появлении очередной «двери в одну сторону»: ADR плюс получасовое ревью с соседями.
  • Готовность важнее замысла. Фаулер перечисляет предпосылки для сервисов: быстрое provisioning, базовый мониторинг, быстрый деплой — практически это CI/CD, распределённая трассировка, централизованные логи, дежурства, IaC. Без них переход к сервисам гарантированно приводит в правый нижний квадрант из раздела 6.
  • Метрики потока важнее метрик кода. DORA выделяет четыре предиктора: частота деплоев, lead time, время восстановления, доля неудачных изменений. Если после архитектурного изменения ни одна не улучшилась — изменение не окупилось.
  • Архитектура живёт в бюджете. Managed-сервисы, трафик между зонами доступности, хранение событий — строки в счёте. Стоимость владения — такой же атрибут качества, как задержка.

13. Мини-итог

  • Архитектура — множество трудноотменяемых решений плюс разделяемое понимание системы; практический критерий качества — форма кривой стоимости изменений.
  • Стиль не выбирается из меню: вы независимо выбираете положение по осям — разделение кода, развёртывания, данных, синхронность связи, оркестрация против хореографии.
  • Решения диктуют атрибуты качества, а они конфликтуют: не назвали, чем платите, — значит, ещё не приняли решение. Закон Конвея при этом ограничение первого порядка: архитектура не бывает автономнее структуры команд.
  • Фиксируйте решения в ADR с критерием пересмотра, инварианты — в fitness functions в CI.
  • Самая дорогая ошибка — распределённый монолит: издержки сети оплачены, автономность не получена.

Источники

Что дальше

Координаты есть. Дальше — самый нижний масштаб: как проводятся границы внутри одного приложения, ещё до всяких сервисов. Это фундамент, без которого разделение на процессы бессмысленно: Многоуровневая, гексагональная, луковичная и чистая архитектуры.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов