Skip to content

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.

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.

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

Manifest:

package: acme.shop
version: "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.backend
use user from arch.extras

A 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.

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, and SessionStore are 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. Ledger is 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: true field 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.

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.

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.

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 Orders to OrderService — its stable ID (#c4m9) carries the identity.
  • Markdown-rich descriptions in hover tooltips, with [[Ledger]] cross-links and @security.zone interpolation 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.

  • 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 broker aspect (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.

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.