Архитектурные паттерны Архитектурные решения: ADR, trade-offs, ATAM, эволюционная архитектура
0%

Архитектурные решения: ADR, trade-offs, ATAM, эволюционная архитектура

Архитектурные решения: ADR, trade-offs, ATAM, эволюционная архитектура

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

Тезис, вокруг которого всё строится: архитектура — это не набор компонентов, а набор решений. Компоненты — их следствие. Диаграмма показывает результат, но не показывает, какие три альтернативы были отброшены и по какой причине; а именно это знание определяет, можно ли систему менять и когда решение придётся пересмотреть. Ральф Джонсон формулировал это как «решения, которые разработчики считают трудноизменяемыми» — определение приведено и разобрано в обзоре трека.

Отсюда четыре навыка, которым посвящены разделы ниже:

  1. Распознать архитектурно значимое решение среди сотни обычных.
  2. Записать его так, чтобы через два года оно объясняло себя само (ADR).
  3. Сравнить альтернативы честно — через атрибуты качества, а не через вкус (trade-offs, ATAM, CBAM).
  4. Сделать решение обратимым и защитить инварианты автотестами (эволюционная архитектура).

Дисциплина эта не новая, и полезно видеть её родословную — методы оценки старше, чем облака:


1. Что делает решение архитектурно значимым

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

Рабочий критерий состоит из трёх независимых осей:

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

Охват. Сколько сущностей затронуто? Решение внутри одного модуля не архитектурно, даже если оно сложное. Решение, которое обязаны знать все команды («межсервисное взаимодействие только через gRPC с контрактом в общем репозитории»), архитектурно по определению — оно становится ограничением для чужой работы.

Влияние на атрибуты качества. Меняет ли решение измеримое поведение системы — латентность, доступность, стоимость владения, модифицируемость? Если да, оно попадает в пространство компромиссов и требует явного разбора.

Формально: решение архитектурно значимо, если оно набирает высокий балл хотя бы по одной оси и ненулевой по остальным. В литературе такие решения называют ASR — architecturally significant requirements/decisions.

Правый нижний квадрант — самый коварный. Решение вроде «пишем новый сервис на Rust, потому что команде интересно» кажется локальным (одна команда, один сервис), но необратимо: через год этот сервис нужно кому-то поддерживать, а компетенций в компании нет. Формально охват низкий, фактически — вы создали одностороннюю дверь, которую никто не заметил.

Практическое правило. Заводите ADR, если верно хотя бы одно: решение стоит дороже двух недель на отмену; решение вводит новый элемент в технологический ландшафт компании (новая БД, новый язык, новый брокер); решение меняет контракт между командами; решение сознательно ухудшает один атрибут качества ради другого.


2. ADR: формат, который действительно работает

Architecture Decision Record предложил Майкл Найгард в заметке «Documenting Architecture Decisions» (2011). Идея минималистична до предела: один markdown-файл на одно решение, файл лежит в том же репозитории, что и код, и меняется тем же процессом ревью.

Ценность ADR — не в описании того, что построено (это видно из кода), а в фиксации контекста и отвергнутых альтернатив. Код отвечает на «что», ADR отвечает на «почему именно так, а не иначе, и при каких условиях это перестанет быть верным».

2.1. Полный пример

Ниже настоящий по структуре ADR (в шаблоне MADR с дополнением, которого в MADR нет, но которое отличает живой документ от мёртвого — критерия пересмотра).

# ADR-0021: Транзакционный outbox вместо прямой публикации в Kafka

**Статус**: Accepted
**Дата**: 2026-03-14
**Заменяет**: ADR-0009 (прямая публикация из обработчика команды)
**Решение принял**: команда Payments, консультации: Platform, SRE
**Затрагивает**: payments-api, payments-worker, все потребители топика `payments.events`

## Контекст

За январь–февраль зафиксировано 47 расхождений между таблицей `payments` и топиком
`payments.events`: платёж закоммичен в PostgreSQL, publish в Kafka упал по таймауту,
ретрая не было. Ручное восстановление одного расхождения занимает ~40 минут работы
дежурного. Бизнес-требование: расхождений быть не должно — на события завязано начисление
бонусов и бухгалтерская выгрузка.

Нагрузка: 320 платежей/с в пике, p99 записи в PostgreSQL 12 мс, SLA на публикацию
события — не более 30 с от коммита (потребители асинхронные).

## Рассмотренные варианты

1. **Ретраи публикации в памяти процесса.** Не решает проблему: падение пода между
   коммитом и публикацией теряет событие безвозвратно. Отвергнут.
2. **Двухфазный коммит между PostgreSQL и Kafka (XA).** Kafka не поддерживает XA как
   ресурс-менеджер; даже с обвязкой это блокирующий протокол, перемножающий доступности.
   Отвергнут, подробности — в статье трека про распределённые транзакции.
3. **Transactional outbox с polling-релеем.** Событие пишется в таблицу `outbox` в той же
   локальной транзакции; отдельный процесс раз в 200 мс забирает пачку и публикует.
   Задержка добавляет ≤ 400 мс к p99 доставки. Стоимость: одна таблица, один воркер.
4. **Transactional outbox с CDC-релеем (Debezium).** Меньше нагрузки на БД, но добавляет
   Kafka Connect в эксплуатационный контур; у SRE нет опыта дежурства по Connect.

## Решение

Выбран вариант 3 — outbox с polling-релеем, публикация at-least-once, все потребители
обязаны быть идемпотентными (inbox-таблица с UNIQUE по `event_id`).

Вариант 4 остаётся предпочтительной эволюцией: переход с polling на CDC не меняет контракт
для потребителей, только реализацию релея.

## Последствия

**Положительные.** Расхождения между БД и топиком становятся структурно невозможными.
Публикация переживает падение любого пода. Появляется естественная точка наблюдения:
глубина очереди `outbox` — прямой индикатор здоровья интеграции.

**Отрицательные.** +1 воркер в эксплуатации и его дежурство. Дополнительная запись в БД:
+18 % IOPS на `payments` по замерам нагрузочного теста. Порядок событий гарантируется
только внутри одного `aggregate_id`. Возможна доставка дубликатов — все потребители
обязаны стать идемпотентными, это работа на их стороне (заведены тикеты PAY-812…816).

**Нейтральные.** Таблица `outbox` требует регламента очистки: партиционирование по дню,
DROP партиций старше 7 суток.

## Критерий пересмотра

Возвращаемся к этому решению, если выполнится любое:
- лаг публикации p99 превысит 5 с в течение недели (polling перестал справляться);
- рост IOPS на `payments` начнёт упираться в лимит инстанса;
- SRE освоят дежурство по Kafka Connect (тогда переход на вариант 4).

Проверяется на квартальном архитектурном ревью, дашборд: `payments/outbox-health`.

Разберём, почему именно эти разделы.

Контекст в цифрах, а не в ощущениях. «У нас бывают проблемы с консистентностью» — непроверяемо. «47 расхождений за два месяца, 40 минут ручной работы на каждое» — это факт, и через год по нему видно, изменилась ли ситуация.

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

Последствия с явным списком минусов. Если в ADR только плюсы, это не решение, а реклама. Правило: у настоящего архитектурного решения всегда есть отрицательные последствия — если вы их не нашли, вы их не искали.

Критерий пересмотра. Заранее названные наблюдаемые условия, при которых решение перестаёт быть верным. Этот раздел превращает ADR из архива в инструмент управления: без него никто никогда не вернётся к решению, потому что нет триггера.

2.2. Y-statement: ADR в одну строку

Для решений средней значимости годится сжатая форма Y-statement (Олаф Циммерман):

В контексте <ситуация>, столкнувшись с <проблемой>, мы выбрали <вариант>, чтобы достичь <качества>, приняв <недостаток>.

Пример: «В контексте синхронного API каталога, столкнувшись с p99 в 900 мс при пиковой нагрузке, мы выбрали read-through кэш в Redis с TTL 60 с, чтобы удерживать p99 ≤ 200 мс, приняв окно устаревания данных до минуты и деградацию каталога при недоступности Redis до прямых обращений к БД».

Одно предложение содержит контекст, силу, решение, цель и цену. Такую форму реалистично писать прямо в описании pull request — и это резко повышает шанс, что её вообще напишут.

2.3. Журнал решений как связный граф

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

Две связи здесь особенно важны. ADR → QUALITY_SCENARIO — это ответ на вопрос «ради чего мы это сделали»; без неё решение невозможно оценить постфактум. ADR → FITNESS_FUNCTION — ответ на вопрос «что мешает решению тихо развалиться»; о ней подробно в разделе 7.


3. Где живут ADR и как их не потерять

Файлы в репозитории рядом с кодом, docs/adr/0021-transactional-outbox.md. Не в Confluence, не в Notion: документ должен меняться тем же pull request, что и код, иначе он неизбежно разойдётся с реальностью. Это же даёт бесплатную историю в git — видно, кто возражал в ревью.

Нумерация — сквозная, четыре цифры, номер никогда не переиспользуется. Имя файла содержит номер и slug, чтобы ссылки из кода (# см. ADR-0021) находились через grep.

# adr-tools: минимальный CLI поверх соглашения о файлах
# https://github.com/npryce/adr-tools
adr init docs/adr                       # создаёт каталог и ADR-0001 о самом использовании ADR
adr new "Транзакционный outbox вместо прямой публикации в Kafka"
adr new -s 9 "Транзакционный outbox ..."   # -s: новое ADR заменяет ADR-0009,
                                           # статусы обоих проставляются автоматически
adr link 21 "Уточняет" 14 "Уточнено в"     # произвольная связь между решениями
adr generate toc > docs/adr/README.md      # индекс всех решений
adr generate graph | dot -Tsvg > adr.svg   # граф связей в Graphviz

Альтернативы: log4brains (генерирует статический сайт с историей и таймлайном решений), MADR (шаблон + линтер), каталог шаблонов на adr.github.io.

3.1. Жизненный цикл: решение и код идут вместе

Ключевая деталь: ADR принимается отдельным PR, до реализации. Если решение и код едут в одном PR, обсуждение неизбежно сваливается на уровень кода, и альтернативы никто не обсудит — их уже поздно обсуждать, работа сделана.

И второе: ADR не редактируют задним числом. Изменилось решение — заводится новое ADR со статусом, заменяющим старое. Ценность журнала именно в том, что он показывает эволюцию мышления: «в 2024 у нас не было опыта эксплуатации Connect» объясняет, почему в 2026 то же решение уже принимается иначе.

3.2. Линтер журнала решений

Журнал из 80 ADR быстро расползается: у половины нет раздела «Последствия», кто-то написал «заменяет ADR-0009», забыв поменять статус у 0009, а где-то возникло кольцо замен. Всё это проверяется машиной за секунды и ставится в CI.

"""Линтер журнала архитектурных решений: структура файлов + целостность графа замен.

Запуск в CI:  python adr_lint.py docs/adr
Код возврата 1, если найдено хотя бы одно нарушение.
"""
from __future__ import annotations

import pathlib
import re
import sys
from dataclasses import dataclass, field

REQUIRED_SECTIONS = ("Контекст", "Рассмотренные варианты", "Решение",
                     "Последствия", "Критерий пересмотра")
ALLOWED_STATUS = {"Proposed", "Accepted", "Rejected", "Superseded", "Deprecated"}

RE_TITLE = re.compile(r"^#\s*ADR-(\d{4}):\s*(\S.*)$", re.M)
RE_FIELD = re.compile(r"^\*\*(\w[\w ]*)\*\*:\s*(.*)$", re.M)
RE_SECTION = re.compile(r"^##\s+(.+?)\s*$", re.M)
RE_REF = re.compile(r"ADR-(\d{4})")


@dataclass
class Adr:
    number: int
    title: str
    path: pathlib.Path
    status: str = ""
    supersedes: set[int] = field(default_factory=set)
    sections: set[str] = field(default_factory=set)


def parse(path: pathlib.Path) -> Adr | None:
    """Разбирает один файл ADR. None, если это не ADR (нет корректного h1)."""
    text = path.read_text(encoding="utf-8")
    head = RE_TITLE.search(text)
    if head is None:
        return None
    adr = Adr(number=int(head.group(1)), title=head.group(2).strip(), path=path)
    for name, value in RE_FIELD.findall(text):
        key = name.strip().lower()
        if key == "статус":
            adr.status = value.strip()
        elif key == "заменяет":
            adr.supersedes = {int(n) for n in RE_REF.findall(value)}
    adr.sections = {s.strip() for s in RE_SECTION.findall(text)}
    return adr


def load(root: pathlib.Path) -> tuple[dict[int, Adr], list[str]]:
    """Читает каталог, попутно проверяя уникальность номеров и имена файлов."""
    index: dict[int, Adr] = {}
    problems: list[str] = []
    for path in sorted(root.glob("*.md")):
        adr = parse(path)
        if adr is None:
            continue
        if adr.number in index:
            problems.append(f"{path}: номер {adr.number:04d} уже занят "
                            f"({index[adr.number].path.name})")
            continue
        if not path.name.startswith(f"{adr.number:04d}-"):
            problems.append(f"{path}: имя файла не начинается с {adr.number:04d}-")
        index[adr.number] = adr
    return index, problems


def check_structure(adr: Adr) -> list[str]:
    """Обязательные разделы и допустимый статус."""
    out = []
    if adr.status not in ALLOWED_STATUS:
        out.append(f"ADR-{adr.number:04d}: статус '{adr.status}' вне "
                   f"{sorted(ALLOWED_STATUS)}")
    for section in REQUIRED_SECTIONS:
        if not any(section.lower() in s.lower() for s in adr.sections):
            out.append(f"ADR-{adr.number:04d}: нет обязательного раздела '{section}'")
    return out


def check_graph(index: dict[int, Adr]) -> list[str]:
    """Целостность отношения 'заменяет': ссылки, статусы, отсутствие циклов."""
    out: list[str] = []
    for adr in index.values():
        for old in sorted(adr.supersedes):
            target = index.get(old)
            if target is None:
                out.append(f"ADR-{adr.number:04d} заменяет несуществующий ADR-{old:04d}")
                continue
            if target.status != "Superseded":
                out.append(f"ADR-{old:04d} заменён ADR-{adr.number:04d}, "
                           f"но его статус '{target.status}', а должен быть 'Superseded'")
            if old >= adr.number:
                out.append(f"ADR-{adr.number:04d} заменяет более поздний ADR-{old:04d}")

    # Цикл в графе замен: обход в глубину с тремя цветами (0 белый, 1 серый, 2 чёрный).
    color: dict[int, int] = {}

    def dfs(node: int, path: list[int]) -> None:
        color[node] = 1
        for nxt in sorted(index[node].supersedes):
            if nxt not in index:
                continue
            if color.get(nxt, 0) == 1:                    # нашли обратное ребро
                cycle = path[path.index(nxt):] + [node, nxt]
                out.append("цикл замен: " + " -> ".join(f"ADR-{n:04d}" for n in cycle))
            elif color.get(nxt, 0) == 0:
                dfs(nxt, path + [node])
        color[node] = 2

    for number in sorted(index):
        if color.get(number, 0) == 0:
            dfs(number, [])
    return out


def main(root: pathlib.Path) -> int:
    index, problems = load(root)
    for adr in index.values():
        problems += check_structure(adr)
    problems += check_graph(index)

    accepted = sum(1 for a in index.values() if a.status == "Accepted")
    print(f"Проверено ADR: {len(index)} (действующих: {accepted})")
    for p in problems:
        print(f"ADR LINT: {p}")
    return 1 if problems else 0


if __name__ == "__main__":
    sys.exit(main(pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else "docs/adr")))

Сложность. Пусть N — число ADR, L — средний размер файла в символах, E — число рёбер «заменяет». Чтение и разбор — O(N · L) по времени (регулярные выражения линейны по длине текста при этих шаблонах) и O(L_max) по памяти на файл. Проверка графа — O(N + E) по времени, O(N) по памяти. На журнале из 500 ADR это доли секунды, значит проверку ставят в pre-commit, а не только в CI.

Ограничение, о котором стоит помнить: рекурсивный dfs упрётся в лимит стека Python (по умолчанию 1000) при цепочке замен длиной в тысячу — для журнала решений это нереалистично, но для графов общего вида такой обход надо писать итеративно (пример итеративного алгоритма — в разделе 7).


4. Trade-offs: как сравнивать несравнимое

Спор «Kafka или RabbitMQ», «монолит или микросервисы», «SQL или документная БД» бесплоден, пока не назван критерий. Критерий даёт теория атрибутов качества (Bass, Clements, Kazman, Software Architecture in Practice).

4.1. Сценарий атрибута качества

«Система должна быть быстрой» — не требование: непроверяемо. Требованием оно становится в виде сценария из шести частей:

Часть Смысл Пример
Источник (source) кто/что создаёт воздействие 3000 одновременных покупателей
Воздействие (stimulus) что происходит запросы на оформление заказа
Артефакт (artifact) что затронуто сервис checkout и его БД
Окружение (environment) при каких условиях пик «чёрной пятницы», одна AZ выключена
Отклик (response) что делает система принимает заказ и подтверждает его
Мера отклика (measure) измеримый порог p99 ≤ 800 мс, доля 5xx ≤ 0.1 %

Мера отклика — самое главное. Именно она превращает архитектурную дискуссию в проверяемое утверждение, а позже становится алертом в мониторинге и нагрузочным тестом в CI.

4.2. Дерево полезности

Сценариев набирается тридцать-сорок, анализировать все невозможно. Поэтому строят дерево полезности (utility tree): корень «полезность» → атрибуты качества → уточнения → конкретные сценарии, каждый помечен парой оценок: важность для бизнеса и сложность реализации (В/С/Н). Анализируются только сценарии с высокой важностью.

Дерево полезности: атрибуты качества, уточнения и сценарии с приоритетами

Дерево полезности даёт два побочных эффекта, ради которых его стоит строить, даже если формальной оценки не будет. Во-первых, оно почти всегда обнаруживает конфликт между стейкхолдерами: бизнес считает критичным сценарий, который инженеры не видели вовсе (или наоборот). Во-вторых, оно показывает атрибуты, о которых никто не подумал — обычно это модифицируемость и наблюдаемость: их не заказывает никто, а стоят они дороже всех.

4.3. Точки чувствительности и точки компромисса

Два понятия, которые ATAM ввёл в оборот и которые полезны сами по себе:

  • Точка чувствительности (sensitivity point) — свойство архитектуры, от которого сильно зависит один атрибут качества. Пример: количество реплик БД сильно влияет на доступность. Это места, где нужно мерить и ставить алерты.
  • Точка компромисса (tradeoff point) — свойство, которое одновременно является точкой чувствительности для нескольких атрибутов, причём в разные стороны. Пример: тот же кэш улучшает латентность и ухудшает согласованность.

Точки компромисса — это ровно то, ради чего вообще пишутся ADR. Улучшение без цены не требует решения; решение нужно там, где за одно платят другим.

Матрица влияния архитектурных решений на атрибуты качества

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

Типичная ошибка здесь — сравнивать варианты «в целом». Правильная формулировка вывода никогда не звучит как «Kafka лучше RabbitMQ». Она звучит как: «при весе сценария “переигрывание истории событий за 30 дней” равном 0.3 Kafka выигрывает; если этот сценарий не нужен, RabbitMQ дешевле в эксплуатации на два человеко-дня в месяц».


5. ATAM: метод оценки архитектуры

ATAM (Architecture Tradeoff Analysis Method) разработан в SEI (Carnegie Mellon) в конце 1990-х. Это структурированный семинар, на котором архитектура проверяется против сценариев качества силами архитекторов, стейкхолдеров и внешней группы оценки.

Важно правильно понимать его цель. ATAM не выдаёт вердикт «архитектура хорошая» или «плохая». Его выход — список рисков, сгруппированных в темы, плюс каталог точек чувствительности и компромисса. Это диагностика, а не оценка.

5.1. Девять шагов и две фазы

Шаг 7 — почему фазы две. Первая фаза идёт с архитектором и заказчиком; она выявляет «официальные» сценарии. Вторая собирает широкий круг — и почти всегда именно там всплывают сценарии, которых архитектор не предполагал: «а что происходит при откате релиза, когда схема событий уже изменилась?», «как выключить одного арендатора, если он выжирает пул соединений?». Дельта между шагами 5 и 7 — сама по себе диагноз: если она велика, значит между архитектурой и эксплуатацией нет разговора.

5.2. Что такое «риск» на выходе ATAM

Риск формулируется как связка «решение → следствие → нарушенная бизнес-цель»:

Риск R-4. Ключ шардирования tenant_id при появлении арендатора крупнее 15 % объёма данных приводит к горячему шарду; решардинг не автоматизирован и оценивается в 3 недели с даунтаймом. Нарушает бизнес-цель «подключение enterprise-клиента за квартал». Тема риска: нет стратегии эволюции схемы данных. Точка чувствительности: равномерность распределения по ключу шардирования.

И симметрично фиксируются non-risks — решения, которые проверили и признали безопасными при текущих предположениях. Это важно: non-risk с явно записанным предположением («при условии, что число арендаторов растёт не быстрее 20/квартал») через год превращается в риск, когда предположение ломается, — и об этом есть запись.

5.3. Когда ATAM оправдан и чем его заменить

Полный ATAM — это 2–3 дня и 10–15 человек, то есть примерно 30 человеко-дней. Он окупается на системах, где цена архитектурной ошибки исчисляется человеко-годами: платформы, регулируемые домены, длинные контракты. Для продуктовой команды из семи человек это избыточно.

Практичные облегчённые варианты:

  • Мини-ATAM на 3 часа. Шаги 3, 5, 6, 9. Дерево полезности строится за 40 минут, берутся 5 сценариев (В,В), по каждому — 20 минут разбора «как архитектура на него отвечает». Выход: список рисков и 2–3 новых ADR. Проводится раз в квартал.
  • Lightweight Architecture Evaluation — официальный «лёгкий» вариант SEI: внутренняя команда, полдня, без внешних оценщиков.
  • LAAAM (Lightweight Architecture Alternative Assessment Method, из Software Architecture: The Hard Parts) — матрица «сценарии × альтернативы», участники независимо расставляют оценки, затем обсуждаются только те ячейки, где оценки сильно разошлись. Дешёво и очень результативно: разногласие в цифре — это найденное скрытое предположение.
  • Pre-mortem на 45 минут: «прошёл год, архитектура провалилась — напишите, почему». Не заменяет ATAM, но ловит риски, которые не всплывают при позитивном разборе.

6. Количественно: CBAM и возврат на архитектурные вложения

ATAM отвечает на вопрос «где риски», но не отвечает на «что чинить первым при ограниченном бюджете». Для этого в SEI появился CBAM (Cost-Benefit Analysis Method): к каждому сценарию привязывается кривая полезности — функция, переводящая значение отклика (миллисекунды, проценты доступности) в безразмерную полезность 0–100. Дальше архитектурные стратегии сравниваются по ROI = ΔПолезность / Стоимость.

"""CBAM: выбор архитектурных стратегий по возврату на вложения.

Идея: полезность нелинейна. Сокращение p99 с 2000 до 900 мс даёт огромный прирост
полезности, с 200 до 150 мс — почти нулевой. Кривая полезности это выражает явно.
"""
from __future__ import annotations

from bisect import bisect_left
from dataclasses import dataclass


@dataclass(frozen=True)
class UtilityCurve:
    """Кусочно-линейная кривая: значение отклика -> полезность (0..100).

    points отсортированы по возрастанию значения отклика. Полезность может как расти
    (доступность), так и убывать (латентность) — направление задаётся самими точками.
    """
    points: tuple[tuple[float, float], ...]

    def utility(self, response: float) -> float:
        xs = [x for x, _ in self.points]
        i = bisect_left(xs, response)
        if i == 0:
            return self.points[0][1]                      # ниже левой границы — плато
        if i >= len(xs):
            return self.points[-1][1]                     # выше правой границы — плато
        (x0, u0), (x1, u1) = self.points[i - 1], self.points[i]
        t = (response - x0) / (x1 - x0)                   # линейная интерполяция внутри
        return u0 + t * (u1 - u0)


@dataclass(frozen=True)
class Scenario:
    """Сценарий качества с весом (важность для бизнеса, сумма весов = 1)."""
    name: str
    weight: float
    curve: UtilityCurve
    current: float                                        # текущее значение отклика


@dataclass(frozen=True)
class Strategy:
    """Архитектурная стратегия: во что обойдётся и что изменит."""
    name: str
    cost_weeks: float                                     # человеко-недели
    expected: dict[str, float]                            # сценарий -> прогноз отклика


def roi(strategy: Strategy, scenarios: dict[str, Scenario]) -> tuple[float, float]:
    """Возвращает (взвешенный прирост полезности, ROI на человеко-неделю)."""
    gain = 0.0
    for name, sc in scenarios.items():
        after = strategy.expected.get(name, sc.current)   # не затронут — остаётся как есть
        gain += sc.weight * (sc.curve.utility(after) - sc.curve.utility(sc.current))
    return gain, gain / strategy.cost_weeks


SCENARIOS = {
    "checkout_p99": Scenario(
        name="p99 оформления заказа в пик, мс", weight=0.40,
        # чем меньше миллисекунд, тем выше полезность; ниже 300 мс прирост почти исчезает
        curve=UtilityCurve(((150, 100), (300, 95), (800, 70), (1500, 25), (3000, 0))),
        current=1500),
    "az_failover": Scenario(
        name="время восстановления при отказе AZ, с", weight=0.35,
        curve=UtilityCurve(((30, 100), (60, 90), (300, 45), (1800, 5))),
        current=1800),
    "new_payment_method": Scenario(
        name="срок добавления способа оплаты, дни", weight=0.25,
        curve=UtilityCurve(((3, 100), (5, 90), (15, 50), (40, 10))),
        current=40),
}

STRATEGIES = [
    Strategy("Read-through кэш каталога", 3.0, {"checkout_p99": 700}),
    Strategy("Мультизональный кластер БД", 8.0, {"az_failover": 60, "checkout_p99": 1300}),
    Strategy("Выделение модуля платежей", 12.0,
             {"new_payment_method": 5, "checkout_p99": 1400}),
    Strategy("Переписать всё на микросервисы", 60.0,
             {"checkout_p99": 900, "az_failover": 120, "new_payment_method": 8}),
]

if __name__ == "__main__":
    ranked = sorted(((roi(s, SCENARIOS), s) for s in STRATEGIES),
                    key=lambda pair: pair[0][1], reverse=True)
    print(f"{'стратегия':<34}{'Δполезность':>13}{'недели':>9}{'ROI':>9}")
    for (gain, r), s in ranked:
        print(f"{s.name:<34}{gain:>13.1f}{s.cost_weeks:>9.1f}{r:>9.2f}")

# стратегия                           Δполезность   недели      ROI
# Read-through кэш каталога                  20.0      3.0     6.67
# Мультизональный кластер БД                 34.9      8.0     4.36
# Выделение модуля платежей                  22.6     12.0     1.88
# Переписать всё на микросервисы             58.2     60.0     0.97

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

Обратите внимание на результат: «переписать всё на микросервисы» даёт наибольший абсолютный прирост полезности и при этом худший ROI. Это типичная картина и главная причина существования CBAM: без деления на стоимость выигрывает всегда самая масштабная затея.

Честные ограничения метода. Кривые полезности — экспертные оценки, прогноз отклика — тоже. Точность здесь иллюзорна, и выдавать ROI = 5.13 за измерение нельзя. Ценность в другом: метод заставляет назвать веса и прогнозы вслух. Когда два инженера расходятся в выводе, они расходятся в конкретном числе, а число — предмет разговора и последующей проверки замером. Разумная практика — считать диапазон (пессимистичный/ожидаемый/ оптимистичный прогноз отклика) и смотреть, меняется ли порядок стратегий; если не меняется, решение устойчиво к неточности оценок.


7. Эволюционная архитектура: решения, которые можно менять

Все предыдущие разделы работают с решением как с точечным актом. Но главный способ уменьшить цену ошибки — не «решать точнее», а уменьшить стоимость смены решения. Это тезис книги Building Evolutionary Architectures (Ford, Parsons, Kua). Определение авторов:

Эволюционная архитектура поддерживает управляемое инкрементальное изменение по множеству измерений.

Три составляющих: инкрементальное изменение (маленькими шагами, с деплоем каждого), fitness functions (автоматическая проверка, что архитектурные свойства не сломались) и подходящая связанность (модули достаточно независимы, чтобы меняться поодиночке).

7.1. Последний ответственный момент

Практический принцип, вытекающий из этого: откладывайте решение до последнего момента, когда его ещё можно принять без потерь (last responsible moment из lean-подхода). Не «до последнего» — это паралич, а до момента максимума информации.

Пример. Вопрос «одна БД или отдельная БД на сервис» не нужно решать в первый месяц. Нужно сделать так, чтобы решение оставалось дешёвым: не использовать джойны между таблицами разных модулей, доступ к чужим данным — только через интерфейс модуля. Тогда через год, когда станет известна реальная нагрузка и реальные границы, разделение БД будет стоить неделю, а не квартал. Это и есть «покупка опциона»: небольшая постоянная плата за дисциплину сегодня взамен права выбора завтра. Подробно эта техника разобрана в https://courses.digitable.life/post/architecture-patterns/02-monolith-and-modular-monolith/.

7.2. Fitness functions: таксономия

Практический вывод из таксономии: у каждого принятого ADR должна быть хотя бы одна fitness function, иначе решение живёт до первого спешного релиза. В ADR-0021 из раздела 2 такая проверка названа прямо в git-истории: тест «нет ни одного вызова producer.send вне модуля relay».

7.3. Holistic fitness function: отсутствие циклов между модулями

Классическая atomic-функция «домен не импортирует инфраструктуру» разобрана в обзоре трека. Здесь — более интересный, holistic случай: проверка, что граф зависимостей между модулями остаётся ациклическим. Цикл между модулями — это скрытая склейка: два «независимых» модуля больше нельзя ни выкатить, ни выделить, ни понять по отдельности.

"""Fitness function: между модулями верхнего уровня нет циклических зависимостей.

Строим граф на уровне пакетов (app.orders -> app.billing), ищем сильно связные
компоненты алгоритмом Тарьяна. Компонента размера > 1 = цикл = нарушение.
"""
from __future__ import annotations

import ast
import pathlib
import sys
from collections import defaultdict


def top_package(module: str, depth: int = 2) -> str:
    """app.orders.service.pricing -> app.orders (модуль верхнего уровня)."""
    return ".".join(module.split(".")[:depth])


def build_graph(root: pathlib.Path, prefix: str = "app") -> dict[str, set[str]]:
    """Граф зависимостей между пакетами по статическому разбору импортов.

    root — каталог, внутри которого лежит пакет prefix (например src/, где есть src/app).
    """
    graph: dict[str, set[str]] = defaultdict(set)
    for path in root.rglob("*.py"):
        rel = path.relative_to(root).with_suffix("")
        module = ".".join(p for p in rel.parts if p != "__init__")
        src = top_package(module)
        if not src.startswith(prefix):        # чужой код, зависимости вне периметра
            continue
        graph.setdefault(src, set())
        tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
        for node in ast.walk(tree):
            if isinstance(node, ast.Import):
                targets = [a.name for a in node.names]
            elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
                targets = [node.module]
            else:
                continue
            for target in targets:
                dst = top_package(target)
                if dst.startswith(prefix) and dst != src:
                    graph[src].add(dst)
    return graph


def strongly_connected(graph: dict[str, set[str]]) -> list[list[str]]:
    """Итеративный алгоритм Тарьяна: список сильно связных компонент. O(V + E)."""
    index: dict[str, int] = {}
    low: dict[str, int] = {}
    on_stack: dict[str, bool] = {}
    stack: list[str] = []
    components: list[list[str]] = []
    counter = 0

    for root_node in graph:
        if root_node in index:
            continue
        index[root_node] = low[root_node] = counter
        counter += 1
        stack.append(root_node)
        on_stack[root_node] = True
        work = [(root_node, iter(sorted(graph.get(root_node, ()))))]

        while work:
            node, it = work[-1]
            descended = False
            for nxt in it:
                if nxt not in index:                      # белая вершина — уходим вглубь
                    index[nxt] = low[nxt] = counter
                    counter += 1
                    stack.append(nxt)
                    on_stack[nxt] = True
                    work.append((nxt, iter(sorted(graph.get(nxt, ())))))
                    descended = True
                    break
                if on_stack.get(nxt):                     # ребро внутрь текущей компоненты
                    low[node] = min(low[node], index[nxt])
            if descended:
                continue

            work.pop()
            if work:                                      # проталкиваем low вверх по стеку
                parent = work[-1][0]
                low[parent] = min(low[parent], low[node])
            if low[node] == index[node]:                  # node — корень компоненты
                component = []
                while True:
                    w = stack.pop()
                    on_stack[w] = False
                    component.append(w)
                    if w == node:
                        break
                components.append(component)
    return components


def main(src: pathlib.Path) -> int:
    graph = build_graph(src)
    cycles = [sorted(c) for c in strongly_connected(graph) if len(c) > 1]
    for cycle in cycles:
        print("ARCH VIOLATION: цикл между модулями: " + " <-> ".join(cycle))
    print(f"Модулей: {len(graph)}, рёбер: {sum(len(v) for v in graph.values())}, "
          f"циклов: {len(cycles)}")
    return 1 if cycles else 0


if __name__ == "__main__":
    sys.exit(main(pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else "src")))

Сложность. Построение графа — O(F · T), где F — число файлов, T — средний размер AST; память — O(T_max + V + E), дерево держим по одному файлу. Тарьян — O(V + E) по времени и O(V) по памяти. Здесь V — число модулей верхнего уровня (десятки), E — рёбра между ними. На репозитории в 5000 файлов всё вместе — единицы секунд.

Почему именно SCC, а не «поиск любого цикла»: сильно связная компонента показывает весь клубок взаимно зависимых модулей сразу, а не одно произвольное кольцо из него. Разрывать надо клубок.

Подключение в CI:

# .github/workflows/architecture.yml — архитектурные инварианты как обычные проверки
name: architecture
on: [pull_request]
jobs:
  fitness:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - name: Журнал решений корректен
        run: python tools/adr_lint.py docs/adr
      - name: Нет циклов между модулями
        run: python tools/no_cycles.py src
      - name: Слои не нарушены
        run: python -m importlinter.cli lint          # import-linter, контракты в setup.cfg
      - name: Публикация в Kafka только из relay (ADR-0021)
        run: |
          ! grep -rn "producer\.send" src --include="*.py" \
            | grep -v "^src/payments/relay/"

Промышленные аналоги проверок: ArchUnit (Java), NetArchTest (.NET), import-linter (Python), dependency-cruiser (TypeScript), go-arch-lint (Go).


8. Кто принимает решения: процесс, а не только формат

Формат ADR ничего не говорит о том, кто решает. Здесь у индустрии есть несколько устоявшихся моделей.

Архитектурный комитет. Решения утверждает выделенная группа. Плюс — согласованность; минус — комитет становится узким местом и отрывается от практики («ivory tower»). Работает только при небольшом потоке действительно крупных решений.

RFC-процесс. Автор пишет документ, открывает его на комментирование фиксированный срок (обычно 1–2 недели), затем решение принимает названный ответственный. Так устроены Rust RFC, Python PEP, Kubernetes KEP. Годится для решений с широким охватом и длинным горизонтом.

Advice process. Решение принимает тот, кто будет его исполнять, но обязан запросить мнение двух групп: тех, кого решение затронет, и тех, у кого есть экспертиза. Мнение запрашивать обязательно, следовать ему — нет; сам факт запроса и полученные возражения фиксируются в ADR. Модель подробно разобрана в книге Эндрю Хармел-Ло Facilitating Software Architecture. Это лучший из известных компромиссов между скоростью и качеством для организаций с автономными командами.

DACI / RAPID. Явное распределение ролей: кто Driver, кто Approver, кто Contributor, кто Informed. Полезно как дополнение к любому из вариантов выше — снимает главный источник затягивания, спор о том, чьё слово последнее.

Правило, объединяющее все модели: скорость процесса должна соответствовать обратимости решения. Двусторонние двери проходят без ритуала. Односторонние — с ADR и запросом мнений. Ошибка в обе стороны одинаково дорога: недельное согласование обратимого решения убивает темп, а необратимое решение «на созвоне» стоит годы.

Наконец, полезный инструмент управления ландшафтом — технологический радар в духе ThoughtWorks Technology Radar: список технологий, разложенных по кольцам Adopt / Trial / Assess / Hold. Радар не заменяет ADR, но снимает большой класс повторяющихся решений: «Postgres — Adopt, новую NoSQL — только через ADR с обоснованием».


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

ADR как отчёт о проделанной работе. Пишется после реализации, содержит только выбранный вариант, раздел «Альтернативы» пуст. Такой документ бесполезен: он дублирует код. Признак здорового процесса — существование ADR со статусом Rejected.

Только плюсы в разделе «Последствия». Если минусов нет, разбора не было. Требуйте минимум двух отрицательных последствий в каждом ADR — это простое правило радикально повышает качество обсуждения.

Отсутствие критерия пересмотра. Решение принимается «навсегда», хотя контекст, в котором оно верно, живёт полтора года. Без наблюдаемого триггера никто и никогда к нему не вернётся.

Редактирование ADR задним числом. Уничтожает главную ценность — историю. Новое решение — новое ADR со ссылкой Заменяет.

Сравнение вариантов без весов. Таблица «плюсы/минусы» без указания, какой атрибут качества сколько стоит для бизнеса, всегда сводится к тому, что побеждает вариант с более красноречивым сторонником.

Оценка архитектуры без стейкхолдеров. ATAM, проведённый одними инженерами, найдёт технические риски и пропустит бизнесовые — а именно они обычно и убивают проект.

Fitness functions без ADR и ADR без fitness functions. Первое — набор произвольных запретов, происхождение которых через год никто не объяснит («тест падает, давайте его удалим»). Второе — благие намерения, которые сотрутся первым дедлайном.

Ритуал вместо решения. Двадцатистраничные RFC на выбор библиотеки логирования. Помните про обратимость: цена процесса не должна превышать цену ошибки.

Паралич анализа. Бесконечное сравнение вместо эксперимента. Часто дешевле построить spike за три дня и получить замер, чем неделю спорить о прогнозах. Хорошее ADR может честно сказать: «решение принято на две недели, пересматриваем после нагрузочного теста».


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

Журнал в репозитории и индекс. docs/adr/ с 40–120 файлами, README с оглавлением, статусы проставлены. Новичок за первый день читает 10 действующих ADR и понимает систему лучше, чем по любой диаграмме.

Квартальное архитектурное ревью на 90 минут. Проходятся по ADR со статусом Accepted, у которых сработал или почти сработал критерий пересмотра. Выход — 1–3 новых ADR или подтверждение старых. Это ровно та петля обратной связи, ради которой писался раздел «Критерий пересмотра».

ADR-ссылки в коде и в мониторинге. Комментарий # ADR-0021 рядом с нетривиальным местом и ссылка на ADR в описании алерта: дежурный ночью видит не только «лаг outbox вырос», но и почему outbox вообще существует и что считается нормой.

Fitness functions в обязательных проверках PR. Не «советующий» линтер, а блокирующая проверка — иначе она деградирует за месяц.

Инцидент как источник ADR. В постмортеме почти всегда обнаруживается неявное архитектурное решение, принятое когда-то молча. Правильная работа с постмортемом — превратить его в ADR (или в новую fitness function), а не только в тикет.

Радар и golden path. Крупные компании фиксируют «дорожку по умолчанию»: этот стек, этот шаблон сервиса, эти библиотеки. Сойти с неё можно — через ADR. Это резко снижает число решений, которые вообще нужно принимать.


Мини-итог

  • Архитектура — это множество решений, а не диаграмма. Диаграмма показывает результат, ADR показывает причины и цену.
  • Значимость решения = обратимость × охват × влияние на атрибуты качества. Скорость процесса должна соответствовать обратимости: двусторонние двери — быстро, односторонние — с записью и разбором альтернатив.
  • ADR обязан содержать контекст в цифрах, отвергнутые варианты с причиной отказа, отрицательные последствия и критерий пересмотра. Без последнего это архив, а не инструмент.
  • ADR не редактируют задним числом: неверное решение закрывается новым ADR со ссылкой «Заменяет». Целостность журнала проверяется линтером в CI.
  • Компромиссы обсуждаются через сценарии атрибутов качества с измеримой мерой отклика, приоритизированные деревом полезности. Вывод формулируется как «X лучше Y при таком-то весе такого-то сценария», а не «X лучше Y».
  • ATAM даёт не оценку, а список рисков, точек чувствительности и точек компромисса; полный метод дорог, мини-ATAM и LAAAM дают 80 % пользы за 3 часа.
  • CBAM добавляет стоимость: сравнивайте стратегии по ΔПолезность / Стоимость, иначе всегда выигрывает самая масштабная затея с худшим ROI.
  • Эволюционная архитектура снижает не вероятность ошибки, а её цену: инкрементальное изменение + fitness functions + подходящая связанность. У каждого принятого ADR должна быть хотя бы одна автоматическая проверка.

Источники


Что дальше

Теперь у вас есть и словарь стилей (статьи 01–10), и дисциплина выбора между ними: как распознать значимое решение, как записать его в ADR, как сравнить альтернативы по сценариям качества и как защитить принятое автотестами. Осталось собрать всё это в одном сквозном упражнении: взять требование, оценить нагрузку, посчитать хранение и трафик, выбрать хранилища, спроектировать API, продумать отказы и узкие места — то есть пройти полный цикл проектирования системы под нагрузку, как на настоящем архитектурном интервью и в настоящем проекте.

Читайте дальше: «System design: как проектировать систему под нагрузку от и до».

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

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

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

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