25. SaaS-бэкенд
Это первый из шести разобранных проектов в части VI. Каждая глава берёт конкретную форму системы и моделирует её с нуля — модули, интерфейсы, процессы, проекции и (где это уместно) типы. Цель не в том, чтобы научить возможностям, описанным в предыдущих главах; цель — показать, как полная модель выглядит на практике и какие решения вы будете принимать, когда будете строить свою.
Система этой главы: небольшой SaaS-бэкенд. Аутентификация, управление заказами, платежи, уведомления. В основном синхронные вызовы с асинхронным событийным позвоночником. Пять сервисов, общая финансовая книга, брокер, четыре процесса, три проекции. Примерно размер продуктового бэкенда стартапа на ранней стадии.
Это также первая модель, где сразу проявляются все паттерны лучших практик из предыдущих глав: базы данных с единственным владельцем, вложенные внутрь своего сервиса, события, принадлежащие своему производителю, транспорт (брокер), привязанный как аспект, никогда не вшитый в путь вызова, и каждый сервис со ссылкой-наружу на свой настоящий репозиторий и контракт. Следите за каждым из них.
Что мы моделируем
Заголовок раздела «Что мы моделируем»Customer ─► Auth.login / getSession (Auth ▸ SessionStore — own cache)Customer ─► Orders.createOrder / getOrder / cancelOrder (Orders ▸ OrdersDB)Orders ─► Inventory.reserveStock / releaseStock (Inventory ▸ InventoryDB)Orders ─► Payments.authorize / capture / refund (Payments ▸ PaymentsDB)Payments ─► Ledger.record (Ledger — Finance-owned, shared sibling)Notifications ─► Orders.orderEvents (consumes the producer's event)Notifications ─► Auth.sessionEvents
Event interfaces carry aspect broker: EventBus EventBus (kafka_cluster) lives on the infra plane — reached by aspect, not by an edge.Два домена: коммерция (Orders, Inventory, Payments) и платформа (Auth, Notifications), при этом Finance владеет общей Ledger. Один асинхронный позвоночник — события заказов и сессий принадлежат своим производителям и потребляются Notifications. Брокер, который их переносит, — настоящий модуль, но он сидит на отдельной плоскости и достижим через аспект broker, а не через маршрутизацию вызовов сквозь него.
Раскладка пакета
Заголовок раздела «Раскладка пакета»shop/├── package.archspace├── platform.arch # Auth, Notifications├── commerce.arch # Orders, Inventory, Payments, Ledger├── infra.arch # EventBus (shared broker, infra plane)├── processes.arch # the four end-to-end flows└── views.arch # the three saved viewsМанифест:
package: acme.shopversion: "0.1.0"
// Pick the working set explicitly — never `use *` the stdlib; it drags in// the whole surface and couples you to types you never instantiate.use service, database, cache, message_broker, kafka_cluster, rest_create, rest_read, grpc_unary, kafka, db_read, db_write from arch.backenduse user from arch.extrasКорневой манифест объявляет package: (непрозрачную единицу разрешения). Список use — это явная, обнаружимая палитра для всего пакета: любой, кто открывает манифест, видит точно, какой словарь в игре. Разбиение модулей по доменам держит каждый файл меньше 100 строк.
platform.arch:
service #h2k4 Auth { aspect team: "Platform" repo.url: "https://github.com/acme/auth" spec.url: "https://specs.acme.internal/openapi/auth.yaml" "Authentication and session management."
aspect { domain: "Platform" security.zone: "Internal" }
rest_create login { "Verify credentials, open a session." } rest_read getSession { "Resolve a session token." }
kafka sessionEvents { "Session created, refreshed, or revoked." aspect { broker: EventBus topic: "platform.sessions.v1" } }
// Single-owner session cache, encapsulated inside Auth's domain. // Omitted from process steps — Auth is the actor, the store is the // obvious detail it carries. cache #r9p2 SessionStore { "Hot session tokens, 24h TTL." aspect { engine: "redis" data.classification: "internal" } db_read get db_write set }}
service #m3x9 Notifications { aspect team: "Platform" repo.url: "https://github.com/acme/notifications" "Transactional email, SMS, and push. A pure consumer — it reacts to events, it exposes no inbound API of its own."
aspect { domain: "Platform" security.zone: "Internal" }
surface Channels { "One interface per delivery transport." rest_create sendEmail rest_create sendSMS rest_create sendPush }}commerce.arch:
service #c4m9 Orders { aspect team: "Commerce" repo.url: "https://github.com/acme/orders" spec.url: "https://specs.acme.internal/openapi/orders.yaml" "Order lifecycle — placement, cancellation, history."
aspect { domain: "Commerce" security.zone: "Internal" }
rest_create createOrder { "Place a new order. Returns an order ID." } rest_create cancelOrder { "Cancel an order before fulfillment." } rest_read getOrder { "Look up an order by ID." }
kafka orderEvents { "Lifecycle events: placed, paid, cancelled, fulfilled." aspect { broker: EventBus topic: "commerce.orders.v1" } }
// Orders' own store — nested, omitted from processes. The data shape // lives in the catalog, not here; ArchLang is not a schema tool. database #o7q3 OrdersDB { "Orders + line items, partitioned by tenant." aspect { engine: "postgres" data.classification: "internal" } catalog.url: "https://datahub.acme.internal/dataset/orders" db_read read db_write write }}
service #v1n8 Inventory { aspect team: "Commerce" repo.url: "https://github.com/acme/inventory" "Stock counts and reservation state."
aspect { domain: "Commerce" security.zone: "Internal" }
grpc_unary reserveStock { "Reserve units against an order." } grpc_unary releaseStock { "Release a prior reservation." } grpc_unary checkStock { "Current available stock for a SKU." }
database #k5w2 InventoryDB { "Per-SKU counts and holds." aspect { engine: "postgres" data.classification: "internal" } catalog.url: "https://datahub.acme.internal/dataset/inventory" db_read read db_write write }}
service #p8z6 Payments { aspect team: "Payments" repo.url: "https://github.com/acme/payments" "Card payment processing — authorize, capture, refund."
aspect { domain: "Payments" security.zone: "PCI" }
grpc_unary authorize { "Authorize a payment hold." } grpc_unary capture { "Capture an authorized payment." } grpc_unary refund { "Issue a refund." }
// Token vault — single-owner, PCI-scoped, encapsulated. database #t3b1 PaymentsDB { "Card tokens + authorization records." aspect { engine: "postgres" data.classification: "pci" } catalog.url: "https://datahub.acme.internal/dataset/payments" db_read read db_write write }}
// Finance owns the system of record; Payments only writes to it. Because// it's a separate domain owned by a different team — not Payments' private// store — it stays a sibling, not a nested detail.database #l9d4 Ledger { aspect team: "Finance" "Immutable financial record of every transaction. Append-only."
aspect { domain: "Finance" security.zone: "Internal" } catalog.url: "https://datahub.acme.internal/dataset/ledger"
db_write record { "Append a financial event." }}infra.arch:
// The deployed broker. It is a real module, but it lives on the infra// plane: services reach it through the `broker` aspect on their event// interfaces, never by routing a call through it. Toggle its aspect// layout (the EventBackbone view) to see which interfaces ride it.kafka_cluster #e6h0 EventBus { aspect team: "Platform" aspect engine: "kafka" "Shared event backbone for order and session events." console.url: "https://kafka.acme.internal/clusters/events"}Пять сервисов, одна общая база, один брокер. Обратите внимание на распределение команд — Platform, Commerce, Payments, Finance — и на то, что владение каскадирует в каждую вложенную базу, если не сказано иное. Обратите внимание на аспекты: domain и security.zone каскадируют на каждый интерфейс и вложенный модуль внутри; мы обопрёмся на это в проекциях.
Инкапсуляция, а не рёбра.
OrdersDB,InventoryDB,PaymentsDBиSessionStoreиспользуются ровно одним сервисом каждая, поэтому они вложены внутрь него — технический детальный элемент домена этого сервиса, а не соседний бокс, подключённый стрелкой.Ledger— контраст: доменный актив, которым владеет Finance, а Payments лишь пишет в него, поэтому он сидит в корне как равноправный сосед. Решающий вопрос — владение и совместное использование, а никогда не «это база данных?».
Ошибка, которой стоит избегать. Не моделируйте «аутентификацию», добавляя поле
requiresAuth: trueк каждой команде. Аутентификация — это забота места вызова (актора или вызывающего в процессе), а не получателя. Модель фиксирует кто кому звонит через процессы; несёт ли этот вызов токен сессии — это реализация, а не архитектура.
Процессы
Заголовок раздела «Процессы»processes.arch:
// The human actor that drives the flows — a stdlib `user`, never a// callee (it exposes no interface to the model).user #u0c0 Customer
process #s5a7 SessionStart { Customer > Auth.login}
process #q2c8 Checkout { "Central business flow. Steps into a service's own nested store are omitted — the service is the actor, the encapsulated DB is implied."
Customer > Auth.getSession Customer > Orders.createOrder
Orders > Inventory.reserveStock Orders > Payments.authorize
Orders select one "payment outcome" { authorized { Orders > Payments.capture Payments > Ledger.record Orders "publishes OrderPaid" } declined { Orders > Inventory.releaseStock Orders "publishes OrderFailed" } }}
process #w8f3 Cancellation { Customer > Auth.getSession Customer > Orders.cancelOrder
Orders > Inventory.releaseStock Orders > Payments.refund Payments > Ledger.record Orders "publishes OrderCancelled"}
process #n4g1 Notify { "Producer-owned events. Notifications consumes each producer's event interface directly — no `subscribes:` wiring, no broker in the path."
Notifications > Orders.orderEvents Notifications > Auth.sessionEvents}Checkout — центральный поток. Orders select one "payment outcome" фиксирует ветвление при авторизации — select — это многопутевой выбор, а one запускает первый подходящий вариант, так что срабатывает ровно один из authorized / declined. Каждая case-метка именует ветку, а префикс Orders — это владелец (сервис, который решает исход). Голые строки (Orders "publishes OrderPaid") — это шаги-заметки: привязанная к актору аннотация, которая рендерится как заметка, а не ребро. (Синтаксиса step : "label" не существует — аннотируйте шагом-заметкой или аргументом.)
Заметьте, чего в процессах нет: ни один шаг не обращается к OrdersDB, PaymentsDB или SessionStore. Это вложенные базы с единственным владельцем — инкапсулированный детальный элемент, который несёт актор, опущенный намеренно. Ledger, общий равноправный сосед, появляется — потому что пропуск высокоуровневого соседа скрыл бы реальную зависимость.
Разветвление уведомлений — это процесс, а не латентная разводка. Каждый событийный интерфейс живёт на своём производителе (Orders.orderEvents, Auth.sessionEvents); Notifications связывается с этим контрактом, проводя к нему ребро. Добавьте завтра второго потребителя — и файл производителя останется нетронутым, ровно так, как и ведёт себя pub/sub. Стрелка рендерится производитель→потребитель (данные текут в обратную сторону от ребра зависимости); это забота рендера, а не моделирования.
Проекции
Заголовок раздела «Проекции»views.arch:
view #vj01 CustomerJourney { "End-to-end customer journey. Used in onboarding and architecture reviews." show @@domain:"Commerce" or @@domain:"Platform" group by @@team}
view #vp02 PCIScope { "Every element in PCI scope — Payments and its nested token vault. For compliance review." show @@security.zone:"PCI" group by @@team}
view #ve03 EventBackbone { "The async spine: every event interface that rides the shared broker. The infra overlay." show @@broker:EventBus}Три проекции. CustomerJourney — это весь поток, сгруппированный по командам, — то, что вы показали бы при введении в проект. PCIScope изолирует подмножество PCI для compliance; с аспектом security.zone: "PCI" на Payments каскад протаскивает её до PaymentsDB, и проекция буквально равна show @@security.zone:"PCI". EventBackbone — это вознаграждение за хранение транспорта на аспекте: show @@broker:EventBus раскрывает плоскость брокера — какие интерфейсы по нему едут — без того, чтобы эта плоскость когда-либо засоряла дефолтный граф вызовов.
Решения, с которыми вы столкнётесь, моделируя своё
Заголовок раздела «Решения, с которыми вы столкнётесь, моделируя своё»База с единственным владельцем vs. общая база. Вопрос — во владении, не в типе. База, которую использует ровно один сервис, — это детальный элемент этого сервиса, вложите её (OrdersDB). Хранилище, которым владеет вторая команда или в которое пишет второй сервис, — равноправная забота, корневой сосед (Ledger). Никогда не рисуйте ряд сервисов, каждый из которых расходится к своему цилиндру.
Где живёт брокер. На аспекте, не в пути вызова. A → broker → B хоронит реальную зависимость (кто от кого зависит) и направляет каждый сервис на один и тот же узел. Моделируйте логическое ребро; привяжите брокер через aspect broker: EventBus. Брокер по-прежнему настоящий модуль на инфра-плоскости — раскройте его проекцией EventBackbone, когда захотите.
Sync vs async. rest_* и grpc_unary для синхронного запрос/ответ; kafka для событий. Событийный интерфейс живёт на производителе; потребители указывают на него в процессе. Не смешивайте формы вызова в одном интерфейсе — тип передаёт форму.
Не воспроизводите схему. Набросайте хранилище неформально и дайте ссылку-наружу. Каждая база здесь несёт catalog.url к настоящему каталогу данных (DataHub, Amundsen или схема-инструмент вроде Atlas/Liquibase), а каждый сервис — spec.url к своему OpenAPI-контракту. ArchLang — это хаб ссылок, а не замена DDL или OpenAPI.
Именование интерфейсов. REST → <action><Resource> (createOrder, getOrder); RPC → глаголы (authorize, reserveStock). Сгруппируйте связный набор в поверхность (Notifications.Channels); оставьте отдельный интерфейс на модуле.
Внешние акторы. Customer — это user из стандартной библиотеки. Для B2B-интеграций или сторонних вызывающих объявляйте их как external_system или user и ставьте слева от шагов процесса. Они никогда не появляются справа — они не выставляют интерфейсы вашей модели.
Что это вам даёт
Заголовок раздела «Что это вам даёт»После валидации:
- Диаграмма с пятью сервисными узлами, общей книгой, брокером, пользователем и рёбрами, выведенными из четырёх процессов, — сгруппированная по командам.
- Проекция области PCI и оверлей событийного позвоночника, обе автоматические из аспектов.
- Валидация того, что каждый шаг процесса ссылается на реальный интерфейс.
- Движок диффов, распознающий переименования, если вы позже переименуете
OrdersвOrderService, — его стабильный ID (#c4m9) несёт идентичность. - Описания с markdown во всплывающих подсказках, с кросс-ссылками
[[Ledger]]и интерполяцией@security.zone, если вы её добавите.
Итого исходник: примерно 160 строк .arch в пяти файлах. Это вся форма системы, в тексте, в системе контроля версий.
- Моделируйте свой домен, сначала записывая модули, затем рисуя процессы между ними. Стрелки на диаграмме появятся следом.
- Вкладывайте базы с единственным владельцем внутрь их сервиса; держите общие / разделяемые между владельцами базы как соседей.
- События принадлежат своему производителю; потребители проводят к ним ребро в процессах. Транспорт (брокер) — это аспект
broker(aspect broker: EventBus), никогда не узел в пути вызова. - Связывайте каждый сервис с его репозиторием и OpenAPI-спецификацией, каждое хранилище — с его каталогом данных: модель — это хаб ссылок, а не схема.
- Группируйте модули по командам и доменам через аспекты; проекции разрезают модель по этим осям — область PCI и событийный позвоночник получаются бесплатно.
Что дальше
Заголовок раздела «Что дальше»Глава 26: Событийный конвейер → — другая топология: аналитический конвейер, где события, принадлежащие производителю, — это основной механизм интеграции, и почти каждое ребро асинхронно.