Как строить архитектуру backend-приложения: от сценария к границам модулей

На примере оформления заказа разберём, как распределять ответственность между частями backend-приложения и сохранять согласованность данных. Материал для junior–middle: от требований к модулям и транзакциям, затем к обработке сбоев и условиям усложнения архитектуры.

1. Начните со сценария и правил, которые нельзя нарушить

Представим учебный интернет-магазин. Backend принимает заказ, проверяет товар, уменьшает доступный остаток и возвращает номер заказа. Сначала всё помещается в одном обработчике HTTP. Затем появляются повторные запросы, параллельные покупки и уведомления. Обработчик растёт, а перенос его содержимого в OrderService оставляет прежние проблемы под новым именем.

Архитектурное решение начинается с вопросов: кто владеет данными, какие изменения должны произойти вместе и что увидит клиент при частичном отказе. Расположение файлов следует из этих ответов.

Для примера примем ограничения: одна команда, один регион, реляционная БД, заказ содержит одну товарную позицию. Оплату пока исключим. Оформление означает создание заказа и резервирование товара: доступный остаток уменьшается, физическая отгрузка происходит позже.

Зафиксируем три правила — инварианта, которые должны сохраняться после завершения операции:

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

Первые два правила определяют работу с БД. Третье требует отдельного механизма распознавания повторов. Уже здесь видно, почему одной структуры controllers/services/repositories недостаточно: она ничего не говорит о поведении системы.

2. Выделите модули по ответственности за данные

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

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

У каждого модуля должен быть публичный способ обращения. Каталог может предоставить getProduct, оформление — placeOrder. Соседние модули не должны произвольно изменять чужие таблицы или импортировать внутренние файлы. Иначе изменение структуры хранения незаметно становится изменением всего приложения.

В коде это можно выразить каталогами catalog/, ordering/, notifications/ с явными экспортами. Ограничения импортов закрепляют средствами языка, сборки или линтера. Одна договорённость в README постепенно теряет силу, если инструменты разрешают обходить её без предупреждения.

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

3. Разделите HTTP, сценарий и инфраструктуру

Внутри модуля оформления нужны разные виды работы. Их можно разделить функциями; создавать отдельный класс для каждого действия необязательно.

ЧастьОтветственность в примереЧто получает на вход
HTTP-обработчикПроверяет форму запроса, вызывает сценарий, формирует ответHTTP-запрос и контекст аутентификации
Прикладной сценарийПроверяет право на операцию, координирует создание заказаПользователь и команда оформления
Предметные правилаОпределяют допустимые количества и переходы состоянияЗначения и состояние заказа
Адаптер храненияВыполняет SQL, обеспечивает согласованное изменение записейПараметры операции и контекст транзакции

Идентификатор пользователя берётся из проверенного контекста аутентификации. Если клиент передаёт customerId, сервер не должен автоматически считать его владельцем операции. Проверка структуры JSON и проверка права оформить заказ от имени покупателя решают разные задачи.

Сценарий удобно выражать как placeOrder(actor, command). Ему не нужны объекты request и response: тогда тот же вход можно использовать из HTTP, фонового задания или административной команды. Правила доступа при этом должны сохраняться для каждого способа вызова.

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

Так работает инверсия зависимостей: код сценария опирается на контракт, инфраструктура его реализует, а точка запуска приложения связывает части. Направление зависимостей исходного кода отличается от порядка вызовов во время работы.

flowchart TD
    HTTP["HTTP-обработчик"] --> UC["Сценарий оформления"]
    UC --> RULES["Правила заказа"]
    UC --> PORT["Контракт хранения"]
    PG["Адаптер PostgreSQL"] -->|"реализует"| PORT
    START["Точка запуска"] --> HTTP
    START --> PG

Стрелки показывают зависимости кода. При запуске сценарий получает реализацию контракта хранения.

Цена такого разделения — дополнительные переходы по коду и необходимость поддерживать контракт. Для небольшого CRUD без сложных правил допустимо вызывать ORM из прикладного сервиса. Универсальный репозиторий, дословно повторяющий методы ORM, сам по себе полезной границы не создаёт.

4. Спроектируйте атомарную операцию до написания контроллера

Наивная реализация читает остаток, проверяет его в приложении, затем записывает новое значение. Два запроса могут прочитать последнюю единицу одновременно и оба решить, что покупка допустима. Даже оборачивание этих действий в транзакцию не устраняет любую гонку автоматически: важны запросы и уровень изоляции.

Для одной товарной позиции можно совместить проверку и уменьшение остатка в условном UPDATE. На уровне Read Committed PostgreSQL при конфликте обновлений ждёт завершения конкурирующей транзакции, а затем повторно проверяет условие для обновлённой строки. Именно это поведение позволяет корректно обработать конкуренцию за последний товар.

Ниже — самостоятельный пример для PostgreSQL 17. Это код адаптера хранения, а не готовый HTTP-сервис. В нём нет покупателей, цен, отмены заказа и защиты от повторной отправки: здесь проверяются только остаток и атомарность создания заказа.

Сохраните код в architecture-demo.sql. Временные таблицы существуют только в текущем соединении и не изменяют постоянные таблицы приложения.

CREATE TEMP TABLE inventory (
    sku text PRIMARY KEY,
    available integer NOT NULL CHECK (available >= 0)
);

CREATE TEMP TABLE orders (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    sku text NOT NULL REFERENCES inventory(sku),
    quantity integer NOT NULL CHECK (quantity > 0)
);

INSERT INTO inventory (sku, available) VALUES ('book-1', 1);

PREPARE place_order(text, integer) AS
WITH reserved AS (
    UPDATE inventory
    SET available = available - $2
    WHERE sku = $1
      AND $2 > 0
      AND available >= $2
    RETURNING sku
)
INSERT INTO orders (sku, quantity)
SELECT sku, $2 FROM reserved
RETURNING id, sku, quantity;

EXECUTE place_order('book-1', 1);
EXECUTE place_order('book-1', 1);

SELECT available FROM inventory WHERE sku = 'book-1';
SELECT count(*) AS order_count FROM orders;

reserved передаёт в INSERT строки, возвращённые через RETURNING. Если товар не найден или остатка недостаточно, вставлять нечего. Это предусмотренный PostgreSQL способ связывать изменяющие данные операции внутри WITH.

Обе модификации входят в один SQL-оператор и одну транзакцию. Ошибка вставки отменит и изменение остатка. Если оператор выполняется внутри более широкой транзакции, успешный ответ клиенту нужно отправлять после её фиксации.

Для проверки нужен доступный PostgreSQL 17 и клиент psql. Переменная DATABASE_URL должна содержать строку подключения к учебной БД:

psql "$DATABASE_URL" -X -v ON_ERROR_STOP=1 -f architecture-demo.sql

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

Этот запуск проверяет последовательное поведение. Для проверки гонки нужны обычные таблицы в отдельной тестовой БД и два соединения: временные таблицы между соединениями не разделяются. Оба клиента должны попытаться зарезервировать последнюю единицу; после завершения ожидается ровно один заказ.

Когда сценарий потребует нескольких SQL-запросов, границу общей транзакции задаёт прикладная операция. Репозитории не должны независимо фиксировать её части. Например, в node-postgres все запросы транзакции выполняются через один полученный из пула client, который затем освобождается; отдельные вызовы pool.query для этого не подходят.

5. Учтите повторы и сбои после фиксации

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

Для этого нужен ключ идемпотентности — идентификатор одной логической операции. Клиент сохраняет его для повторных попыток. Сервер связывает ключ с пользователем, типом операции, содержимым команды и результатом. Уникальность этой связки обеспечивает БД, чтобы два параллельных запроса не прошли проверку одновременно.

Запись ключа и создание заказа должны фиксироваться согласованно. При повторе сервер возвращает сохранённый результат; тот же ключ с другим содержимым отклоняет. Нужно также определить срок хранения ключей и поведение запроса, чей первый запуск ещё выполняется. Проверка «есть ли ключ» в памяти одного процесса этих гарантий не даёт.

Следующая граница — уведомления. Если отправлять письмо после COMMIT, процесс может завершиться между фиксацией заказа и отправкой. Если отправлять до фиксации, письмо может уйти о заказе, который затем откатится.

Когда потеря уведомления недопустима, применяется transactional outbox: вместе с заказом в той же транзакции сохраняется запись о событии. Фоновый обработчик читает зафиксированные записи и доставляет их получателю. При сбое доставка повторяется, поэтому получатель должен учитывать возможные дубликаты. Механизм и ограничения описаны в AWS Prescriptive Guidance.

sequenceDiagram
    participant A as Сценарий
    participant D as PostgreSQL
    participant W as Обработчик outbox
    participant N as Получатель
    A->>D: BEGIN; резерв, заказ, событие
    A->>D: COMMIT
    D-->>A: Транзакция зафиксирована
    W->>D: Прочитать ожидающее событие
    W->>N: Доставить с идентификатором события
    N-->>W: Подтверждение
    W->>D: Отметить доставку
    Note over W,N: Сбой до отметки может вызвать повтор

Схема показывает расширение примера: таблица outbox в приведённый SQL не включена.

Две характерные проблемы помогают проверить архитектуру на практике.

Покупатель получил два заказа. Возможная причина — повтор после таймаута без общей записи идемпотентности. Сопоставьте пользователя, содержимое команды и ключи в журнале запросов. Если повторные попытки имеют разные ключи, исправление потребуется и на клиенте.

Заказ есть, уведомления нет. Проверьте, создаётся ли outbox-запись в той же транзакции. Если запись есть, исследуйте число попыток, последнюю ошибку и возраст ожидающего события. Outbox сохраняет намерение доставки, но работоспособность обработчика всё равно нужно наблюдать.

6. Усложняйте архитектуру под конкретное ограничение

Очередь полезна, когда работу допустимо завершить позже или нужно сгладить всплески поступающих задач. Она сокращает работу внутри HTTP-запроса, но сохраняет затраты на само выполнение и добавляет ожидание, повторы и контроль накопившихся заданий. Сначала определите, какую задержку допускает продукт.

Отдельный сервис имеет смысл, когда модулю действительно нужны независимые выпуск, ресурсы или эксплуатационная ответственность. Например, ресурсоёмкое построение документов можно отделить от оформления заказа. Однако граница процесса добавляет сетевые отказы, совместимость контрактов и отдельное развёртывание.

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

Рост числа HTTP-запросов сам по себе ещё не определяет решение. Измерьте время ожидания соединения с БД, длительность запросов и блокировок, загрузку CPU, долю ошибок. Если задержка возникает на одной строке остатка, дополнительные экземпляры API не устранят конкуренцию за неё.

Границы также проверяются изменениями и тестами. Предметные правила удобно проверять отдельно; атомарность, ограничения и конкуренцию — на PostgreSQL. Подмена репозитория объектом в памяти не воспроизводит поведение блокировок. Для нашего сценария особенно ценны проверки последнего товара, отката и повторной команды.

7. Начните с одной законченной операции

Для описанных условий backend стоит строить вокруг модулей с явным владением данными, сценариев без привязки к HTTP и согласованных операций хранения. Модульный монолит позволяет уточнять эти границы без преждевременного распределения данных между сервисами.

Дополнительные интерфейсы полезны, когда отделяют существенную зависимость. Очередь оправдана допустимой отсрочкой работы, отдельный сервис — измеримой потребностью в независимости. Если такой потребности нет, новые компоненты увеличивают число мест, где приходится понимать и восстанавливать состояние операции.

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

Примечание редактора. Механизмы сверены с документацией PostgreSQL 17. SQL-пример не запускался: в среде подготовки отсутствовал сервер PostgreSQL. Приведены ожидаемые результаты и способ проверки.

Последние статьи