25. A SaaS Backend
This is the first of six worked designs in Part VI. Each chapter takes a concrete system shape and models it from scratch — modules, interfaces, processes, views, and (where relevant) types. The goal isn’t to teach features that were covered in earlier chapters; it’s to show what a complete model looks like in practice and what decisions you’ll face when you build your own.
The system this chapter: a small SaaS backend. Authentication, order management, payments, notifications. Mostly sync, with an async event spine. Five services, a shared financial ledger, a broker, four processes, three views. Roughly the size of an early-stage startup’s product backend.
This is also the first model where the best-practices patterns from earlier chapters all show up at once: single-owner databases nested inside their service, events owned by their producer, transport (the broker) attached as an aspect, never wired into the call path, and every service linked out to its real repository and contract. Watch for each.
What we’re modeling
Section titled “What we’re modeling”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.Two domains: commerce (Orders, Inventory, Payments) and platform (Auth, Notifications), with Finance owning the shared Ledger. One async spine — order and session events are owned by their producers and consumed by Notifications. The broker that carries them is a real module, but it sits on a separate plane and is reached through a broker aspect, not by routing calls through it.
Package layout
Section titled “Package layout”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 viewsManifest:
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.extrasA root manifest declares package: (the opaque unit of resolution). The use list is the explicit, discoverable palette for the whole package — anyone opening the manifest sees exactly what vocabulary is in play. Splitting modules by domain keeps each file under 100 lines.
The modules
Section titled “The modules”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"}Six services, one shared store, one broker. Note the team distribution — Platform, Commerce, Payments, Finance — and that ownership cascades into each nested store unless it says otherwise. Note the aspects: domain and security.zone cascade to every interface and nested module inside; we lean on that in the views.
Encapsulation, not edges.
OrdersDB,InventoryDB,PaymentsDB, andSessionStoreare each used by exactly one service, so they nest inside it — a technical detail of that service’s domain, not a sibling box wired by an arrow.Ledgeris the contrast: a Finance-owned domain asset that Payments merely writes to, so it sits at the root as a peer. The deciding question is ownership and sharing, never “is it a database.”
Mistake to avoid. Don’t model “auth” by adding a
requiresAuth: truefield to every command. Authentication is a concern of the call site (the actor or caller in the process), not of the receiver. The model captures who calls whom via processes; whether that call carries a session token is implementation, not architecture.
The processes
Section titled “The processes”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 is the central flow. Orders select one "payment outcome" captures the branch on authorization — select is the multi-way choice, and one runs the first matching case, so exactly one of authorized / declined fires. Each case-label names a branch, and the Orders prefix is the owner (the service that decides the outcome). The bare strings (Orders "publishes OrderPaid") are note steps: an actor-anchored annotation that renders as a note, not an edge. (There is no step : "label" syntax — annotate with a note step or an argument.)
Notice what the processes don’t contain: no step ever talks to OrdersDB, PaymentsDB, or SessionStore. Those are nested, single-owner stores — encapsulated detail the actor carries, omitted on purpose. Ledger, a shared peer, does appear, because skipping a high-level peer would hide a real dependency.
The notification fan-out is a process, not latent wiring. Each event interface lives on its producer (Orders.orderEvents, Auth.sessionEvents); Notifications couples to that contract by edging to it. Add a second consumer tomorrow and the producer’s file is untouched — exactly how pub/sub behaves. The arrowhead renders producer→consumer (data flows the other way from the dependency edge); that’s a render concern, not a modeling one.
The views
Section titled “The views”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}Three views. CustomerJourney is the all-up flow grouped by team — what you’d show in onboarding. PCIScope isolates the PCI subset for compliance; with security.zone: "PCI" on Payments, cascade pulls it through to PaymentsDB, and the view is literally show @@security.zone:"PCI". EventBackbone is the payoff for keeping transport on an aspect: show @@broker:EventBus reveals the broker plane — which interfaces ride it — without that plane ever cluttering the default call graph.
Decisions you’ll face when modeling your own
Section titled “Decisions you’ll face when modeling your own”Single-owner vs. shared stores. The question is ownership, not type. A database used by exactly one service is a detail of that service — nest it (OrdersDB). A store a second team owns or a second service writes to is a peer concern — root-level sibling (Ledger). Never draw a row of services each fanning out to its own cylinder.
Where the broker lives. On an aspect, not in the call path. A → broker → B buries the real dependency (who depends on whom) and points every service at the same node. Model the logical edge; attach the broker with aspect broker: EventBus. The broker is still a real module on the infra plane — reveal it with the EventBackbone view when you want it.
Sync vs async. rest_* and grpc_unary for sync request/response; kafka for events. The event interface lives on the producer; consumers point at it in a process. Don’t mix call shapes in one interface — the type conveys the shape.
Don’t reproduce the schema. Sketch a store informally and link out. Each database here carries a catalog.url to the real data catalog (DataHub, Amundsen, or a schema tool like Atlas/Liquibase) and each service a spec.url to its OpenAPI contract. ArchLang is a links hub, not a DDL or OpenAPI replacement.
Interface naming. REST → <action><Resource> (createOrder, getOrder); RPC → verbs (authorize, reserveStock). Group a coherent set into a surface (Notifications.Channels); leave a standalone interface on the module.
External actors. Customer is a user from the stdlib. For B2B integrations or third-party callers, declare them as external_system or user and put them on the left of process steps. They never appear on the right — they don’t expose interfaces to your model.
What this gives you
Section titled “What this gives you”After validation:
- A diagram with five service nodes, a shared ledger, a broker, the user, and edges derived from four processes — grouped by team.
- A PCI-scoped view and an event-backbone overlay, both automatic from aspects.
- Validation that every process step references a real interface.
- A diff engine that recognizes renames if you later rename
OrderstoOrderService— its stable ID (#c4m9) carries the identity. - Markdown-rich descriptions in hover tooltips, with
[[Ledger]]cross-links and@security.zoneinterpolation if you add it.
Total source: roughly 160 lines of .arch across five files. That’s the entire shape of the system, in text, in version control.
Summary
Section titled “Summary”- Model your domain by writing modules first, then drawing processes between them. Arrows in the diagram follow.
- Nest single-owner stores inside their service; keep shared/cross-owned stores as siblings.
- Events are owned by their producer; consumers edge to them in processes. Transport (the broker) is a
brokeraspect (aspect broker: EventBus), never a node in the call path. - Link every service to its repo and OpenAPI spec, every store to its data catalog — the model is a links hub, not a schema.
- Group modules by team and domain via aspects; views slice the model along those axes — PCI scope and the event backbone both fall out for free.
What’s next
Section titled “What’s next”Chapter 26: An Event-Driven Pipeline → — a different topology: an analytics pipeline where producer-owned events are the primary integration mechanism and almost every edge is async.