Svartsinn1
Programming. Как проективаровать информационные системы?

Самиздат: [Регистрация] [Найти] [Рейтинги] [Обсуждения] [Новинки] [Обзоры] [Помощь|Техвопросы]
Links
Кожевенное мастерство: сумки, ремни своими руками
 Ваша оценка:

Как проектировать информационную систему и не утонуть в слоях

Каждая вторая неудачная система умирает не от плохого кода, а от того, что кода стало слишком много там, где ему не место. Проблема проектирования информационных систем не техническая - она когнитивная. Специалист удерживает в рабочей памяти около четырёх-семи смысловых объектов одновременно, поэтому система из ста классов без структуры превращается в материал, который невозможно понять. Эрик Эванс в 2003 году в книге Domain-Driven Design сказал вроде бы очевидную вещь: сначала разберись, что за бизнес ты автоматизируешь, а потом уже пиши технические слои. Двадцать лет спустя половина команд всё ещё начинает со схемы базы данных.

Проектирование информационной системы - это не рисование квадратиков на диаграмме и не выбор модного фреймворка. Это ограничение сложности: вы раскладываете будущую систему так, чтобы в любой момент времени разработчик держал в голове ровно один уровень и не обязан помнить про остальные. Слои, потоки, домен, границы контекстов - всё это инструменты против перегрузки, а не украшения для презентаций. Каждый паттерн из этой области решает одну и ту же задачу: как десять человек могут менять одну систему, не наступая друг другу на ноги.

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

Домен: сначала узнай, о чём вообще система

Домен - это предметная область, которую вы автоматизируете: банковские переводы, складская логистика, тест-менеджмент, продажа авиабилетов. До первого класса, до первого эндпоинта вы обязаны ответить на вопрос: какие действия существуют в этой области, какие объекты в них участвуют и какие правила нельзя нарушать. В DDD это называется стратегическим проектированием, и оно не требует ни одного символа кода. Только разговоры с экспертами, наблюдение за работой и составление единого языка - ubiquitous language.

Ubiquitous language - это когда программист и бухгалтер используют одно и то же слово для одного и того же понятия. Если бухгалтер говорит "аккредитив", а в коде это `money_transfer_v2`, у вас уже есть дефект проектирования, просто он пока не записан ни в одном баг-трекере. Смешение языков ведёт к тому, что требования от бизнеса транслируются в код с потерями: аналитик говорит про "бронирование слота", разработчик пишет `reserve_time`, тестировщик проверяет "создание записи". Три термина для одной операции - три возможности понять друг друга неправильно.

Домен делится на поддомены. В любом онлайн-магазине есть каталог, корзина, оформление заказа, оплата, доставка, возвраты. Каждый поддомен живёт по своим правилам: у каталога правило "товар нельзя продавать, если он снят с публикации", у оплаты - "сумму нельзя списать дважды". Правила разные, объекты разные, языки почти не пересекаются. Это наблюдение становится фундаментом всей дальнейшей архитектуры: там, где граница между поддоменами чёткая, будет граница между модулями или сервисами. Там, где границы нет, любое разрезание системы будет произвольным и со временем сгниёт.

Практический инструмент первого дня - event storming, сеанс с экспертами, где на доске отмечаются все события предметной области: "заказ оформлен", "платёж прошёл", "товар закончился на складе". После расстановки событий ищите команды, которые их порождают, и объекты, участвующие в процессе. Через два-три таких сеанса граница между поддоменами становится видимой физически: около одних событий стоят одни эксперты, около других - другие. Это и есть ваши будущие ограниченные контексты, самая ценная находка стратегического этапа.

Слои: зачем их три, четыре или пять, если можно в один

Слоистая архитектура отвечает на вопрос: где живут разные по природе куски программы. Классическая версия, восходящая к работам early-2000-х вокруг Эванса и до него - к концепции Ports and Adapters Алистера Кокборна, делит систему на четыре части. Первый слой - презентация: контроллеры HTTP, GraphQL-резолверы, CLI-команды. Его обязанность: принять вход, разобрать его, отдать ответ. Бизнес-логики здесь нет ни строчки. Второй слой - прикладной (application): он координирует сценарии, открывает транзакции, решает, какой доменный объект вызвать и что делать дальше. Третий слой - доменный: правила предметной области в чистом виде, без понятия HTTP и PostgreSQL. Четвёртый слой - инфраструктура: драйверы баз, брокеры сообщений, файловые системы, внешние API.

Стандартная ошибка новичка - рисовать стрелки вниз везде, где захочется. Контроллер вызывает репозиторий, репозиторий вызывает домен, домен пишет в лог через глобальную функцию. Такая "слоистая" архитектура на бумаге, а в коде - одна большая каша, где доменная логика размазана тонким слоем по всему проекту. Правильная схема инверсии зависимостей сформулирована в гексагональной архитектуре Кокборна: домен в центре, к нему подключаются адаптеры через интерфейсы (порты), и направление всех зависимостей - внутрь. Домен ничего не знает о базе, о HTTP и даже о том, существует ли вообще внешний мир. Инфраструктура знает о домене и реализует его интерфейсы.

Зачем так строго? Ответ снова когнитивный, а не эстетический. Когда домен отделён интерфейсом от инфраструктуры, его можно читать как документ по бизнесу: файл `order.py` описывает, что можно сделать с заказом и при каких условиях. Когда домен размазан по контроллерам и SQL-запросам, чтобы понять правило "скидка не применяется к товарам со скидкой", придётся прочитать контроллер, сервис, запрос и три test-фикстуры. Слои экономят не процессорное время - они экономят минуты жизни каждого, кто откроет код после вас.

На практике структура папок луковичного (onion) проекта выглядит так:

src/
  domain/            # сердце: без рамок и ORM
    model/           # entity, value object, aggregate
    events/          # OrderPaid, ReservationExpired ...
    ports/           # OrderRepository, PaymentGateway (интерфейсы)
  application/       # сценарии использования
    commands/        # PlaceOrder, CancelOrder ...
    queries/         # GetOrderDetails (CQRS, чтение)
  infrastructure/    # реализация портов
    persistence/     # SqlOrderRepository
    payments/        # AcmePaymentGateway
    messaging/       # KafkaPublisher
  presentation/      # вход/выход
    http/            # контроллеры, сериализация
    cli/             # консольные команды

Обратите внимание на направление импортных стрелок: `infrastructure` импортирует `domain`, но не наоборот. `presentation` импортирует `application`. Нарушение - когда в файле `domain/model/order.py` появляется `import requests` или `from sqlalchemy import`. Это единственное железное правило слоёв, и его соблюдение отличает систему от свалки, даже когда названия модулей идентичны.

Потоки данных и поток управления: чем они отличаются и почему оба важны

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

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

Зрелый подход к потокам - CQRS с разделением команд и запросов. Команда меняет состояние системы и возвращает минимум ("принято, номер 4127"), запрос читает состояние и ничего не меняет. Разделение кажется педантичным, пока вы не увидите, как в типичном проекте метод `get_orders` заодно "починил" флаги в базе. Чтение и запись ходят по разным путям, поэтому их можно масштабировать по-разному: чтение через реплику с кэшем, запись через основной узел с очередью. В предельной форме CQRS завершается event sourcing, где текущее состояние вообще не хранится, а вычисляется из последовательности событий: `OrderPlaced`, `ItemAdded`, `PaymentReceived`. История событий вместо таблицы текущих состояний - вот предельная форма такого мышления.

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

Тактический DDD, или из чего собирается сам домен

Стратегический DDD рисует границы, тактический задаёт внутренний скелет каждого контекста. Базовые кирпичи описаны еще у Эванса, а доведены до инженерной ясности Воном Верноном в трёхчастной серии Effective Aggregate Design (2011) и книге Implementing Domain-Driven Design (2013). Первый кирпич - сущность (entity): объект с уникальным идентификатором, живущий во времени и меняющий состояние так же, как реальный объект меняет атрибуты. Заказ меняет статус, но остаётся тем же заказом. Второй - объект-значение (value object): не имеет идентичности, определён набором атрибутов и неизменяем. Сумма с валютой, интервал дат, адрес - любые "две тысячи рублей" равны любым другим "двум тысячам рублей".

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

Скелет агрегата заказа на Python без какого-либо фреймворка выглядит так:

class OrderStatus(Enum):
    DRAFT, PAID, SHIPPED, CANCELLED = 1, 2, 3, 4

@dataclass(frozen=True)
class Money:                      # объект-значение
    amount: Decimal
    currency: str
    def add(self, other: "Money") -> "Money":
        assert other.currency == self.currency
        return Money(self.amount + other.amount, self.currency)

class Order:                      # корень агрегата
    def __init__(self, id: OrderId, customer: CustomerId):
        self._id, self._customer = id, customer
        self._items: list[OrderLine] = []
        self._status = OrderStatus.DRAFT

    def add_item(self, line: OrderLine) -> None:
        if self._status is not OrderStatus.DRAFT:
            raise DomainError("заказ уже зафиксирован")
        if line.quantity <= 0:
            raise DomainError("количество должно быть положительным")
        self._items.append(line)

    def pay(self, paid: Money) -> "OrderPaid":
        if self._status is not OrderStatus.DRAFT:
            raise DomainError("оплата невозможна")
        if paid.amount < self.total().amount:
            raise DomainError("оплачено меньше суммы заказа")
        self._status = OrderStatus.PAID
        return OrderPaid(order_id=self._id)

Каждый `raise DomainError` - это бизнес-правило, записанное на языке домена, а не "exception в слое сервиса". Каждое поле с подчёркиванием - инвариант, который невозможно нарушить извне агрегата. Именно так выглядит "толстая" доменная модель в противоположность анемичной, где сущность - просто набор сеттеров, а правила живут размазанными по сервисам. Код выше вы будете читать, дорабатывать и защищать тестами, потому что это и есть автоматизированный закон предметной области - цена таких тестов несоизмеримо ниже цены ошибок в массе взаимодействующих сервисов.

Репозиторий (repository) - интерфейс, через который агрегаты достаются из хранилища и возвращаются туда. Он описывается в доменной терминологии: `save(order)`, `by_id(order_id)`, а вовсе не `SELECT * FROM orders WHERE ...`. Реализация репозитория может жить в PostgreSQL, в MongoDB или в памяти для тестов - домену всё равно. Фабрика (factory) инкапсулирует корректное создание сложных объектов, доменное событие (`OrderPaid` из примера) фиксирует факт, важный другим контекстам. Единственная роль, которой следует избегать, - это доменный сервис в лице класса "сделай всё": если логика не помещается ни в сущность, ни в значение, это симптом, что модель нарисована криво.

Реальная система слой за слоем: интернет-магазин в разрезе

Возьмём типичный магазин средних размеров и пройдём по всем слоям насквозь. Сверху вниз по контейнеру в духе модели C4 Саймона Брауна (четыре уровня зума: контекст, контейнеры, компоненты, код). Контекстный уровень: покупатель через браузер/приложение, магазин, платёжный шлюз, складская система, сервис рассылок. Контейнерный: три приложения (витрина-фронтенд, API-монолит заказа, отдельный сервис расчёта доставки), одна база PostgreSQL, брокер Kafka, поисковый индекс Elasticsearch. Всё, дальше начинается разбиение на компоненты и слои внутри монолита.

Витрина отправляет `POST /api/orders`. Контроллер презентационного слоя достаёт DTO из запроса, валидирует форму токена и вызывает команду `PlaceOrder` прикладного слоя. Контроллеру запрещено знать о домене больше, чем знает командная оболочка пользователя: он принимает вход и передаёт его вниз, ничего не решая. Если вы видите в контроллере строки вида `if order.status == "paid"`, это домен, влезший в презентацию - вернуть на место.

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

class PlaceOrderHandler:
    def __init__(self, orders: OrderRepository,
                 events: EventPublisher, uow: UnitOfWork):
        self._orders, self._events, self._uow = orders, events, uow

    def handle(self, cmd: PlaceOrder) -> OrderId:
        with self._uow:                        # одна транзакция
            order = Order(customer_id=cmd.customer_id)
            for line in cmd.lines:             # вход уже проверен формой
                order.add_item(line.to_domain())
            order.place()                       # бизнес-решение в домене
            if order.has_promo():
                order.apply_promotion(cmd.promo_code)
            self._orders.save(order)
            self._events.publish_all(order.collect_events())
            return order.id

Домен внутри `place()` и `add_item()` исполняет правила, написанные в предыдущем разделе. Инфраструктурный `SqlOrderRepository` транслирует агрегат в таблицы `orders` и `order_lines`, а `KafkaEventPublisher` сериализует `OrderPlaced` в топик. Дальше начинается второй поток: складской и рассылочный контуры читают событие и работают по своему расписанию. Витрина получает ответ за 150 миллисекунд, склад узнаёт о заказе через секунду, письмо уходит через десять. Клиент этого не замечает ровно потому, что расщепление потоков было спроектировано заранее.

Разберём аналогичную схему в другом домене - банковском. Ограниченных контекстов там больше: бухгалтерия (счета и балансы, строгая двойная запись), риски (лимиты и антифрод, потоковое правило на каждый вход), клиенты (профили и документы), уведомления. Попытка сделать "одно банковское приложение" закономерно разваливается: согласованность бухгалтерии требует строгих транзакций, а антифрод работает на потоке событий в реальном времени. Именно поэтому любая банковская система старше пяти лет содержит те же четыре слоя внутри каждого контекста и события между контекстами. Заметьте: эта форма воспроизводится везде, от Wildberries до Netflix, - не потому, что все читали одни книги, а потому что иначе не выдержать скорость изменений, которую требует онлайн-среда.

Дерево решений: как выбрать форму, а не модный фасад

Разговоры об архитектуре часто скатываются в священные войны: монолит против микросервисов, SQL против NoSQL, REST против gRPC. Полезнее держать рядом дерево решений, где каждый вопрос задаётся не о технологии, а о связях и согласованности. Первый вопрос: сколько у вас ограниченных контекстов и как часто они меняются независимо? Если меньше трёх или команда меньше 15-20 инженеров, модульный монолит с чёткими границами модулей почти всегда даст лучшую цену разработки и отладки. Микросервисы оправданы, когда контексты действительно деплоятся и масштабируются по-разному, и у вас есть платформа для этого (CI/CD, мониторинг, администрирование очередей).

1. Сколько команд и контекстов?
   ├─ 1-3 команды → МОДУЛЬНЫЙ МОНОЛИТ (модули = контексты)
   └─ 4+ команд, разные релизные циклы → дальше
       2. Контексты делят одну транзакцию?
          ├─ да → хранить вместе, разделить кодом
          └─ нет → МИКРОСЕРВИСЫ по границам контекстов
2. Связь между операциями внутри контекста?
   ├─ нужна мгновенная согласованность (остаток на счёте) →
   │    одна транзакция, один агрегат, СИНХРОННЫЙ поток
   └─ допустим лаг (письмо, статистика, склад) →
        СОБЫТИЕ, асинхронный поток, eventual consistency
3. Читаете чаще, чем пишете? (обычно 10-100:1)
   ├─ да → CQRS: чтение через реплику/проекцию
   └─ нет → одно хранилище без расслоения чтения
4. Данные структурированы и связаны?
   ├─ да, есть целостность → РСУБД (PostgreSQL)
   ├─ документы, вариативная структура → документо-ориентированное
   └─ поток метрик с высокой частотой записи → специализированное

Каждый лист этого дерева - следствие связности, а не вкуса. Реляционная база выбирается не потому, что "все так делают", а потому что бизнес-транзакции со строгой согласованностью требуют ACID. Документное хранилище берётся там, где модель данных - это вложенные документы с редкими связями. Асинхронные сообщения берутся там, где потребитель переживёт лаг. Ребята, которые в 2026-м строят микросервисы на пять разработчиков "потому что Google так делает", просто повторяют чужое дерево решений без анализа собственных условий. Google строит так по причинам, начинающимся с тысяч команд, - ваши условия другие.

У дерева решений есть вторая польза: оно документирует выбор. Через два года, когда новый архитектор придёт и спросит "почему у нас монолит, а все вокруг на микросервисах", вы развернёте ту же цепочку: команда из девяти человек, три контекста, общий релизный цикл, бюджет. Аргументированный отказ от паттерна охраняет командные знания лучше, чем само применение паттерна, потому что архитектура - это прежде всего управление решёнными вопросами.

Один запрос от начала до конца: полный поток выполнения

Соберём всё в единый след: пользователь нажимает в корзине кнопку "оформить". Браузер отправляет `POST /api/orders` с JSON-телом и JWT-токеном. Веб-сервер передаёт запрос в контроллер, тот проверяет авторизацию, составляет экземпляр команды `PlaceOrder` со всеми данными текущего пользователя. С этого момента домен самодостаточен: команда - это не HTTP, она могла бы прийти из CLI, из планировщика, из другого сервиса. Возможность гонять команды из разных источников - и есть практическая польза чистых слоёв.

Обработчик команды открывает транзакцию и просит репозиторий найти корзину. Репозиторий включает SQL, собирает агрегат заказа из строк таблиц, возвращает объект. Обработчик вызывает `order.place()`: домен проверяет, что корзина непуста, что все товары опубликованы (через доменный сервис каталога), фиксирует статус. Эти проверки - единственное место, где живут бизнес-"если". Ниже в том же вызове: `save`, публикация `OrderPlaced` в outbox-таблицу (транзакционный outbox - паттерн, гарантирующий, что событие уйдёт только вместе с закоммиченным заказом), коммит. Время всего цикла для магазина - 30-200 миллисекунд.

Параллельный, второй поток стартует в момент коммита: отдельный процесс читает outbox и выталкивает события в Kafka. Сервис склада потребляет `OrderPlaced`, резервирует позиции (свой агрегат, своя транзакция), публикует `StockReserved`. Сервис доставки получает `StockReserved` и планирует маршрут. Сервис писем рендерит подтверждение. Каждый из них может лежать, обновляться, отставать - витрина всё равно отвечает за свои 150 миллисекунд. Восстановление любого потребителя происходит через offset в топике: система не рассыпается, потому что взаимодействие сконструировано как очередь, а не как цепочка вызовов.

Проследите этот след в своём проекте мысленно. Если кнопка "оформить" в вашем коде ведёт к трём вложенным `if`, обращению к ORM из контроллера и SQL-запросу из домена, у вас не поток выполнения, а хаотичное переплетение вызовов. Признак здоровой системы - возможность нарисовать её на одном листе так, чтобы стрелки не пересекались, и рассказать по рисунку за пять минут так, чтобы понял не только программист.

Антикоррупционный слой и правило одного направления

Реальные проекты редко живут в чистом поле: рядом почти всегда legacy-система, внешний API партнёра, корпоративная CRM. Интеграция "в лоб" приводит к протечке чужой модели в ваш код: поля `cust_no` и `regn_fl` расползаются по домену, и через год ваш язык домена наполовину состоит из слов чужой системы. Против этого в DDD есть антикоррупционный слой (anti-corruption layer): узкий переводчик между вашей моделью и внешним миром. Он конвертирует DTO партнёра в ваши объекты-значения, а ваши команды - в его запросы. Ценой потери пары миллисекунд вы покупаете суверенитет домена.

Правило "вниз по слоям, вверх через интерфейсы" даёт здесь ощутимый эффект. Внешний шлюз доставки меняет REST на gRPC - в системе с антикоррупционным слоем меняется один адаптер и мапперы в нём. Домен, слои приложения, тесты - нетронуты. Без антикоррупционного слоя это каскад: сотни мест, где встречается поле `tariff_descr`, бесконечные фиксы и регрессии. Стоимость слоя всегда ниже стоимости его отсутствия, потому что внешние системы меняются по чужому расписанию, без вашего разрешения.

Как понять, что система спроектирована хорошо

Критерии качества спроектированной системы таковы. Первый: замена инфраструктуры - базы, брокера, внешнего API - не затрагивает домен. Если для перехода с PostgreSQL на другую СУБД нужно править файлы с бизнес-правилами, границы проведены неверно. Второй: каждое бизнес-правило лежит в одном месте и тестируется без поднятия веб-сервера и базы. Юнит-тест домена - обычная функция Python без фикстур, и таких тестов можно писать сотни на день. Третий: новый разработчик, читая только доменную папку, понимает, что система делает, не заглядывая в настройки движка.

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

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


 Ваша оценка:

Связаться с программистом сайта.

Новые книги авторов СИ, вышедшие из печати:
О.Болдырева "Крадуш. Чужие души" М.Николаев "Вторжение на Землю"

Как попасть в этoт список

Кожевенное мастерство | Сайт "Художники" | Доска об'явлений "Книги"