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. Six modules, four processes, two views. Roughly the size of an early-stage startup’s product backend.

What we’re modeling

Customer ──► Auth (Login, GetSession)
Customer ──► Orders (CreateOrder, GetOrder, CancelOrder)
Orders ──► Inventory (Reserve, Release)
Orders ──► Payments (Authorize, Capture, Refund)
Payments ──► Ledger (Record)
Orders ──► OrderEvents (event interface)
Notifications subscribes to Orders.OrderEvents

Two domains: commerce (Orders, Inventory, Payments, Ledger) and platform (Auth, Notifications). One async hop — order events on Orders fan out to a handler on Notifications.

Package layout

shop/
├── package.archspace
├── platform.arch # Auth, Notifications
├── commerce.arch # Orders, Inventory, Payments, Ledger
├── processes.arch # the four end-to-end flows
└── views.arch # the two saved views

Manifest:

name: acme.shop
version: "0.1.0"
use * from arch.modules
use * from arch.kinds

Two files of modules, one of processes, one of views. Splitting by domain keeps each file under 100 lines.

The modules

platform.arch:

service Auth {
team: Platform
labels {
domain: Platform
security.zone: Internal
}
"Authentication and session management."
command Login {
"Verify credentials and create a session token."
}
query GetSession {
"Look up a session by token."
}
event SessionEvents {
"Emitted when sessions are created, refreshed, or revoked."
}
}
service Notifications {
team: Platform
labels {
domain: Platform
security.zone: Internal
}
"Sends transactional email, SMS, and push notifications."
command SendEmail {
"Sent on OrderPaid and OrderCancelled events."
subscribes: Orders.OrderEvents
}
command SendSMS
command SendPush
}

commerce.arch:

service Orders {
team: Commerce
labels {
domain: Commerce
security.zone: Internal
}
"Order lifecycle — placement, cancellation, history."
command CreateOrder { "Place a new order. Returns an order ID." }
command CancelOrder { "Cancel an order before fulfillment." }
query GetOrder { "Look up an order by ID." }
event OrderEvents { "Lifecycle events: placed, paid, fulfilled, cancelled." }
}
service Inventory {
team: Commerce
labels {
domain: Commerce
security.zone: Internal
}
"Stock counts and reservation state."
command Reserve { "Reserve units against an order." }
command Release { "Release a prior reservation." }
query Check { "Check current available stock for a SKU." }
}
service Payments {
team: Payments
labels {
domain: Payments
security.zone: PCI
}
"Card payment processing — authorize, capture, refund."
command Authorize { "Authorize a payment hold." }
command Capture { "Capture an authorized payment." }
command Refund { "Issue a refund." }
}
service Ledger {
team: Finance
labels {
domain: Finance
security.zone: Internal
}
"Immutable financial record of every transaction."
command Record { "Record a financial event." }
}

Six modules. Note the team distribution: Platform, Commerce, Payments, Finance. Note the labels — domain and security.zone cascade to every interface inside; we’ll lean on that in the views.

Mistake to avoid. Don’t try to model “auth” by adding a requiresAuth: true field to every command. Authentication is a concern of the call site (the actor or the 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

processes.arch:

process #flow01 SessionStart {
Customer > Auth.Login
Auth > Auth.SessionEvents
}
process #flow02 Checkout {
Customer > Auth.GetSession : "validate session"
Customer > Orders.CreateOrder
Orders > Inventory.Reserve
Orders > Payments.Authorize
switch "payment outcome" {
authorized {
Orders > Payments.Capture
Payments > Ledger.Record
Orders > Orders.OrderEvents : "OrderPaid"
}
declined {
Orders > Inventory.Release
Orders > Orders.OrderEvents : "OrderFailed"
}
}
}
process #flow03 Cancellation {
Customer > Auth.GetSession
Customer > Orders.CancelOrder
Orders > Inventory.Release
Orders > Payments.Refund
Payments > Ledger.Record
Orders > Orders.OrderEvents : "OrderCancelled"
}

Checkout is the central business flow. switch "payment outcome" captures the two-way branch on authorization — each case-label (authorized, declined) names a branch. The labels after the colon (: "validate session", : "OrderPaid") annotate steps for diagram readability without changing semantics.

The notification fan-out is wired declaratively — Notifications.SendEmail carries subscribes: Orders.OrderEvents, which lives on the interface declaration in platform.arch. No explicit process is needed; the diagram renderer joins the event with its subscriber.

Alternative: explicit-process style. If you’d rather see the fan-out as a process for review-discoverability, drop the subscribes: and add:

process NotificationFanout {
Notifications > Notifications.SendEmail : "on OrderEvents"
}

Same architectural fact, different surface. The choice is taste — explicit processes are more discoverable; subscribes: is less duplicative. The validator and renderer accept both.

The views

views.arch:

view CustomerJourney {
focus domain: Commerce
focus domain: Platform
include "Auth.*", "Orders.*", "Inventory.*", "Payments.*", "Notifications.*"
group by team
layout elk
"End-to-end customer journey. Used in onboarding and architecture reviews."
}
view PCIScope {
focus security.zone: PCI
group by team
layout dagre
"Every service in PCI scope. For compliance review."
}

Two views. The first is the all-up customer journey grouped by team — what you’d show in onboarding. The second isolates the PCI-scoped subset for compliance reviews; with the labels in place it’s literally focus security.zone: PCI and the renderer does the work.

Decisions you’ll face when modeling your own

Module granularity. Should Orders be one module or several? Rule: if it has one team and one boundary, one module. The team can break it up internally with component sub-modules later (see the component stdlib kind in Chapter 11) without changing the external surface.

Sync vs async. Use command and query for sync; use event (with subscribes: on the handler) for async. Mixing them in one interface is wrong — the kind conveys the call shape.

Where to put labels. Labels go on whatever container best describes the classification’s scope. domain and security.zone belong on modules. criticality might belong on individual interfaces (a service can have one critical command and twenty advisory queries). When in doubt, put it on the broader scope and override down.

Process granularity. One process per business flow. If a flow has a clean conditional split, use if or switch. If it has independent parallel work, use parallel. Use try / catch for explicit error paths. Don’t try to encode every error path; capture the happy path and the major alternatives — exhaustiveness is the validator’s job, not the model’s.

External actors. Customer here is an actor from the stdlib. For B2B integrations or third-party callers, declare them as external_system or actor and put them on the left of process steps. They never appear on the right (because they don’t expose interfaces to your model).

What this gives you

After validation:

  • A diagram with six service nodes and the actor, edges derived from four processes, grouping by team.
  • A second diagram filtered to PCI scope.
  • Validation that every process step references a real interface.
  • A diff engine that recognizes renames if you, say, rename Orders to OrderService later.
  • Markdown-rich descriptions in hover tooltips, with [[Notifications]] cross-links and @security.zone interpolation if you add it.

Total source: roughly 120 lines of .arch across four files. That’s the entire shape of the system, in text, in version control.

Summary

  • Model your domain by writing modules first, then drawing processes between them. Arrows in the diagram follow.
  • Group modules by team and by domain via labels; views slice the model along those axes.
  • Use command/query for sync, event+subscribes: for async; pick explicit-process or subscribe-wiring style based on what’s clearer in your codebase.
  • Three-to-six modules per file, splitting by domain, is a comfortable density.
  • The PCI view is automatic once security.zone: PCI is on the relevant modules — labels do the work.

What’s next

Chapter 26: An Event-Driven Pipeline → — a different topology: queues, streams, fan-out fan-in, and subscribe wiring as the primary integration mechanism.